Documentation · @itzsa/page-builder

React Page Builder — @itzsa/page-builder

@itzsa/page-builder is a drag-and-drop visual page builder for React — the same class as Elementor, Webflow, or Puck. Authors compose pages from registered blocks; you persist Page JSON, not a one-off HTML export.

You own the data

The engine never imports your services/, store/, or routes. Persistence, auth, uploads, and previews go through host callbacks (onSave, uploadAsset, …).
FeatureDescription
Block registryRegister your own React blocks (or use built-in primitives) with fields, defaults, and render.
Page JSONYou own the data. Persist a structured Page document — not a one-off HTML dump.
Render parityCanvas, Preview, and Open Page share one React render path for the same JSON + CSS + locale.
LocalizationFirst-class i18nProps with host-configured locales (English, Nepali, or any set you define).
Author CSS / JSPage look comes from author CSS composers — not engine decorative skins. JS is capability-gated.
VisibilityShow or hide blocks by device, locale, publish state, or renderContext predicates.
Motion EffectsElementor-style entrance + hover on block.motion — shared CSS/runtime for canvas, preview, and open page.
Presets & compositionCard / Hero presets expand to editable primitive trees — prefer composition over mega-widgets.
Data sourcesRepeater + {{item.*}} binding with host-fed fetchDataSource (or SSR dataSources).
Feature togglingcapabilities and host UI flags gate CSS, JS, Code panel, registration, and canvas mode.
Images & mediaPreview, content width, alignment, link, CDN uploadAsset or Base64.
BackgroundBackground Type color | image with opacity and dark overlay.
Flex & GridNest blocks inside flex/grid; direction, columns, gap, and drop zones.
Palette filtersPaletteConfig hideCategories / hideBlocks / hidePresets.

Getting started#

Install the package, mount the editor, then render the same Page JSON on the public site.

Prefer a short path: register primitives → mount PageBuilder → save JSON → render with RenderPage / OpenPageView. Canvas, Preview, and Open Page share one React render path.

Installation#

Add the package and import editor chrome styles once in your app.

pnpm add @itzsa/page-builder

Peer dependencies: react, react-dom (^18 or ^19), and zod.

import "@itzsa/page-builder/styles.css";

Render the editor#

PageBuilder is a controlled component: you hold page state and persist on save.

import { useState } from "react";
import {
  PageBuilder,
  createRegistry,
  registerPrimitives,
  createDefaultLocaleConfig,
  PAGE_SCHEMA_VERSION,
  type Page,
} from "@itzsa/page-builder";
import "@itzsa/page-builder/styles.css";

const registry = createRegistry();
registerPrimitives(registry);

const localeConfig = createDefaultLocaleConfig();

const initialPage: Page = {
  id: "home",
  schemaVersion: PAGE_SCHEMA_VERSION,
  revision: "1",
  meta: { title: "Home" },
  blocks: [],
};

export function Editor() {
  const [page, setPage] = useState(initialPage);
  const [locale, setLocale] = useState(localeConfig.defaultLocale);

  return (
    <PageBuilder
      page={page}
      onChange={setPage}
      registry={registry}
      localeConfig={localeConfig}
      activeLocale={locale}
      onActiveLocaleChange={setLocale}
      onSave={(next, { expectedRevision }) => {
        // Persist Page JSON to your database
        void savePage(next, expectedRevision);
      }}
      capabilities={{
        allowCustomCss: true,
        allowCustomJs: false,
      }}
    />
  );
}

Parity rule

If something is visible as page content in the canvas for the current renderContext, it must render identically in Preview and Open Page with the same JSON, author CSS, and locale.

Render the page#

Use the same registry and Page document outside the editor.

import {
  RenderPage,
  OpenPageView,
  createRegistry,
  registerPrimitives,
  createDefaultLocaleConfig,
  type Page,
} from "@itzsa/page-builder";

const registry = createRegistry();
registerPrimitives(registry);
const localeConfig = createDefaultLocaleConfig();

/** Same registry + page JSON as the editor — canvas / preview / open parity. */
export function PageView({ page, locale }: { page: Page; locale: string }) {
  return (
    <RenderPage
      page={page}
      registry={registry}
      localeConfig={localeConfig}
      activeLocale={locale}
      surface="open"
    />
  );
}

/** Full document helper (injects composed author CSS/JS). */
export function PublishedPage({ page, locale }: { page: Page; locale: string }) {
  return (
    <OpenPageView
      page={page}
      registry={registry}
      localeConfig={localeConfig}
      activeLocale={locale}
    />
  );
}
  • RenderPage — block tree only (embed in your layout).
  • OpenPageView — also injects composed author CSS/JS when allowed. Prefer this on public / preview routes.

Show page on your site#

End-to-end: save Page JSON from the editor → fetch it on another route → mount OpenPageView.

