Adding custom components

defineComponent, the resolver, props schema, toHTML, settings panels.

You're a developer using @pagehub/sdk and you want to add your own draggable component — a pricing card, a custom embed, a chart widget — that lives in your app's codebase, not the SDK's.

This is one file, one function call, one config field. No fork, no patch, no rebuild of the SDK.

If you are modifying the SDK itself to add a new built-in (Container, Button, Form etc.), see registration.md — different surface, 8 places inside packages/sdk.


TL;DR

import { PageHubEditor, defineComponent } from "@pagehub/sdk";
import { PricingCard } from "./components/PricingCard";

const PricingCardDef = defineComponent({
  name: "PricingCard",
  displayName: "Pricing Card",
  category: "Marketing",
  component: PricingCard,
  defaultProps: { title: "Pro", price: 29, period: "mo" },
  props: {
    title: { type: "text", label: "Plan Name" },
    price: { type: "number", label: "Price", min: 0 },
    period: { type: "select", label: "Period", options: [
      { label: "Month", value: "mo" },
      { label: "Year", value: "yr" },
    ]},
  },
  toHTML: ({ props }) =>
    `<div class="rounded-box border p-6"><h3>${props.title}</h3><p>$${props.price}/${props.period}</p></div>`,
});

export default function Builder() {
  return (
    <PageHubEditor
      components={[PricingCardDef]}
      callbacks={{ onLoad: ..., onSave: ... }}
    />
  );
}

Drop it onto the canvas from the toolbox's Marketing category. Done.


The flow

  1. Build your React component as you'd build any other React component. No PageHub imports required.
  2. Wrap it in defineComponent(...) to declare its name, default props, settings schema, and an HTML serializer for static export.
  3. Pass the resolved def into PageHubConfig.components (an array). The SDK merges it with the built-in catalog.
  4. (Optional) Add inspector inputs beyond the simple props schema by registering custom properties — see extensibility.md.

defineComponent(def, opts?)

Returns a frozen ResolvedComponentDef you pass via PageHubConfig.components. Same descriptor is used by the editor, the viewer, and renderToHTML() — define once, render everywhere.

Required fields

FieldTypeNotes
namestring (PascalCase)Unique. Must NOT collide with a built-in (Container, Text, Button, etc.) — throws at register time.
componentReact.ComponentType<Props>Your React component. Receives the resolved props as the first argument.

Highly recommended

FieldTypeNotes
displayNamestringShown in toolbox + inspector. Defaults to a humanized version of name.
descriptionstringToolbox tooltip.
categorystringToolbox section. Defaults to "Custom". Use an existing category to group with built-ins.
defaultPropsPartial<Props>Applied when a new instance is dropped on the canvas.
propsRecord<string, PropSchema>Simple inspector schema — text / number / select / checkbox / slider / color / url / textarea. The SDK auto-generates the settings panel.
toHTML(node) => stringSerializer for renderToHTML() / static export. The SDK ships a generic fallback that wraps children in a <div> — provide your own for non-trivial components.
iconstring | ReactElementToolbox icon. Use ref-icon:tb/TbStar style refs or a React element.

Advanced fields

FieldTypeNotes
canvasbooleanWhen true, the component accepts child nodes (drop targets).
settingsReact.ComponentTypeCustom React panel for the inspector — replaces the auto-generated props UI.
advancedSettingsReact.ComponentTypeCustom panel for the inspector's Advanced tab.
rulesCraftRulesCraftJS drop-validation rules (canMoveIn, canDrop, etc.).
disablestring[]Disable specific inspector tabs (e.g. ["Layout"]).
peerInheritPeerInheritConfigInherit className chrome from a sibling on insertion.

PropSchema reference

{
  type: "text" | "number" | "select" | "checkbox" | "slider" | "color" | "url" | "textarea",
  label: string,
  default?: any,
  options?: Array<{ label: string; value: string }>, // select only
  min?: number, max?: number, step?: number,          // slider / number
  description?: string,                                // help text below input
  section?: string,                                    // group in the inspector. Default: "Content"
}

This is the fast path. For richer inputs (color picker with palette presets, conditional fields, dynamic options, custom React widgets), drop the props schema and provide a settings component instead — or use registerProperties to inject inputs across multiple components.


Wiring components into the editor

React

import { PageHubEditor } from "@pagehub/sdk";
import { PricingCardDef, ChartDef, EmbedDef } from "./pagehub-components";

<PageHubEditor
  components={[PricingCardDef, ChartDef, EmbedDef]}
  callbacks={{ onLoad, onSave }}
/>;

Vanilla JS

import PageHub from "@pagehub/sdk";
import { PricingCardDef } from "./pagehub-components";

PageHub.init({
  container: "#editor",
  components: [PricingCardDef],
  callbacks: { onLoad, onSave },
});

Viewer / static renderer

The same components array goes into the read-only paths:

import { PageHubViewer } from "@pagehub/sdk/viewer";

<PageHubViewer content={saved} components={[PricingCardDef]} />;
import { renderToHTML } from "@pagehub/sdk/static-renderer";

