Host integration

PageHubConfig shape, feature flags, callbacks, locale, AI config.

Everything you need to wire the SDK into your app, in one config object. Pass it to <PageHubProvider config={...}> (React) or PageHub.init(...) (vanilla JS). Same shape, same fields, same behavior.

Looking for the full TypeScript type? See PageHubConfig in types.ts. This doc covers what each field is for and when you'd reach for it — the type file is the source of truth for the shape.


The smallest config that works

import { PageHubEditor } from "@pagehub/sdk";

<PageHubEditor
  callbacks={{
    onLoad: async () => fetch("/api/page").then(r => r.json()),
    onSave: async data => {
      const res = await fetch("/api/page", { method: "PUT", body: JSON.stringify(data) });
      if (!res.ok) return { ok: false, reason: `HTTP ${res.status}`, status: res.status };
      const { pageId, updatedAt } = await res.json();
      return { ok: true, pageId, updatedAt };
    },
  }}
/>;

That's it. Two callbacks. Everything else is optional.


A fully wired config

The same config shape supports as much customization as you want to layer on:

<PageHubEditor
  // ─── Mount ─────────────────────────────────────────
  apiBaseUrl="https://my-app.com/api"
  pageId="home"
  readOnly={false}

  // ─── Required: callbacks ───────────────────────────
  callbacks={{ onLoad, onSave, onPublish, onMediaUpload }}

  // ─── Visual customization ──────────────────────────
  theme={{ primaryColor: "#2563eb", logo: "/my-logo.svg" }}
  features={{ aiGeneration: true, customCSS: false }}
  locale={{ language: "en", strings: { /* overrides */ } }}

  // ─── Component extensions ──────────────────────────
  components={[MyPricingCard, MyChart]}
  presets={{ Container: [...containerPresets, ...mine] }}
  modifiers={{ Button: [...buttonModifiers, ...mine] }}

  // ─── Host-rendered chrome ──────────────────────────
  aiPanel={<MyAiAssistant />}
  editorChromeSlots={{ renderMediaEditAiActions: (ctx) => <MyMediaAiActions {...ctx} /> }}

  // ─── Picker tuning ─────────────────────────────────
  curatedGoogleFontFamilies={{ popular: [...] }}
  viewportDevicePresets={[{ name: "iPhone 15", width: 393, height: 852, dpr: 3 }]}

  // ─── Misc ──────────────────────────────────────────
  apiKey={process.env.PAGEHUB_API_KEY}
  cdn={{ accountHash: "abc123" }}
  urlStrategy="pushState"
/>;

Everything below explains each section in detail.


Mount fields

FieldTypeWhat it does
containerHTMLElement | stringVanilla JS only — the DOM node (or selector) to mount into. React users skip this.
pageIdstringInitial page to load. If omitted, the SDK calls onLoad(undefined) and you decide.
readOnlybooleanStart in viewer mode (no editor chrome). Default false.
apiBaseUrlstringBase URL for SDK-managed server calls (AI, media uploads, etc.). Required for any feature that talks to your server.
apiKeystringPageHub Cloud API key. Skip this when you're self-hosted.

Callbacks (required)

callbacks is the only required field. Two functions get you running; the rest are opt-in. See PageHubCallbacks in types.ts for the full list.

callbacks: {
  // Required
  onLoad: async pageId => fetch(...),               // return PageData | null
  onSave: async (data, meta) => ({                  // must return SaveResponse
    ok: true, pageId, updatedAt,
  }),

  // Common opt-ins
  onChange: data => {},                             // every edit (debounced)
  onPublish: async data => fetch(...),              // user hit Publish
  onMediaUpload: async file => uploadAndReturnUrl,  // file uploads
  onMediaDelete: async url => fetch(...),           // file deletes
}

onSave receives a PageData object containing content (compressed editor state — store this to reload later), html (rendered static HTML), classes (Tailwind classes for purging), title, and seo. It must return a SaveResponse: { ok: true, pageId, updatedAt } on success, { ok: false, reason, status? } on failure, or { ok: false, conflict: { currentUpdatedAt } } on optimistic-concurrency conflict so the SDK can surface the reload/override modal.


Theme

Quick brand customization. For full theming see theme.md.

theme: {
  primaryColor: "#2563eb",
  secondaryColor: "#7c3aed",
  accentColor: "#06b6d4",
  colorScheme: "system",          // "light" | "dark" | "system"
  logo: "/my-logo.svg",           // shown in editor toolbar
  cssVariables: { brand: "#f00" }, // any CSS var, no `--` prefix needed
  customCSS: "/* injected raw */",
}

Extending the editor — commands, menus, slots, keybindings

