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.
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.
defineComponent(...) to declare its name, default props, settings schema, and an HTML serializer for static export.PageHubConfig.components (an array). The SDK merges it with the built-in catalog.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.
| Field | Type | Notes |
|---|---|---|
name | string (PascalCase) | Unique. Must NOT collide with a built-in (Container, Text, Button, etc.) — throws at register time. |
component | React.ComponentType<Props> | Your React component. Receives the resolved props as the first argument. |
| Field | Type | Notes |
|---|---|---|
displayName | string | Shown in toolbox + inspector. Defaults to a humanized version of name. |
description | string | Toolbox tooltip. |
category | string | Toolbox section. Defaults to "Custom". Use an existing category to group with built-ins. |
defaultProps | Partial<Props> | Applied when a new instance is dropped on the canvas. |
props | Record<string, PropSchema> | Simple inspector schema — text / number / select / checkbox / slider / color / url / textarea. The SDK auto-generates the settings panel. |
toHTML | (node) => string | Serializer for renderToHTML() / static export. The SDK ships a generic fallback that wraps children in a <div> — provide your own for non-trivial components. |
icon | string | ReactElement | Toolbox icon. Use ref-icon:tb/TbStar style refs or a React element. |
| Field | Type | Notes |
|---|---|---|
canvas | boolean | When true, the component accepts child nodes (drop targets). |
settings | React.ComponentType | Custom React panel for the inspector — replaces the auto-generated props UI. |
advancedSettings | React.ComponentType | Custom panel for the inspector's Advanced tab. |
rules | CraftRules | CraftJS drop-validation rules (canMoveIn, canDrop, etc.). |
disable | string[] | Disable specific inspector tabs (e.g. ["Layout"]). |
peerInherit | PeerInheritConfig | Inherit 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.
import { PageHubEditor } from "@pagehub/sdk";
import { PricingCardDef, ChartDef, EmbedDef } from "./pagehub-components";
<PageHubEditor
components={[PricingCardDef, ChartDef, EmbedDef]}
callbacks={{ onLoad, onSave }}
/>;
import PageHub from "@pagehub/sdk";
import { PricingCardDef } from "./pagehub-components";
PageHub.init({
container: "#editor",
components: [PricingCardDef],
callbacks: { onLoad, onSave },
});
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.
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.toHTML powers renderToHTML(), the viewer's SSR pass, and editor.exportHTML().The inspector has two distinct surfaces, and they take different APIs — picking the wrong one is the most common confusion point:
| Surface | What goes there | How 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 classes | registerProperties |
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:
props schema — auto-generated UI. Best for 1-5 simple inputs.settings field — your own React component renders the entire content tab.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.
defineComponent 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.props as immutable; mutations skip the dirty-tracking and don't persist.registerBlocksProvider, media handler, form handler, presets, modifiers, inspector properties).PageHubConfig shape, feature flags, the rest of the boot-time surface.components array.packages/sdk) — not what you want as a host.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.