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
PageHubConfigintypes.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.
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.
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.
| Field | Type | What it does |
|---|---|---|
container | HTMLElement | string | Vanilla JS only — the DOM node (or selector) to mount into. React users skip this. |
pageId | string | Initial page to load. If omitted, the SDK calls onLoad(undefined) and you decide. |
readOnly | boolean | Start in viewer mode (no editor chrome). Default false. |
apiBaseUrl | string | Base URL for SDK-managed server calls (AI, media uploads, etc.). Required for any feature that talks to your server. |
apiKey | string | PageHub Cloud API key. Skip this when you're self-hosted. |
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.
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 */",
}
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.
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.
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.
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:
<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.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.
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).
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.
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.
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.
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.
createRegistriesBundle()); host bindings register after.0. SDK builtins do NOT set explicit priorities — they all sit at 0.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.priority (e.g. 50). Anything > 0 wins outright.priority: -1.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.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.
query, actions, ecosystemWhen 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).
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 field | New slot id |
|---|---|
renderToolboxAiButton | toolbox/ai-button |
renderInlineCopyAssistantTrigger | tiptap/inline-copy-assistant |
settingsAiButton | settings/ai-button |
renderDataSourceSection | node/data-source-section |
renderNodeAiGenerateButton | node/ai-generate-button |
renderNodeAiContextButton | node/ai-context-button |
renderNodeAiContextEditor | node/ai-context-editor |
renderEmptyStateAiCard | empty-state/ai-card |
renderNavAiMenuItem | navmenu/ai-row |
renderNavHeaderItems | navmenu/header-items |
renderImportExportHandoffExtras | import-export/handoff-extras |
renderMediaEditAiActions | media-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.
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: { 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.
Override built-in strings, format dates, etc. See PageHubLocale in types.ts.
locale: {
language: "en",
strings: { "editor.save": "Salvar" },
}
Three ways to extend the catalog. All optional; all composable.
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]} />
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.
The SDK gives you slots inside its UI; you render whatever React you want.
| Field | Slot |
|---|---|
aiPanel | Docked AI assistant panel (shown when features.aiGeneration is true). |
editorChromeSlots.renderMediaEditAiActions | AI 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.
Two narrow knobs that shape specific UI surfaces. Most apps never touch these.
curatedGoogleFontFamiliesShapes 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.
viewportDevicePresetsDrives 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.
| Field | What it does |
|---|---|
cdn | Cloudflare 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. |