The editor exposes four registries off the SDK instance. Together they cover every "add a button / shortcut / panel / React-slot" extension point. Same shape three times (commands/menus/slots) + keybindings — learn one mental model, reuse it everywhere.

You want…Use
"Add a button users can click to do X"Command + menu contribution placing it.
"Replace / restyle a built-in button's chrome but keep its action"Menu item iconOverride / titleOverride.
"Render arbitrary React inside a specific spot — multi-input form, badge, custom layout"Slot contribution.
"Inject a whole tab into a modal"List slot — register your own (no builtin list slots remain).
"Define a new keyboard shortcut"Command + keybinding.
"Listen to lifecycle events"PageHubEmitter (on("save", ...)) — separate from registries.

The catalog of SDK builtins is at builtin-commands.md.

Quickstart — first 10 lines

import {
  PageHubEditor,
  PageHubProvider,
  createRegistriesBundle,
} from "@pagehub/sdk";

// 1. Build a bundle (builtins pre-registered). Memoize per editor mount.
const bundle = createRegistriesBundle();

// 2. Contribute host commands / slots / menus / keybindings BEFORE mount.
bundle.commands.register({
  id: "acme.editor.publishStaging",
  title: "Publish to staging",
  category: "File",
  run: async ({ query }) => {
    await fetch("/api/staging", { method: "POST", body: query.serialize() });
  },
});
bundle.menus.contribute("topbar", [{ command: "acme.editor.publishStaging", group: "publish@10" }]);
bundle.keybindings.register({ command: "acme.editor.publishStaging", key: "shift+mod+p" });
bundle.slots.contribute({
  slot: "navmenu/ai-row", // BuiltinSlotId autocompletes
  render: ({ close }) => <MyAiPanel onClose={close} />,
});

// 3. Pass the bundle to the provider — surfaces read from it.
<PageHubProvider registries={bundle}>
  <PageHubEditor callbacks={{ onLoad, onSave }} />
</PageHubProvider>;

The bundle is the wiring; the provider activates it. Contribute before you mount — surfaces snapshot the registries on mount, so post-mount contributions only show up after subscribe() rerenders the consumer. Subsequent sub-sections drill into each registry.

Accessing the registries

Anywhere you can reach the SDK instance — from a host React component via useSDK(), or from your bootstrap code via PageHub.init(config).sdk — the four registries hang off it:

const { commands, menus, slots, keybindings, context } = sdk;

context is the live snapshot of when-clause inputs (selection, mode, features, host-set keys via context.set(...)). Use it inside when predicates to decide whether a command / menu item / slot contribution applies.

Building the bundle — where to call createRegistriesBundle()

createRegistriesBundle() is not idempotent across calls: each invocation builds fresh registries with the builtins pre-registered, so calling it twice gives you two separate worlds. Pick one of:

  • Module scope (simple, single-editor apps). Build once at module load, pass to <PageHubProvider registries={bundle}>. Works fine when the editor mounts once per page reload — the canonical example is components/PageHubEditorIntegration.tsx. Caveat: if your host hot-swaps the editor (multi-tab editors, route changes that remount the provider, HMR), module-scope bundles will keep stale state from the previous mount. Use the next option for those cases.
  • Per-mount via useMemo (hot-reload-safe). Build inside the component that renders <PageHubProvider>:
    function MyEditor() {
      const bundle = useMemo(() => {
        const b = createRegistriesBundle();
        applyMyHostContributions(b);
        return b;
      }, []);
      return <PageHubProvider registries={bundle}>{/* … */}</PageHubProvider>;
    }
    
    [] deps because the bundle survives the component's lifetime; if you need to rebuild on tenant/user change, key the parent on that id so React unmounts → remounts → fresh memo.

If you contribute host slots / commands from outside the provider tree (e.g. a top-level applyHostEditorChromeSlots(bundle.slots) next to the bundle build), keep that call colocated with the build — same lifecycle, same scope.

Hide a built-in command

sdk.commands.unregister("ph.editor.save");

Removing a command auto-removes any menu entries that referenced it (filter-on-read; nothing to clean up by hand).

Override a built-in run body (keep the same id + menu placement)

Use commands.replace(), not register()register() throws CommandRegistryError({ code: "COMMANDS_DUPLICATE" }) on collision so accidental duplicates surface loudly. replace() requires the id to already exist (throws COMMANDS_NOT_FOUND if not) so a typo in a builtin id can't silently shadow nothing.

sdk.commands.replace({
  id: "ph.editor.save",                  // must already be registered (a builtin)
  title: "Save (to my server)",
  category: "File",
  run: async (ctx) => {
    const json = ctx.query.serialize();
    await fetch("/my/save", { method: "POST", body: json });
  },
});

The new entry inherits the builtin's keybinding (⌘S) and menu placement (topbar). All you replaced is the run body.

