Editor vs viewer vs static-renderer — why three runtimes, what each ships.
You have content from onSave. Now you need to put it on a page. The SDK ships three runtimes for that, all reading the same payload. This doc helps you pick.
The TypeScript shapes —
RenderToHTMLOptions,RenderToHTMLResult,PageHubViewerProps— live inpackages/sdk/src/static-renderer/types.tsandpackages/sdk/src/viewer.tsx. This doc covers which one to reach for and why.
You saved a page. You want to render it.
// In a Node script, edge worker, or getStaticProps
import { renderToHTML } from "@pagehub/sdk/static-renderer";
const { html, classes, fontUrls } = renderToHTML(savedContent);
// In a React app
import { PageHubViewer } from "@pagehub/sdk/viewer";
<PageHubViewer content={savedContent} />;
// You want the full drag-and-drop editor
import { PageHubEditor } from "@pagehub/sdk";
<PageHubEditor callbacks={{ onLoad, onSave }} />;
All three read the same content string. All three produce the same visual output for the same input.
| Runtime | Import path | Reach for it when… | Ships | Doesn't ship |
|---|---|---|---|---|
| Editor | @pagehub/sdk | You want the drag-and-drop builder on /edit/<id> or similar. | Full editor chrome, toolbox, inspector, TipTap, CodeMirror, GSAP harness, browser Tailwind runtime. | — |
| Viewer (React) | @pagehub/sdk/viewer | You're rendering published pages inside a React app and want runtime interactivity. | React render walker, theme + animation presets, action handlers, condition eval, scroll observers. | Editor chrome, TipTap, CodeMirror, GSAP editor harness, prop-system UI. |
| Static renderer | @pagehub/sdk/static-renderer | You want plain HTML — no React, no DOM — for SSG, edge, email, or pre-render pipelines. | Pure string walker, theme CSS generator, class collector, optional vanilla runtime for interactivity. | React, ReactDOM, any browser API. Runs in Bun, Deno, Cloudflare Workers, Node. |
Same content payload everywhere. Pick the lightest one that does the job.
@pagehub/sdkThe full visual builder. Mount it on a route only authors hit (/edit/<id>, /admin/pages/<id>).
import { PageHubEditor } from "@pagehub/sdk";
import "@pagehub/sdk/editor.css";
<PageHubEditor
callbacks={{
onLoad: async () => fetch("/api/page").then(r => r.json()),
onSave: async data => fetch("/api/page", { method: "PUT", body: JSON.stringify(data) }),
}}
/>;
You get the canvas, the toolbox, the inspector, multi-page nav, SEO panel, responsive preview, and a runtime Tailwind compiler so dynamically-added classes paint immediately. See host-configuration.md for every config field.
The editor renders content through the same component code the viewer does, so the canvas mirrors what users will see — minus interactive actions, which the editor intentionally suppresses (see actions-and-handlers.md).
@pagehub/sdk/viewerA read-only React renderer for live pages. Same component tree the editor uses, none of the authoring UI.
import { PageHubViewer } from "@pagehub/sdk/viewer";
<PageHubViewer
content={savedContent}
components={myCustomComponents} // same array you pass to the editor
/>;
What you get:
What's stripped out:
pagehub-sdk-root, toolbox, inspector, settings panels).Good fit for: live published sites inside a React/Next host, preview routes, in-app page rendering, A/B variants.
@pagehub/sdk/static-rendererRenders a CraftJS tree to a plain HTML string. No React imported, no DOM touched. Runs anywhere a function can run.
import { renderToHTML } from "@pagehub/sdk/static-renderer";
// From compressed content (what onSave gives you)
const { html, classes, fontUrls, themeCSS, seo, scrollObserverScript } =
renderToHTML(savedContent);
// From raw JSON
const { html } = renderToHTML(jsonString, { compressed: false });
// Standalone document, ready to write to disk or pipe into an email
const { html } = renderToHTML(savedContent, {
document: true,
title: "My Page",
includeThemeVars: true,
});
What it returns (RenderToHTMLResult):
| Field | What it's for |
|---|---|
html | The page body (or full document if document: true). |
classes | Every Tailwind class used. Feed into your Tailwind compile / purge step. |
fontUrls | Google Font URLs to drop into <head> as <link> tags. |
themeCSS | :root block with palette, spacing, font CSS variables. Include in <style>. |
scrollObserverScript | Tiny script for ph-anim-scroll classes. Empty string if the page uses no scroll animations. |
seo | { title, description, ogImage, jsonLd, schema } extracted from ROOT.props. |
breakpoints | Per-site breakpoint overrides if the author customized them. Undefined means defaults. |
renderError | Set when the tree is invalid (missing ROOT, etc.). Display this instead of treating output as valid HTML. |
Document mode. document: true wraps everything in <!DOCTYPE html><html><head>…</head><body>…</body></html> with theme vars inlined, fonts linked, and SEO meta tags emitted. Drop the string straight into a file, an email, or an edge response.
Vanilla runtime opt-in. Pass runtime: true to inline a small IIFE at the end of <body> that hydrates data-state-*, data-ph-actions, data-ph-map, forms, connector refetch, and customer-token detection. This is how a fully static export still gets show-hide, modals, and state actions — no React required. See actions-and-handlers.md and state-system.md.
Good fit for: SSG (getStaticProps, Astro, 11ty), next export, on-publish HTML uploads to a CDN, email rendering, search-engine pre-render snapshots, static site exports for hosting elsewhere.
There are actually two render walkers under the hood. Both produce the same output for the same content; one runs as React, one emits strings.
| Walker | File | Runs on |
|---|---|---|
| React walker | packages/sdk/src/render/RenderTree.tsx | Editor (/build), viewer, /view/*, and /static/* for sites with staticPublish: false. |
| Static walker | packages/sdk/src/static-renderer/walker.ts | renderToHTML() calls, plus /static/* and custom domains when the site has staticPublish: true. |
For sites with staticPublish: true, proxy.ts rewrites /static/<id> to /api/published/<id>, which calls the static walker. The URL doesn't tell you which walker ran — a Mongo flag does. See .claude/known-issues/two-walkers-rendertree-and-static.md.
Component-level changes — edit the component's .render.tsx (React) and .toHTML.ts (static) together. Same dual-write rule, scoped to one file pair.
Walker-level changes — patch BOTH walker files when you change tree-level rendering: ROOT-child filtering, per-page gating, sibling stripping, new walker-level condition handling, or anything that decides whether a subtree renders. Forget one and you'll ship a fix that works on /view/<id> but silently misbehaves on production custom domains (or vice versa).
Interactivity changes — React walker uses standard React event handlers. Static walker has its own vanilla runtime under static-renderer/runtime/ for show-hide, conditions, repeaters, and the cart bridge. New interactive primitives need a runtime chunk there too if they should work on static-published sites.
When you register a custom component with defineComponent, you have to hand the same array to whichever runtimes will render it:
import { defineComponent } from "@pagehub/sdk";
const PricingCard = defineComponent({
name: "PricingCard",
component: PricingCardReact, // used by the editor + viewer
toHTML: pricingCardToHTML, // used by the static renderer
});
// Editor
<PageHubEditor components={[PricingCard]} />
// Viewer
<PageHubViewer content={savedContent} components={[PricingCard]} />
// Static renderer
renderToHTML(savedContent, { components: [PricingCard] });
Skip the components arg on the viewer or static renderer and any PricingCard node will render as a blank wrapper (or crash with Cannot destructure 'type' of … if the resolver map can't find the type). See registration-host.md for the full defineComponent shape and extensibility.md for the runtime-registration variants.
publishStateKeys → state-system.md