The builder does not ship a “website.” After authors build a page, your app stores the JSON and your public frontend draws it with OpenPageView (or RenderPage). That is the component people see on the next page.

1. Save to your backend

Persist the Page object from onSave. Do not rely on HTML as the source of truth.

// In your editor host
<PageBuilder
  page={page}
  onChange={setPage}
  registry={registry}
  localeConfig={localeConfig}
  activeLocale={locale}
  onActiveLocaleChange={setLocale}
  onSave={async (next, { expectedRevision }) => {
    const res = await fetch(`/api/pages/${next.id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        page: next,
        expectedRevision, // optimistic concurrency
      }),
    });
    if (res.status === 409) {
      // conflict — reload or overwrite UI
      return;
    }
    if (!res.ok) throw new Error("Save failed");
  }}
/>

2. Preview on another route (before publish)

Demo pattern used at /page-builder/create: store a short-lived session, put only an opaque id in the URL, then load and render with OpenPageView on /page-builder/preview.

// 1) Editor — Preview button
import {
  createPreviewSession,
  buildPreviewUrl,
} from "@itzsa/page-builder";

const handlePreview = async () => {
  const session = await createPreviewSession({
    page,
    activeLocale,
    store: "sessionStorage", // or "indexedDB" for large pages
  });
  // URL is only /preview?preview=<opaque-id> — never ?data=<json>
  router.push(buildPreviewUrl("/page-builder/preview", session.id));
};

// 2) app/page-builder/preview/page.tsx — load + render
"use client";
import { useEffect, useMemo, useState } from "react";
import {
  OpenPageView,
  createRegistry,
  registerPrimitives,
  createDefaultLocaleConfig,
  getPreviewIdFromUrl,
  loadPreviewSession,
  type Page,
} from "@itzsa/page-builder";
import "@itzsa/page-builder/styles.css";

const localeConfig = createDefaultLocaleConfig();

export default function PreviewPage() {
  const registry = useMemo(() => {
    const r = createRegistry();
    registerPrimitives(r);
    // register the SAME custom blocks as the editor
    return r;
  }, []);

  const [page, setPage] = useState<Page | null>(null);
  const [locale, setLocale] = useState(localeConfig.defaultLocale);

  useEffect(() => {
    const id = getPreviewIdFromUrl(window.location.href, "preview");
    if (!id) return;
    void loadPreviewSession(id).then((session) => {
      if (!session) return;
      setPage(session.page as Page);
      setLocale(session.activeLocale);
    });
  }, []);

  if (!page) return <p>Loading preview…</p>;

  return (
    <OpenPageView
      page={page}
      registry={registry}
      localeConfig={localeConfig}
      activeLocale={locale}
    />
  );
}

Never put Page JSON in the URL

Multi-locale trees + CSS blow past URL length limits. Use createPreviewSession + buildPreviewUrl, or a draft API that returns an id.

3. Public frontend — fetch JSON, show the page

After publish, your public route loads the saved document from your API and mounts OpenPageView. Use the same registry as the editor (primitives + every custom block type).

// app/pages/[slug]/page.tsx  (public site)
import {
  OpenPageView,
  createRegistry,
  registerPrimitives,
  createDefaultLocaleConfig,
  type Page,
} from "@itzsa/page-builder";
import "@itzsa/page-builder/styles.css";

const registry = createRegistry();
registerPrimitives(registry);
// Must match editor: registerBlock / registerDynamicBlock for every type on the page
const localeConfig = createDefaultLocaleConfig();

async function getPageBySlug(slug: string): Promise<Page | null> {
  // Your backend — returns the same Page JSON you saved from onSave
  const res = await fetch(`${process.env.API_URL}/pages/${slug}`, {
    next: { tags: [`page-${slug}`] },
  });
  if (!res.ok) return null;
  const data = (await res.json()) as { page: Page };
  return data.page;
}

export default async function PublicPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const page = await getPageBySlug(slug);
  if (!page) return <p>Not found</p>;

  const locale = localeConfig.defaultLocale;

  return (
    <main>
      {/* THIS is the component that draws the built page */}
      <OpenPageView
        page={page}
        registry={registry}
        localeConfig={localeConfig}
        activeLocale={locale}
        // surface is "open" inside OpenPageView
        capabilities={{
          allowCustomCss: true,
          allowCustomJs: false,
        }}
      />
    </main>
  );
}

// Client-only alternative (if you fetch in useEffect):
// const [page, setPage] = useState<Page | null>(null);
// useEffect(() => { void fetch(...).then(r => r.json()).then(d => setPage(d.page)); }, [slug]);
// return page ? <OpenPageView page={page} ... /> : null;
  • OpenPageView = the public renderer (CSS/JS composers + RenderPage).
  • Registry mismatch → missing blocks fall back / blank — register everything the editor had.
  • Same localeConfig + choose activeLocale from the request (cookie, path, query).
  • Optional: pass fetchDataSource / renderContext so repeaters and visibility match the editor.

Integrating#

Wire registry, locales, media, visibility, motion, capabilities, and canvas into your host app.

The sections below match how you typically integrate a visual editor: register blocks, understand the data model, then layer locales, CSS, motion, uploads, and feature gates.

Register blocks#

Built-in primitives cover layout and content. Add tenant blocks with registerBlock (Model A) or JSON specs (Model B).

Call registerPrimitives(registry) for heading, text, image, button, box/flex/grid, spacer, divider, and repeater. Card / Icon Box, Image Box, Testimonial, Card, and Hero presets expand to editable primitive trees — prefer composition over mega-widgets. WordPress Shortcode/Sidebar widgets are out of scope.

import {
  createRegistry,
  registerBlock,
  registerPrimitives,
  type BlockDefinition,
} from "@itzsa/page-builder";
import { z } from "zod";

const calloutDefinition: BlockDefinition = {
  type: "tenant:callout", // non-core types must be namespaced
  label: "Callout",
  category: "basic",
  source: "tenant",
  defaultProps: { tone: "info" },
  defaultI18nProps: {
    en: { body: "Note" },
    ne: { body: "नोट" },
  },
  translatableProps: ["body"],
  sharedProps: ["tone"],
  propsSchema: z
    .object({
      tone: z.enum(["info", "warn"]).optional(),
      body: z.string().optional(),
    })
    .passthrough(),
  render: ({ block, props }) => (
    <aside data-block-id={block.id} data-tone={String(props.tone ?? "info")}>
      {String(props.body ?? "")}
    </aside>
  ),
  ContentFields: ({ block, locale, onChange }) => (
    <label className="pb-field">
      <span className="pb-field-label">Body</span>
      <textarea
        aria-label="Callout body"
        value={String(block.i18nProps?.[locale]?.body ?? "")}
        onChange={(e) =>
          onChange({
            i18nProps: {
              ...block.i18nProps,
              [locale]: {
                ...block.i18nProps?.[locale],
                body: e.target.value,
              },
            },
          })
        }
      />
    </label>
  ),
};

const registry = createRegistry();
registerPrimitives(registry);
registerBlock(registry, calloutDefinition, {
  allowRegisterTenantBlocks: true,
});

One render path

Never add a second HTML template or publish-only component for the same type. Canvas, Preview, and Open Page all mount definition.render.

Built-ins: box/container, flex, grid, heading, text, list, badge, icon, image, video, button, divider, space, code, repeater, Google Map, embed/iframe, html.

Google Map & Embed

Paste the iframe HTML from Google Maps (Share → Embed) or any https iframe. The engine parses and stores only the src URL — never raw HTML with scripts.

Flex & Grid#

Drop Flex or Grid on the canvas, then drag other blocks into the dashed drop zone inside.

  • Empty Flex/Grid shows Empty / Drop here — drop Heading, Text, Image, etc. into that zone.
  • Content tab: Flex has direction, justify, align, gap, wrap; Grid has columns, gaps, align/justify items.
  • Children are stored on block.children — same JSON for Preview and Open Page.
// Drag Flex or Grid onto the canvas, then drop Heading / Text / Image
// into the dashed "Empty" / "Drop here" zone inside the container.

// Flex Content fields
{ direction: "row", justifyContent: "space-between", gap: "16px" }

// Grid Content fields
{ columns: "3", gap: "16px", rowGap: "24px" }

// Containers are isContainer: true — children live in block.children[]
{
  "id": "flex-1",
  "type": "flex",
  "props": { "direction": "row", "gap": "16px" },
  "children": [
    { "id": "h1", "type": "heading", "props": {}, "i18nProps": { "en": { "title": "Left" } } },
    { "id": "h2", "type": "heading", "props": {}, "i18nProps": { "en": { "title": "Right" } } }
  ]
}

Data model#

The canonical saved document is structured Page JSON (schema-validated). Full HTML is an optional derived export.

{
  "id": "page-home",
  "schemaVersion": 1,
  "revision": "3",
  "meta": { "title": "Home" },
  "globalCss": "[data-pb-page] { font-family: system-ui; }",
  "blocks": [
    {
      "id": "b1",
      "type": "heading",
      "props": { "level": "h1" },
      "i18nProps": {
        "en": { "title": "Welcome" },
        "ne": { "title": "स्वागत छ" }
      },
      "motion": { "entrance": "fadeInUp", "trigger": "scroll" }
    }
  ]
}
  • props — shared (non-translated) values.
  • i18nProps[locale] — translated fields.
  • revision — bump on save; use with assertRevisionMatch / expectedRevision.
  • globalCss / block customCss — author look (not engine skins).

Localization#

Locales are host-configured. Flat host keys normalize through i18nResolve — never a hardcoded switch (lang).

import {
  createDefaultLocaleConfig,
  createEnglishOnlyLocaleConfig,
  createNepaliOnlyLocaleConfig,
  createLocaleConfig,
} from "@itzsa/page-builder";

createDefaultLocaleConfig();      // English + Nepali
createEnglishOnlyLocaleConfig(); // English only
createNepaliOnlyLocaleConfig();  // Nepali only

createLocaleConfig([
  { code: "en", label: "English", dir: "ltr", flatSuffixes: ["en"] },
  { code: "hi", label: "हिन्दी", dir: "ltr", flatSuffixes: ["hi"] },
]);

Pass the same localeConfig into the editor and into RenderPage. Switch activeLocale to edit or view another language.

Author CSS / JS#

Page look comes from author CSS. The engine does not ship decorative block skins.

/* Page.globalCss */
[data-pb-page] {
  font-family: Georgia, serif;
  color: #1c1917;
}

[data-block-type="heading"] {
  margin: 0 0 0.75rem;
  letter-spacing: -0.02em;
}

[data-block-type="button"] {
  display: inline-block;
  padding: 0.55rem 1rem;
  background: #1c1917;
  color: #fafaf9;
}

@media (max-width: 640px) {
  [data-block-type="heading"] { font-size: 1.5rem; }
}
  • Target [data-pb-page], [data-block-type="…"], or .b-{blockId}.
  • Gate with capabilities.allowCustomCss / allowCustomJs.
  • Re-validate on the server with validateAuthorCode before persist (see API).

Images & media#

Image blocks: preview, URL + Upload (CDN or Base64), content width, alignment, link, alt.

import { PageBuilder, type UploadAsset } from "@itzsa/page-builder";

const uploadAsset: UploadAsset = async (file) => {
  const form = new FormData();
  form.append("file", file);
  const res = await fetch("/api/page-builder/upload", {
    method: "POST",
    body: form,
  });
  const { url } = await res.json();
  return { url }; // CDN / media URL stored on image props.src
};

<PageBuilder
  /* … */
  uploadAsset={uploadAsset}
/>
  • Upload — uses uploadAsset when provided; otherwise Base64 (size-capped). Same control is reused for background images.
  • Content width presets (full / large 1024 / medium / small / custom) plus left / center / right alignment and optional link.
  • Also: video (YouTube / Vimeo / mp4) and html (sanitized allow-list).

Background#

Style tab and Box/Flex/Grid Content: Background Type Color | Image, opacity, dark overlay.

// Style tab → Background Type: Color | Image
// Also on Box / Flex / Grid Content fields

{
  "backgroundType": "image",
  "backgroundImage": "https://cdn.example.com/hero.jpg",
  "backgroundSize": "cover",
  "backgroundOpacity": "100",
  "backgroundOverlay": "40"   // dark overlay %
}

// Color mode
{
  "backgroundType": "color",
  "backgroundColor": "#0f172a",
  "backgroundOpacity": "100",
  "backgroundOverlay": "0"
}

Typography#

Style tab — type font weight freely; letter-spacing uses value + unit like font size; pass host fonts.

// Style tab — Typography
patchStyle({
  fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif", // or type a custom stack
  fontSize: "18",
  fontSizeUnit: "px",
  fontWeight: "600",           // type any CSS weight (100–900 or bold)
  letterSpacing: "0.02",
  letterSpacingUnit: "em",     // px | em | rem
});

// Host can pass extra fonts into the inspector:
fontFamilies={[
  { label: "Inter", value: "Inter, sans-serif" },
  { label: "Noto Sans Devanagari", value: "'Noto Sans Devanagari', sans-serif" },
]}

When the active locale is Nepali (ne), the editor header shows / edits the Nepali page name (meta.title_np).

Visibility#

Default: omit visibility → the block is always shown as page content.

// Default: omit visibility → always shown
{
  "id": "promo",
  "type": "text",
  "props": {},
  "i18nProps": { "en": { "body": "Desktop only" } },
  "visibility": {
    "hiddenDevices": ["mobile"],
    "hiddenLocales": ["ne"],
    "hiddenOnPublish": false
  },
  "visibleWhen": {
    "allOf": [{ "key": "auth.isLoggedIn", "equals": true }]
  }
}

Resolve with the same renderContext and surface in editor and published views so Preview matches Open Page.

Motion Effects#

Elementor-style entrance + hover on block.motion. Same CSS/runtime for canvas, preview, and open page.

import type { Block } from "@itzsa/page-builder";

const block: Block = {
  id: "hero-title",
  type: "heading",
  props: { level: "h1" },
  i18nProps: { en: { title: "Welcome" } },
  motion: {
    entrance: "fadeInUp", // fadeIn | fadeInUp | zoomIn | slideInLeft | …
    trigger: "scroll",    // scroll | load
    durationMs: 600,
    delayMs: 0,
    hover: "grow",        // none | grow | shrink | float
  },
};

// Advanced tab → Motion Effects in the create editor.
// composePageCss / composePageJs (+ initPbMotion on embedded canvas)
// keep Canvas === Preview === Open Page.
  • Inspector: select a block → Advanced Motion Effects.
  • Entrance presets (fadeInUp, zoomIn, …) with scroll or load trigger; hover grow / shrink / float.
  • composePageCss / composePageJs (+ initPbMotion on the embedded canvas). Honors prefers-reduced-motion. No decorative defaults when motion is omitted.

Data sources#

Repeater blocks load items from the host and bind template fields with {{item.key}}.

<PageBuilder
  /* … */
  fetchDataSource={async (sourceId) => {
    const items = await api.list(sourceId);
    return { items };
  }}
/>

// Inside a Repeater template, bind with {{item.title}}

Requires allowDataBinding (default allowed). Disable it to hide repeater binding for a workspace.

Feature toggling#

Two layers: package capabilities (security / product) and host UI flags (chrome).

import { createProductionCapabilities } from "@itzsa/page-builder";

// Production / low-trust hosts — prefer the helper:
capabilities={createProductionCapabilities()}

// Or explicit:
capabilities={{
  allowCustomCss: true,
  allowCustomJs: false,
  allowDataBinding: true,
  allowRegisterTenantBlocks: true,
  allowRegisterPluginBlocks: false,
  allowDynamicBlockDefs: true,
  allowSignedBlockImport: false,
}}

capabilities

PropTypeDefaultDescription
allowCustomCssbooleantrueAuthor globalCss / block customCss. Set false to hide and ignore.
allowCustomJsbooleantrue*Author page JS (prefer false in production until you need it).
allowDataBindingbooleantrueRepeater + {{item.*}} and fetchDataSource.
allowRegisterTenantBlocksbooleantrueregisterBlock with source: tenant.
allowRegisterPluginBlocksbooleantrueregisterBlock with source: plugin.
allowDynamicBlockDefsbooleantrueModel B registerDynamicBlock(s).
allowSignedBlockImportbooleanfalse (deny)Phase 19 registerSignedBlock — must be explicitly true (default deny).

Host chrome flags (demo create shell):

const CREATE_FEATURES = {
  showHeader: true,
  showCodePanel: true,   // HTML/JSON code panel
  showPreview: true,
  showOpenPage: true,
  showPublish: true,
};

// Locale ne → header shows / edits meta.title_np (Nepali page name)

Hide groups & blocks#

Filter the elements palette by category and/or block type.

import type { PaletteConfig } from "@itzsa/page-builder";

const palette: PaletteConfig = {
  hideCategories: ["other", "presets"], // layout | basic | presets | other
  hideBlocks: ["html", "repeater"],      // by block type id
  // hidePresets: true,                  // or ["hero", "card"]
};

// Create demo host:
// <CreateLeftSidebar palette={palette} … />
// Package ElementsPanel also accepts palette={…}

Canvas & viewports#

embedded mode enables same-document DnD. iframe mode sandboxes the page document (ADR-02).

<PageBuilder
  canvasMode="iframe"
  canvasSrc="/page-builder/canvas"
  /* … */
/>

// Or host create flags:
// CREATE_FEATURES.canvasMode = "embedded" | "iframe"

Editor chrome stays outside the page DOM

Selection outlines and drag ghosts are parent overlays — never injected into the published page document.

Theming the editor#

Override --pb-* tokens on .pb-root / [data-pb-editor]. Do not use these to style published page content.

.pb-root,
[data-pb-editor] {
  --pb-accent: #0f766e;
  --pb-accent-fg: #ecfdf5;
  --pb-fg: #0f172a;
  --pb-muted: #64748b;
  --pb-border: #e2e8f0;
  --pb-surface: #ffffff;
  --pb-page: #f8fafc;
}

API reference#

Primary exports for integrating and extending the builder.

Deeper topic docs also live in the repo under docs/page-builder/ and ARCHITECTURE-PAGE-BUILDER.md.

PageBuilder#

PageBuilderProps

PropTypeDefaultDescription
pagePage-Current page document (controlled).
onChange(page: Page) => void-Called whenever the tree changes (history-aware edits).
registryBlockRegistry-Live block definition map from createRegistry().
localeConfigLocaleConfig-Host locale list + default (see Localization).
activeLocalestring-Locale used for i18nProps resolution and inspector fields.
onActiveLocaleChange(locale: string) => void-Locale switcher callback.
onSave(page, { expectedRevision? }) => void | Promise-Persist Page JSON. Pass expectedRevision for optimistic concurrency.
onPreview(page: Page) => void | Promise-Open preview with the same page JSON (opaque id preferred).
onOpenPage(page: Page) => void | Promise-Open published / live view.
capabilitiesPageBuilderCapabilities-Gate CSS, JS, registration, data binding (explicit false disables).
uploadAsset(file: File) => Promise<{ url: string }>-CDN / media upload for Image Upload. Falls back to Base64 if omitted.
fetchDataSourceFetchDataSource-Load repeater / binding items by source id.
renderContextPartial<RenderContext>-Device, publish flags, auth keys for visibility predicates.
featuresPageBuilderUiFeatures-showSave / showPreview / showOpenPage for package toolbar.
canvasMode"embedded" | "iframe""embedded"embedded = DnD canvas; iframe = sandboxed shell at canvasSrc.
canvasSrcstring-Required when canvasMode is iframe (e.g. /page-builder/canvas).
selectedIdstring | null-Optional controlled selection.
titlestring"Page builder"Editor chrome title.

RenderPage / OpenPageView#

RenderPageProps (core)

PropTypeDefaultDescription
pagePage-Same document the editor saves.
registryBlockRegistry-Must include every block type on the page.
localeConfig / activeLocaleLocaleConfig / string-Resolve i18nProps for the active locale.
surface"canvas" | "preview" | "open""open"Affects visibility (e.g. hiddenOnPublish).
capabilities / fetchDataSource / renderContext-Same contracts as PageBuilder for parity.

OpenPageView adds nonce, cssOptions, and injectAuthorCode (default true) for publishing surfaces.

registerBlock#

BlockDefinition (key fields)

PropTypeDefaultDescription
typestring-Stable type id. Core primitives are unprefixed; tenant/plugin must be namespaced.
labelstring-Palette label.
render(props: BlockRenderProps) => ReactNode-One React component for canvas, preview, and open page.
ContentFieldsComponent-Inspector content UI (block, locale, onChange).
propsSchemaZodType-Validates props / i18n keys.
translatableProps / sharedPropsstring[]-Which keys live in i18nProps vs shared props.
source"core" | "tenant" | "plugin"-Registration capability gates non-core sources.

Host callbacks#

Injected I/O — the engine never reaches into your app.

  • onSave — persist Page JSON (+ revision).
  • onPreview / onOpenPage — navigate with an opaque id, not serialized JSON in the URL.
  • uploadAsset — return a stable CDN URL.
  • fetchDataSource (sourceId) => { items }.

validateAuthorCode#

Parse author CSS/JS with the same composers used at render time. Call before save/publish.

import { validateAuthorCode } from "@itzsa/page-builder";

const result = validateAuthorCode(page, {
  allowedUrlOrigins: ["https://cdn.example.com"],
});

if (!result.ok) {
  // Reject save — result.cssErrors / result.jsErrors
}

Guides#

Longer how-tos for blocks, data, locales, CSS/JS, and parity. End-to-end save → public render is covered in Getting started (Show page on your site).

Open live demo

MIT License

Add a block#

Add a core-style or host Model A block without forking the engine.

  • Define BlockDefinition: type, label, category, defaultProps, propsSchema, render, ContentFields.
  • Declare translatableProps / sharedProps / defaultI18nProps for i18n.
  • Register after primitives with registerBlock.
  • render uses semantic HTML + author CSS only (no engine decorative skins).
  • Mount PageBuilder / RenderPage with the same registry (parity).
import {
  createRegistry,
  registerBlock,
  registerPrimitives,
  type BlockDefinition,
} from "@itzsa/page-builder";
import { z } from "zod";

const calloutDefinition: BlockDefinition = {
  type: "tenant:callout", // non-core types must be namespaced
  label: "Callout",
  category: "basic",
  source: "tenant",
  defaultProps: { tone: "info" },
  defaultI18nProps: {
    en: { body: "Note" },
    ne: { body: "नोट" },
  },
  translatableProps: ["body"],
  sharedProps: ["tone"],
  propsSchema: z
    .object({
      tone: z.enum(["info", "warn"]).optional(),
      body: z.string().optional(),
    })
    .passthrough(),
  render: ({ block, props }) => (
    <aside data-block-id={block.id} data-tone={String(props.tone ?? "info")}>
      {String(props.body ?? "")}
    </aside>
  ),
  ContentFields: ({ block, locale, onChange }) => (
    <label className="pb-field">
      <span className="pb-field-label">Body</span>
      <textarea
        aria-label="Callout body"
        value={String(block.i18nProps?.[locale]?.body ?? "")}
        onChange={(e) =>
          onChange({
            i18nProps: {
              ...block.i18nProps,
              [locale]: {
                ...block.i18nProps?.[locale],
                body: e.target.value,
              },
            },
          })
        }
      />
    </label>
  ),
};

const registry = createRegistry();
registerPrimitives(registry);
registerBlock(registry, calloutDefinition, {
  allowRegisterTenantBlocks: true,
});

Checklist

Namespace non-core types (tenant: / plugin:). Duplicate type throws — no silent override. No eval. Unknown types still get FallbackBlock.

Register a custom block (Model A)#

Tenants and plugins add block types by registering a bundled BlockDefinition — no remote eval.

  • Non-core types must be tenant:… or plugin:vendor.block.
  • Cannot register tenant:heading — bare ids are reserved for core.
  • Duplicate type throws. render ships in the host/plugin bundle.
  • Missing type at render → FallbackBlock (tree-preserving placeholder).
import {
  createRegistry,
  registerBlock,
  registerPrimitives,
  type BlockDefinition,
} from "@itzsa/page-builder";
import { z } from "zod";

const calloutDefinition: BlockDefinition = {
  type: "tenant:callout", // non-core types must be namespaced
  label: "Callout",
  category: "basic",
  source: "tenant",
  defaultProps: { tone: "info" },
  defaultI18nProps: {
    en: { body: "Note" },
    ne: { body: "नोट" },
  },
  translatableProps: ["body"],
  sharedProps: ["tone"],
  propsSchema: z
    .object({
      tone: z.enum(["info", "warn"]).optional(),
      body: z.string().optional(),
    })
    .passthrough(),
  render: ({ block, props }) => (
    <aside data-block-id={block.id} data-tone={String(props.tone ?? "info")}>
      {String(props.body ?? "")}
    </aside>
  ),
  ContentFields: ({ block, locale, onChange }) => (
    <label className="pb-field">
      <span className="pb-field-label">Body</span>
      <textarea
        aria-label="Callout body"
        value={String(block.i18nProps?.[locale]?.body ?? "")}
        onChange={(e) =>
          onChange({
            i18nProps: {
              ...block.i18nProps,
              [locale]: {
                ...block.i18nProps?.[locale],
                body: e.target.value,
              },
            },
          })
        }
      />
    </label>
  ),
};

const registry = createRegistry();
registerPrimitives(registry);
registerBlock(registry, calloutDefinition, {
  allowRegisterTenantBlocks: true,
});

Gate with allowRegisterPluginBlocks / allowRegisterTenantBlocks (default allow). Use createPageSchema({ registry }) so live registry refine accepts new types.

Dynamic blocks (Model B)#

JSON specs + a composition tree of existing primitives. No downloaded render JS, no eval.

  • Host fetches specs (fetchDynamicBlocks — host-owned).
  • registerDynamicBlock(s) after registerPrimitives.
  • Live registry .refine() accepts the new types.
  • Template strings may use {{props.fieldKey}} (same one-pass rules as repeater {{item.*}}).
import {
  createRegistry,
  registerPrimitives,
  registerDynamicBlock,
  type DynamicBlockSpec,
} from "@itzsa/page-builder";

const spec: DynamicBlockSpec = {
  type: "tenant:promo", // must be namespaced
  label: "Promo",
  source: "tenant",
  fields: [
    { key: "title", kind: "text", translatable: true },
    { key: "image", kind: "image" },
    { key: "href", kind: "url" },
  ],
  template: [
    {
      type: "box",
      children: [
        { type: "image", props: { src: "{{props.image}}" } },
        {
          type: "heading",
          i18nProps: { en: { title: "{{props.title}}" } },
        },
        {
          type: "button",
          props: { href: "{{props.href}}" },
          i18nProps: { en: { label: "Go" } },
        },
      ],
    },
  ],
};

const registry = createRegistry();
registerPrimitives(registry);
registerDynamicBlock(registry, spec);

Reject with registerDynamicBlock(registry, spec, { allowDynamicBlockDefs: false }).

Dynamic blog card#

CMS blog cards are a repeater + DataSource + primitive template — not a locked blog-card widget.

repeater  (dataBinding → sourceId: "posts", params: { limit: 6 })
└── template (children):
    box
    ├── image     src: {{item.image}}
    ├── heading   text: {{item.title}}
    ├── text      body: {{item.excerpt}}
    └── button    label: {{item.cta}}  href: {{item.url}}
  • Register DataSource metadata (posts + itemSchema).
  • Strategy A: resolve into renderContext.dataSources.posts (SSR / Open Page).
  • Strategy B: pass fetchDataSource for client fetch.
  • capabilities.allowDataBinding must be allowed or binding is inert.
  • Outline edits the template, not N clones. Tokens: {{item.field}} only.
<PageBuilder
  /* … */
  fetchDataSource={async (sourceId) => {
    const items = await api.list(sourceId);
    return { items };
  }}
/>

// Inside a Repeater template, bind with {{item.title}}

Add a locale#

Locales are host-configured — no engine release to add hi, zh, etc.

  • Extend LocaleConfig.locales with { code, label, dir, flatSuffixes? }.
  • Keep defaultLocale / fallbackLocale valid.
  • Pass the same localeConfig into PageBuilder and RenderPage.
  • Never hardcode switch (lang) in block render — use i18nResolve.
const locales: LocaleConfig = {
  locales: [
    { code: "en", label: "English", dir: "ltr", flatSuffixes: ["en"] },
    { code: "ne", label: "नेपाली", dir: "ltr", flatSuffixes: ["ne", "np"] },
    { code: "hi", label: "हिन्दी", dir: "ltr", flatSuffixes: ["hi"] },
  ],
  defaultLocale: "en",
  fallbackLocale: "en",
  localeStorage: "nested",
};

Custom CSS / JS#

Author code always passes through composers — never raw-injected.

CSS

  • Gate with capabilities.allowCustomCss !== false.
  • Authors edit Block.customCss (Advanced) and/or Page.globalCss.
  • Engine: parse → compose → inject with CSP nonce. Per-block rules scope to [data-block-id="…"].
/* Page.globalCss */
[data-pb-page] {
  font-family: Georgia, serif;
  color: #1c1917;
}

[data-block-type="heading"] {
  margin: 0 0 0.75rem;
  letter-spacing: -0.02em;
}

[data-block-type="button"] {
  display: inline-block;
  padding: 0.55rem 1rem;
  background: #1c1917;
  color: #fafaf9;
}

@media (max-width: 640px) {
  [data-block-type="heading"] { font-size: 1.5rem; }
}

JS

  • Gate with capabilities.allowCustomJs (often off for low-trust tenants).
  • Runs only in canvas iframe / Open Page — never the editor parent.
  • Network default-deny; pass allowedConnectOrigins into sandbox CSP if needed.

Do not

Skip nonces with 'unsafe-inline', put allow-same-origin on the canvas sandbox, or treat client composers as the authority — re-validate on the server with validateAuthorCode.
import { validateAuthorCode } from "@itzsa/page-builder";

const result = validateAuthorCode(page, {
  allowedUrlOrigins: ["https://cdn.example.com"],
});

if (!result.ok) {
  // Reject save — result.cssErrors / result.jsErrors
}

Render parity#

For the same Page JSON + author CSS/JS + locale + renderContext: canvas === Preview === Open Page.

  • One React render per block type (registry). No second HTML template or publish-only component.
  • Editor chrome (selection, drag ghosts, toolbars) lives in the parent document via overlays — never inside the page DOM.
  • Engine must not ship decorative default skins so the canvas “looks less empty.”
  • No switch (block.type) outside registry dispatch.

Failure modes

Canvas styles Preview lacks → remove engine/demo CSS. Overlay chrome on Open Page → move to parent overlays. Locale differs per surface → same i18nResolve + activeLocale everywhere. Motion only on one surface → ensure composePageCss / composePageJs (or initPbMotion) run everywhere block.motion is used.

Motion Effects#

Add entrance and hover animations via block.motion — one render path, no engine skins by default.

  • Open Create, select a block, open Advanced Motion Effects.
  • Pick entrance + trigger (scroll / load), duration, delay, optional hover.
  • Confirm Preview and Open Page match the canvas for the same JSON.
import type { Block } from "@itzsa/page-builder";

const block: Block = {
  id: "hero-title",
  type: "heading",
  props: { level: "h1" },
  i18nProps: { en: { title: "Welcome" } },
  motion: {
    entrance: "fadeInUp", // fadeIn | fadeInUp | zoomIn | slideInLeft | …
    trigger: "scroll",    // scroll | load
    durationMs: 600,
    delayMs: 0,
    hover: "grow",        // none | grow | shrink | float
  },
};

// Advanced tab → Motion Effects in the create editor.
// composePageCss / composePageJs (+ initPbMotion on embedded canvas)
// keep Canvas === Preview === Open Page.

Do not

Bake fade/slide into every primitive's default CSS, or animate only inside the editor. Motion is page content — parity required.

Signed dynamic import#

Phase 19 — opt-in. Fetch a host CDN ESM, verify SRI, then import() — never eval. Default deny.

Hard forbid

Never eval / new Function of remote source. Never skip SRI or the origin allow-list. allowSignedBlockImport must be explicitly true.
  • URL must be https and origin ∈ allowedImportOrigins.
  • Bytes verified against SRI (sha256|sha384|sha512-<base64>) before import().
  • Module exports definition or default BlockDefinition (namespaced tenant: / plugin:).
  • Prefer canvasMode: "iframe" so page scripts stay sandboxed.
import {
  createRegistry,
  registerPrimitives,
  registerSignedBlock,
} from "@itzsa/page-builder";

const registry = createRegistry();
registerPrimitives(registry);

await registerSignedBlock(
  registry,
  {
    url: "https://cdn.example.com/blocks/tenant-callout.js",
    integrity: "sha384-…",
    expectedType: "tenant:callout",
  },
  {
    capabilities: { allowSignedBlockImport: true },
    allowedImportOrigins: ["https://cdn.example.com"],
  },
);