Add a host command + menu entry

sdk.commands.register({
  id: "acme.editor.publishStaging",
  title: "Publish to staging",
  category: "File",
  when: (ctx) => Boolean(ctx.features.publishButton),
  run: async (ctx) => {
    await fetch("/my/publish-staging", { method: "POST" });
  },
});

sdk.menus.contribute("topbar", [
  { command: "acme.editor.publishStaging", group: "save@65" },
]);

Group keys are groupName@order; the resolved sort is globally monotonic across all contributors, so picking save@65 lands your button between the builtin save (save@70) and view chip (view@60) — see the builtin topbar list at the top of builtins/menus.ts for the existing ordering.

Contribute a host React slot

import { SlotRenderer } from "@pagehub/sdk";

sdk.slots.contribute({
  slot: "node/ai-context-button",
  render: (ctx) => (
    <button onClick={ctx.onClick} className={ctx.className} disabled={ctx.disabled}>
      <MyWandIcon /> {ctx.label ?? "AI"}
    </button>
  ),
});

The SDK renders the contributed node wherever it mounts <SlotRenderer id="node/ai-context-button" ctx={...} />. Single-cardinality slots take the highest-priority contributor; list-cardinality slots accumulate by group@order. The full slot id list is in builtin-commands.md → Slots.

Register a custom keybinding

sdk.keybindings.register({
  command: "acme.editor.publishStaging",
  key: "shift+mod+p",
  when: (ctx) => !ctx.tiptap?.active,
  priority: 50,
});

Key syntax: mod (⌘ on macOS, Ctrl elsewhere), shift, alt, then the key — e.g. mod+s, shift+mod+m, backspace, escape.

Keybinding priority + collision rules

  • All bindings live in the same flat list. Builtins register first (during createRegistriesBundle()); host bindings register after.
  • Default priority is 0. SDK builtins do NOT set explicit priorities — they all sit at 0.
  • Dispatcher resolution: the matcher scans every binding whose chord matches the event, applies when(ctx), then keeps the highest priority. Ties (including the all-zero default) break to last-registered wins, which means host bindings beat builtins on collision because they register after.
  • To intentionally beat a builtin without relying on registration order, set a positive priority (e.g. 50). Anything > 0 wins outright.
  • To intentionally lose to a builtin, register at priority: -1.
  • No shadow-detection — registering mod+s doesn't warn that ph.editor.save already binds it. Pick chords from outside the builtin-commands.md list, or override with explicit priority.

Command id namespace conventions

  • ph.* — reserved for SDK builtins. Listed in builtin-commands.md. Use sdk.commands.replace({ id: "ph.editor.save", ... }) to override a builtin's run body; register() is strict on collision so accidental duplicates throw CommandRegistryError({ code: "COMMANDS_DUPLICATE" }).
  • <host-prefix>.* — pick one and use it everywhere (acme.*, myapp.*, etc.). This keeps host commands clearly separable from SDK commands in the palette + dev tools.

Hosts should NOT use ph.* for new commands. Stick with your own prefix.

Run-context backrefs — query, actions, ecosystem

When commands.execute(...) fires a run body, the SDK augments the CommandContext with three editor backrefs:

run: (ctx, args) => {
  ctx.query                    // craft query (read editor state)
  ctx.actions                  // craft actions (mutate editor state)
  ctx.trigger                  // "menu" | "palette" | "keybinding" | "api" | "host"
  // plus any atom you need via setAtomExternal(...) from @pagehub/sdk
}

