Rendering pipeline

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 in packages/sdk/src/static-renderer/types.ts and packages/sdk/src/viewer.tsx. This doc covers which one to reach for and why.


The smallest thing that works

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.


Three runtimes, pick one

RuntimeImport pathReach for it when…ShipsDoesn't ship
Editor@pagehub/sdkYou 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/viewerYou'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-rendererYou 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.


Editor — @pagehub/sdk

The 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).


Viewer — @pagehub/sdk/viewer

A 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:

  • React render walker — the same one the editor uses internally.
  • Theme variables, animation presets, condition evaluation, scroll observers.
  • Action handlers — forms submit, modals open, links navigate, state updates fire.
  • Lazy-loaded chunks for optional components (Audio, Data, Embed, Form, FormElement, Map, MapPoint, Video) so pages that don't use them never download their JS.

What's stripped out:

  • The whole editor chrome shell (pagehub-sdk-root, toolbox, inspector, settings panels).
  • TipTap, CodeMirror, GSAP editor harness, prop-system UI — all tree-shaken.
  • The browser Tailwind runtime (the viewer expects you to have compiled CSS already; pass it via your host page).

Good fit for: live published sites inside a React/Next host, preview routes, in-app page rendering, A/B variants.


Static renderer — @pagehub/sdk/static-renderer

Renders 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):

FieldWhat it's for
htmlThe page body (or full document if document: true).
classesEvery Tailwind class used. Feed into your Tailwind compile / purge step.
fontUrlsGoogle Font URLs to drop into <head> as <link> tags.
themeCSS:root block with palette, spacing, font CSS variables. Include in <style>.
scrollObserverScriptTiny 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.
breakpointsPer-site breakpoint overrides if the author customized them. Undefined means defaults.
renderErrorSet 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.


The two walkers — when you'd touch both

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.

WalkerFileRuns on
React walkerpackages/sdk/src/render/RenderTree.tsxEditor (/build), viewer, /view/*, and /static/* for sites with staticPublish: false.
Static walkerpackages/sdk/src/static-renderer/walker.tsrenderToHTML() 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.


Custom components — pass them to every runtime

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.


Where to go from here