const { html } = renderToHTML(saved, { components: [PricingCardDef] });

If you omit components from the viewer or renderer, custom nodes render as their toHTML fallback (or break, if the SDK can't find the def). Always ship the same array everywhere.


Static HTML export (toHTML)

The static renderer walks the saved tree and asks each component def for an HTML string. The default fallback wraps children in <div className={node.props.className}> — fine for layout containers, wrong for anything with real markup.

toHTML: ({ props, children, node }) => {
  // `children` is the already-rendered HTML string of child nodes
  // `node` is the raw CraftJS node (id, props, type, displayName)
  return `
    <article class="rounded-box border ${props.className ?? ""}">
      <h3>${escapeHtml(props.title)}</h3>
      <p>$${props.price}/${props.period}</p>
      ${children}
    </article>
  `;
}

Notes:

  • toHTML runs in plain Node (no React, no browser). Don't useState, don't useEffect, don't import a React-only dep.
  • Escape any string interpolated into HTML — the SDK does not do this for you.
  • Same toHTML powers renderToHTML(), the viewer's SSR pass, and editor.exportHTML().

Custom inspector UIs

The inspector has two distinct surfaces, and they take different APIs — picking the wrong one is the most common confusion point:

SurfaceWhat goes thereHow to populate
Component's own "Content" tab (the main tab when selected)Inputs specific to this one component — title, price, image src, etc.def.props (fast path) or def.settings (full control)
Shared cross-component tabs (Layout, Background, Animation, Advanced, your own custom tabs)Inputs that apply to many components — analytics ID, tracking events, custom CSS classesregisterProperties

The two surfaces don't overlap — def.props only renders the Content tab, registerProperties only renders the shared tabs. You can use both at once on the same component without conflict.

Three options for the Content tab, in order of complexity:

  1. props schema — auto-generated UI. Best for 1-5 simple inputs.
  2. settings field — your own React component renders the entire content tab.
  3. registerProperties — for inputs that should appear on multiple components, not just yours. Lives in the shared tabs (not your component's Content tab).

The settings component receives the current node and a setter:

function PricingCardSettings() {
  const { actions, query } = useEditor();
  const { id, props } = useNode(node => ({ id: node.id, props: node.data.props }));
  return (
    <div>
      <label>Title</label>
      <input
        value={props.title}
        onChange={e => actions.setProp(id, p => (p.title = e.target.value))}
      />
      {/* ... */}
    </div>
  );
}

For the full property-registry surface (sections, input types, conditional visibility), see sidebar-property-registry.md.


Common gotchas

  • Name collisionsdefineComponent throws if name matches a built-in (Container, Text, Button, etc.). Pick a host-specific prefix (MyApp_Card) or check BUILT_IN_NAMES before naming.
  • components arrays must match across runtimes — if the editor knows about PricingCardDef but the viewer doesn't, viewer pages with that node will render empty or fall back to the SDK's generic wrapper.
  • defaultProps must be JSON-serializable — functions, class instances, and React elements don't survive the save round-trip.
  • toHTML is required for non-trivial components — the fallback is <div>${children}</div> and silently strips your component's structure on static export.
  • Don't mutate props inside the component — the editor treats props as immutable; mutations skip the dirty-tracking and don't persist.

Related

  • extensibility.md — host-injected registries (registerBlocksProvider, media handler, form handler, presets, modifiers, inspector properties).
  • host-configuration.mdPageHubConfig shape, feature flags, the rest of the boot-time surface.
  • rendering.md — why the editor, viewer, and static renderer need the same components array.
  • registration.mdSDK-internal checklist for adding a built-in (8 places inside packages/sdk) — not what you want as a host.

Generic prop narrowing

defineComponent<P>(...) propagates P into toHTML, defaultProps, and the props schema, so authors get type narrowing + typo-checking exactly where it matters:

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

interface PricingCardProps {
  title: string;
  price: number;
  period: "mo" | "yr";
}

export const PricingCardDef = defineComponent<PricingCardProps>({
  name: "PricingCard",
  component: PricingCard,

  // ✅ `Partial<PricingCardProps>` — typo `tittle` is a type error
  defaultProps: { title: "Pro", price: 29, period: "mo" },

  // ✅ keys are `keyof PricingCardProps` — typo `tittle` is a type error
  props: {
    title: { type: "text", label: "Plan Name" },
    price: { type: "number", label: "Price", min: 0 },
    period: { type: "select", label: "Period", options: [
      { label: "Month", value: "mo" },
      { label: "Year", value: "yr" },
    ]},
  },

  // ✅ `props.title` is `string`, `props.price` is `number`,
  //    `props.period` is `"mo" | "yr"` — not `any`.
  toHTML: (props) =>
    `<div><h3>${props.title}</h3><p>$${props.price}/${props.period}</p></div>`,
});

settings / advancedSettings are not narrowed — settings panels read props via CraftJS useNode() at runtime, not via a React props prop, so propagating P there would not help. Omit the generic entirely and you fall back to Record<string, any>, matching the pre-generic behavior for every built-in def.