Use ctx.query.getEvent("selected").first() for the current selection (or fall back to ctx.selection.id from the snapshot if you're not in canvas), ctx.actions.move(...) / selectNode(...) / setProp(...) for mutations, and ctx.trigger to branch on how the command was invoked (some surfaces want different UX for keybinding vs menu vs palette).

Migration — old editorChromeSlots.render* → new sdk.slots.contribute(...)

The 12 editorChromeSlots.render* fields on PageHubConfig are deprecated (still work via an adapter shim — host code doesn't have to change today). Each maps to a slot id; migrate at your leisure:

Old fieldNew slot id
renderToolboxAiButtontoolbox/ai-button
renderInlineCopyAssistantTriggertiptap/inline-copy-assistant
settingsAiButtonsettings/ai-button
renderDataSourceSectionnode/data-source-section
renderNodeAiGenerateButtonnode/ai-generate-button
renderNodeAiContextButtonnode/ai-context-button
renderNodeAiContextEditornode/ai-context-editor
renderEmptyStateAiCardempty-state/ai-card
renderNavAiMenuItemnavmenu/ai-row
renderNavHeaderItemsnavmenu/header-items
renderImportExportHandoffExtrasimport-export/handoff-extras
renderMediaEditAiActionsmedia-edit/ai-actions

The adapter shim continues to work, but the deprecated fields will be removed in the next major version. New integrations should use sdk.slots.contribute(...).

siteSettingsExtraTabs and pageSettingsExtraTabs are not in this table — Site Settings and Page Settings were both moved out of the SDK (Site Settings in main commit fd8aa69e, Page Settings in cdb7e4ba; both live in the dashboard under /dashboard/sites/[id]/...). Hosts that need extra tabs there add them to the dashboard route directly. With both gone, no builtin list slots remain — the only injectable list surfaces are ones the host registers itself.


Features

Turn editor capabilities on or off. See PageHubFeatures in types.ts for the full list.

features: {
  sidebar: true,                  // component panel (toolbox)
  toolbar: true,                  // top toolbar
  saveButton: true,               // save/publish button in top toolbar
  aiGeneration: false,            // AI content generation (needs `ai` config)
  multiPage: true,                // multi-page site editing
  responsivePreview: true,        // device preview toggle
  seoPanel: true,                 // SEO settings panel
  importExport: true,             // Import/Export row in More menu
  settingsPanelSwitcher: true,    // "Left/Right Settings Panel" row in More menu
  darkModeSwitcher: true,         // "Switch to Dark/Light Theme" row in More menu
  customCSS: false,               // CSS editor panel
  restrictedComponents: [],       // deny-list (use `registerComponentAllowlist` for allow-list)
}

For constrained modes (email, kiosk) where you want to lock down the editor instead of just toggling features, see host-constraints.md.


AI

ai: { enabled: true }

Drives the AI assistant button + the panel slot. To actually render an assistant UI, also provide aiPanel: <YourAssistant /> — the SDK gives you a layout slot; you own the API calls and the UI.


Locale

Override built-in strings, format dates, etc. See PageHubLocale in types.ts.

locale: {
  language: "en",
  strings: { "editor.save": "Salvar" },
}

Component extensions

Three ways to extend the catalog. All optional; all composable.

Custom components

Build your own draggable component. One function, one config field. See registration-host.md for the full guide.

import { defineComponent } from "@pagehub/sdk";

const PricingCard = defineComponent({ name: "PricingCard", component: ..., toHTML: ... });

<PageHubEditor components={[PricingCard]} />

Presets / modifiers

Add variant chips to existing components. Replaces the built-in list — use the @pagehub/sdk/presets/<component> sub-path to compose with built-ins:

import { containerPresets } from "@pagehub/sdk/presets/container";

<PageHubEditor
  presets={{ Container: [...containerPresets, myCustomPreset] }}
  modifiers={{ Button: [...buttonModifiers, myCustomModifier] }}
/>

You can also register these at runtime via registerPresets / registerModifiers — see extensibility.md.


Host-rendered chrome

The SDK gives you slots inside its UI; you render whatever React you want.

FieldSlot
aiPanelDocked AI assistant panel (shown when features.aiGeneration is true).
editorChromeSlots.renderMediaEditAiActionsAI action block inside the Media edit modal.

The SDK provides layout and styling primitives; the host owns API calls, auth, and business logic. Use the useSDK() hook inside your components if you need the SDK emitter or config.


Picker tuning

Two narrow knobs that shape specific UI surfaces. Most apps never touch these.

curatedGoogleFontFamilies

Shapes the editor's font picker — the two rails (popular, funky) and the offline extended fallback list. Does not affect what fonts ship to published sites (that's driven by styleGuide + font-* classes on the page).

curatedGoogleFontFamilies: {
  popular: ["Inter", "Space Grotesk", "Geist"],
  funky: ["Bagel Fat One", "Caprasimo"],
  extended: ["Roboto", "Lato", /* ...used when Google Fonts API is down */],
}

Default: DEFAULT_CURATED_GOOGLE_FONT_FAMILIES. Spread from it if you only want to tweak one rail.

Tip: memoize the config object (or this field) when building inline — a new identity on every render re-runs the sync effect and wipes the in-memory fonts cache.

viewportDevicePresets

Drives the device-mode dropdown in the responsive preview chrome.

viewportDevicePresets: [
  { name: "iPhone 15", width: 393, height: 852, dpr: 3 },
  { name: "iPad", width: 820, height: 1180, dpr: 2 },
  { name: "Custom", width: 1280, height: 800, dpr: 1 },
]

Default: DEFAULT_VIEWPORT_DEVICE_PRESETS. Include a "Custom" row if you want the same manual-resize UX as the stock list.


Misc

FieldWhat it does
cdnCloudflare Images delivery config — accountHash, baseUrl, variant. Required if you use the built-in image upload pipeline.
urlStrategy"pushState" makes browser back / forward navigate between pages. Omit for in-memory-only switching.

Where to go from here