form-page-builder
v2.3.0
Published
Embeddable bilingual drag-and-drop form/content builder for React and Next.js — builds and previews a JSON form schema (fields, sections, layout blocks); does not handle or store submitted data itself.
Readme
form-page-builder
Embeddable, bilingual (EN/JA by default, extensible), drag-and-drop form builder widget for React. Ships a single <FormBuilder /> component with a Build mode (drag/drop canvas, field inspector) and a Preview mode (responsive, validating runtime form), plus a JSON export of the resulting document.
Examples
The live demo is a gallery of <FormBuilder /> configurations, each showing a different realistic way to compose features/theme/storage/initialDocument/language — jump straight to one. Its sticky header (its headings/captions, not the widget's own EN/JA switcher) has an EN/日本語 toggle, a Light/Dark toggle that swaps every example except "Branded" (badged "Fixed theme", since its whole point is a locked theme) between DEFAULT_THEME and DARK_THEME, and links back to this repo and the npm package.
| Example | What it shows |
|---|---|
| Full-featured | No props beyond features={{ design: true }} — the default, everything on. |
| Minimal (forms-only embed) | Every optional surface off, a 3-type field allowlist — stripped down for embedding inside a larger app's own chrome. |
| Branded, theme locked | A fixed theme plus features.design/blockStyling off — colors are locked to the brand, no styling UI exposed. |
| Survey builder | fieldTypes/contentBlocks allowlists plus onSubmit — a single-shape survey embed that hands you the answers. |
| Locked-structure form | initialDocument seeds a fixed field set; structural toggles are off so only styling/labels stay editable. |
| Localized (French) | language/languages/strings/chrome — a language beyond the built-in EN/JA, partially translated. |
| Template roles | Two instances sharing one template library — templates: { manage: true, max: 3 } (curate) next to { manage: false } (apply-only), plus copy/paste between them. |
The source for all of them lives in dev/main.tsx — npm run dev runs the same gallery locally against src/ directly, and is the fastest way to try a features/theme combination before wiring it into your app.
This is a builder + viewer, not a data handler. It builds and previews a JSON schema describing a form's fields, sections, and layout blocks (including plain content blocks like paragraphs and images, not just inputs). Preview mode's "Submit" validates and shows a mock "here's what would be sent to your backend" modal, and — if you pass onSubmit — hands you the entered values too; either way, this package never sends or stores them itself. The only thing it persists on its own is the builder's own draft/Templates state (via the pluggable StorageAdapter below); actually delivering submissions to a backend is up to the host app.
Install
npm install form-page-builder
# or, from a private GitHub repo:
npm install git+ssh://[email protected]/yourorg/form-page-builder.gitreact and react-dom (>=18) are peer dependencies — install them in the consuming app if not already present.
Usage (React / Next.js)
import { FormBuilder } from "form-page-builder";
export default function BuilderPage() {
return <FormBuilder />;
}In Next.js App Router, the package's entry already carries a "use client" directive, so it can be imported directly from a Server Component tree without you adding the directive yourself.
Sizing
The widget caps itself at the viewport height (100vh, upgrading to 100dvh on browsers that support it) and scrolls its own Palette/Canvas/Inspector panels internally past that — it never grows taller than the space available. Give its wrapping container an explicit height (e.g. height: "100dvh" for a full-height layout on mobile, or any fixed/% height) if you want it to fill that space; without one, it just sizes to its content and the page scrolls normally, either way with a single scrollbar.
If you do size a wrapper to exactly 100vh/100dvh and still see the page itself scroll by a few extra pixels, that's almost always the browser's default <body> margin (commonly 8px) adding to that full-viewport height — reset it yourself (body { margin: 0; }), same as you would for any other full-height layout; this package doesn't touch your page's global styles.
Responsive layout
Above ~720px wide, Build mode is the usual three-column Palette/Canvas/Inspector layout. Below that, Canvas becomes the full-width primary view, and Palette/Inspector become full-bleed drawers that slide over it instead of permanently eating vertical space — a small "Blocks"/"Properties" bar (reusing the same chrome labels as the desktop tabs, no separate i18n) toggles between them. Tapping a block type in the drawer adds it and returns straight to Canvas; tapping a field in Canvas opens the Properties drawer on it automatically. Nothing gets clipped by the widget's own overflow: hidden, and there's no horizontal scrolling. This is CSS-only (a media query in the stylesheet the widget injects) with a small bit of state driving which drawer is open, so it responds to the container's width, not a JS-measured breakpoint — useful if you're embedding it in a narrow sidebar on an otherwise-wide page. Modals size themselves as width: 100% up to a max-width (with padding around the overlay) instead of a fixed pixel width, so they never overflow a narrow viewport either.
Props
| Prop | Type | Description |
|---|---|---|
| theme | Partial<Theme> | Override default colors/layout spacing — see "Features vs. theming" below. |
| features | FormBuilderFeatures | Independently toggle UI surfaces on/off — full-featured by default. See "Features" below. |
| language | string | Initial builder language (defaults to the first entry in languages). |
| languages | { code: string; label: string }[] | Language switcher options (default: EN/JA). |
| strings | partial override of runtime/validation strings, keyed by language code | |
| chrome | partial override of builder-UI strings, keyed by language code | |
| storage | StorageAdapter | Pluggable persistence backend for the builder's own draft/Templates library — see below. |
| onSubmit | (payload: SubmitPayload) => void | Called when Preview mode's Submit button is clicked and validation passes — see below. |
| initialDocument | FormDocument | Seeds the builder with this document on mount instead of the autosaved draft — see "Programmatic integration" below. |
| initialMode | "build" \| "preview" | Which mode the widget mounts into (default "build"). Pair with features.previewMode: false to lock a consumer to just one mode with no tabs — e.g. a fill-only embed that never shows the Build canvas. Or leave previewMode on and pick initialMode per document (e.g. "preview" once a document already has saved data) so the widget opens on the right mode while still letting the built-in tabs switch it. |
| onModeChange | (mode: "build" \| "preview") => void | Fires on mount and on every Build/Preview toggle. Lets a host mirror the current mode (e.g. to show its own Save button only in Build mode) without building a separate tab UI around the widget. |
| onTemplateChange | (change: TemplateChange) => void | Fires when a template is created (source: "new"), overwritten ("saved"), applied as the working document ("applied"), or deleted ("deleted"). change is { id: string \| null; title: string; source }. Useful for syncing a host's own state or backend index. |
| templateClipboardKey | string | Overrides the localStorage key behind "Copy template" / "Paste template" (default "form-page-builder:clipboard"). Instances sharing a key can copy/paste between each other. |
A ref on <FormBuilder /> gives you a FormBuilderHandle (getDocument() / loadDocument() / exportJson() / getTemplate() / loadTemplate()) — see "Programmatic integration" and "Copy / paste templates" below.
Features: features
Every optional UI surface can be switched on or off independently through one features prop, kept deliberately separate from theme/ThemeOverrides (which control how things look, not whether they appear). Everything defaults to true (design defaults to false, matching the pre-features default), so the default <FormBuilder /> is unchanged and fully featured; pass only the keys you want to restrict.
// A forms-only embed: no title editing, no templates/JSON/preview chrome,
// no theming UI, and only two field types available to add.
<FormBuilder
features={{
naming: false,
templates: false,
newForm: false,
autosave: false,
jsonView: false,
previewMode: false,
languageSwitcher: false,
design: false,
blockStyling: false,
contentBlocks: false,
fieldTypes: ["input", "select"],
sections: false,
dragReorder: false,
}}
/>| Key | Type | Default | Controls |
|---|---|---|---|
| naming | boolean | true | The editable form-title input in the toolbar. |
| formTitle | boolean | false | Renders the form title as an <h2> heading above the fields in Preview. Off by default — the host app usually shows the form's name in its own chrome, so leave this off to avoid a duplicate. |
| templates | boolean \| { manage?: boolean; max?: number } | true | The Templates library and the "Save" button. true = full library (browse / apply / save / overwrite / delete). false = no template UI. { manage: false } = pick-and-apply only: the library lists templates and the user can apply one as a starting point, but can't create, overwrite, or delete them and the "Save" button is hidden. { max: n } caps how many templates can be stored (default 5). |
| newForm | boolean | true | The "New Form" reset button. |
| autosave | boolean | true | Autosaving the draft to storage. The initial draft load always happens regardless — this only gates the write path. |
| jsonView | boolean | true | The "View JSON" button and modal. |
| templateClipboard | boolean | true | The "Copy template" / "Paste template" icon buttons next to "View JSON". Paste is enabled only once another builder instance in the same browser has copied one. |
| previewMode | boolean | true | The Build/Preview tabs. When false, the tabs are hidden and the builder stays in whichever mode it started in (initialMode, Build by default). |
| languageSwitcher | boolean | true | The language-switcher pill in the toolbar. |
| design | boolean | false | The global "Design" tab (colors/spacing), i.e. the old themeEditable prop. |
| blockStyling | boolean | true | Per-field styling controls: paragraph heading/font/color, button color — independent of design. |
| contentBlocks | boolean \| ("paragraph" \| "image" \| "button")[] | true | Which content blocks can be added from the palette. true = all, false = none, or an allowlist. |
| fieldTypes | boolean \| ("input" \| "textarea" \| "select" \| "radio" \| "checkboxGroup" \| "checkbox" \| "toggle")[] | true | Which form field types can be added from the palette. true = all, false = none, or an allowlist. |
| sections | boolean | true | Add/duplicate/move/delete-section controls and the "Add section" button. |
| sectionBackground | boolean | inherits sections | Per-section background-color swatches + custom-color picker in the section header. Independent of sections — set it false to lock section backgrounds while keeping the structural controls, or leave it unset and it follows whatever sections is. |
| dragReorder | boolean | true | Drag-to-reorder fields within a section. |
| deviceToggle | boolean | true | The Laptop/Tablet/Mobile width switcher above the Preview canvas. When false, Preview always renders at the Laptop (full) width. |
| maxFields | number | undefined (unlimited) | Caps the total number of input-type fields (not content blocks) addable across the whole document. Once reached, the Form Fields palette buttons disable until a field is removed. |
Disabling contentBlocks/fieldTypes for a given type only hides it from the palette going forward — if a document loaded via initialDocument (or a saved template) already contains fields of a now-disabled type, they still render and remain editable in Build mode; nothing is stripped or hidden.
Styling hooks
The widget styles itself with inline styles and CSS custom properties (see theming below), but the rendered form also carries stable, rule-free class names so a host stylesheet can target it:
| Class | On |
|---|---|
| .fb-form | The Preview form container. |
| .fb-section | Each section wrapper (also data-section-id). |
| .fb-field | Every field wrapper, in Preview and on the Build canvas (also data-field-id). |
| .fb-field--<type> | Same wrapper, by field type — fb-field--input, fb-field--select, fb-field--button, … |
| .fb-field--<id> | Same wrapper, by field id (fb-field--field_1). Field ids are sequential (field_1, field_2, …) and stable across reloads. |
These are additive hooks only — the package ships no rules for them, so anything you write wins without !important.
Features vs. theming
features and theme are separate, composable concerns: features.design decides whether the Design tab's color/spacing controls are shown at all, while theme (and the Design tab, when shown) decide what those colors/spacing values are. You can, for instance, pass a fixed theme with features.design and features.blockStyling both false to lock a form to your brand's colors with zero styling UI exposed to the builder's user.
Dark theme
Every color in the widget — including modals, toggles, and badges, not just the canvas/toolbar — is driven by the theme prop via CSS custom properties, so dark mode is just a different set of color values, no separate "dark mode" flag needed:
import { FormBuilder, DARK_THEME } from "form-page-builder";
<FormBuilder theme={DARK_THEME} />;DARK_THEME (and DEFAULT_THEME, the light palette used when theme is omitted) are both exported as plain Theme objects, so you can spread and tweak either one ({ ...DARK_THEME, primary: "#22c55e" }) or swap between them at runtime for a user-facing light/dark toggle — see the "Localized (French)"-adjacent light/Dark switch in the live demo for a working example.
Persistence: StorageAdapter
By default the component autosaves a draft, plus a "Templates" library (up to 5 saved forms, shown via the toolbar's Templates button), to window.localStorage. Being local-storage-only means neither persists across devices or browsers. To persist the builder's state to your own backend (a Next.js API route, a PHP endpoint, etc.) instead — so drafts and templates are shared across devices, and your backend can populate/manage the template list itself — implement and pass a StorageAdapter. Its get/set/delete calls are the create/update/delete hooks: whatever your implementation does inside them (write to a database, call your API) runs on every template save/update/delete. get/set may be async — the Templates modal shows a spinner while a load is in flight and an inline error if the adapter throws or rejects — and onTemplateChange (see the props table) fires alongside so a host can keep its own index in sync.
interface StorageAdapter {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
delete(key: string): Promise<void>;
}The adapter is called with three kinds of key, exported so you can route them to your own REST endpoints without hardcoding the strings: DRAFT_KEY (the autosaved draft), INDEX_KEY (the template list — a JSON SavedFormMeta[]), and formKey(id) (one saved template). savedFormId(key) returns the id for a per-template key, else null.
import {
FormBuilder, type StorageAdapter,
DRAFT_KEY, INDEX_KEY, savedFormId,
} from "form-page-builder";
const apiStorage: StorageAdapter = {
async get(key) {
if (key === DRAFT_KEY) return localStorage.getItem(key); // keep the draft local
if (key === INDEX_KEY) {
const res = await fetch("/api/form-templates");
return res.ok ? JSON.stringify(await res.json()) : null;
}
const id = savedFormId(key);
const res = await fetch(`/api/form-templates/${id}`);
return res.ok ? JSON.stringify(await res.json()) : null;
},
async set(key, value) {
if (key === DRAFT_KEY) return void localStorage.setItem(key, value);
if (key === INDEX_KEY) return; // index is re-derived from the records on read
await fetch(`/api/form-templates/${savedFormId(key)}`, { method: "PUT", body: value });
},
async delete(key) {
const id = savedFormId(key);
if (id) await fetch(`/api/form-templates/${id}`, { method: "DELETE" });
},
};
<FormBuilder storage={apiStorage} />;This is separate from — and unrelated to — however you choose to handle real end-user form submissions in your own app; this package doesn't send or receive those on its own (see onSubmit below if you want Preview mode's Submit button to hand you the entered values).
Handling submissions: onSubmit
A submit action lives on a Button field — drag one into a section from the palette (next to Paragraph/Image) and set its "When clicked" mode to Submit, with a scope of either "This section" or "Whole form". There's no document-level submit setting anymore; you can place as many buttons as you like (e.g. a per-section "Next" alongside a final "Submit", or a plain "Open link" CTA button that doesn't submit at all).
Clicking a submit-action button validates the fields in its scope and, once they pass, calls onSubmit with the entered values — pass this if you want to do something with them (send to your backend, log them, etc.) instead of just seeing the built-in "here's what would be sent" confirmation:
import { FormBuilder, type SubmitPayload } from "form-page-builder";
function handleSubmit(payload: SubmitPayload) {
// payload.buttonId: id of the button field that triggered this — use it to
// branch when a form has more than one submit button (e.g. "save draft" vs "submit")
// payload.scope: "form" | "section", matching that button's own scope setting
// payload.all: every field's raw value across the whole form, keyed by field id
// payload.sections: the same values, broken down section by section
// payload.values: just whatever was submitted (the whole form, or one section if scope is "section")
console.log(payload);
}
<FormBuilder onSubmit={handleSubmit} />;Programmatic integration
Copying and pasting the "View JSON" output is fine for development, but a production integration usually wants to load and save forms through its own backend in the background instead. Two props/APIs cover that:
initialDocumentseeds the builder with a document (e.g. one your backend just fetched) instead of the autosaved draft.- A ref exposes
getDocument(),loadDocument(doc), andexportJson()so you can pull the current document out (to save it yourself, on whatever schedule/event you choose) or push a new one in, independent of thestorageautosave path:
import { useEffect, useRef, useState } from "react";
import { FormBuilder, type FormBuilderHandle, type FormDocument } from "form-page-builder";
function BuilderPage() {
const ref = useRef<FormBuilderHandle>(null);
const [initialDocument, setInitialDocument] = useState<FormDocument>();
useEffect(() => {
fetch("/api/forms/123").then((r) => r.json()).then(setInitialDocument);
}, []);
async function saveNow() {
if (!ref.current) return;
await fetch("/api/forms/123", { method: "PUT", body: ref.current.exportJson() });
}
if (!initialDocument) return null;
return <FormBuilder ref={ref} initialDocument={initialDocument} />;
}Copy / paste templates
The toolbar's "Copy template" / "Paste template" icons (next to "View JSON", toggle with features.templateClipboard) let a user copy the current form from one builder and paste it into another — across pages, tabs, and reloads in the same browser. Copy writes a portable envelope to a localStorage key (templateClipboardKey, default "form-page-builder:clipboard"); every mounted builder watches that key, so Paste lights up everywhere as soon as something is copied. Paste asks for confirmation, then replaces the working document.
The same portable object is available programmatically — use it to hand a template to your own storage, or to move one between builders without the clipboard:
import { FormBuilder, serializeTemplate, parseTemplate, type FormBuilderHandle } from "form-page-builder";
// via the ref handle
const tpl = ref.current.getTemplate(); // { __fpb: "template", v: 1, document }
ref.current.loadTemplate(tpl); // accepts the object or its JSON string; returns false if unparseable
// or the standalone helpers (e.g. to persist to your backend)
const json = serializeTemplate(ref.current.getDocument());
const doc = parseTemplate(json); // also accepts bare "View JSON" output; null if not a documentUsing in a plain HTML page (no bundler)
There's intentionally no UMD/IIFE global-script build (see below), but you don't need a bundler to use this package — a browser-native ES module setup works too, via an import map and a CDN like esm.sh:
<!DOCTYPE html>
<html>
<head>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18",
"react-dom/client": "https://esm.sh/react-dom@18/client",
"form-page-builder": "https://esm.sh/form-page-builder?external=react,react-dom"
}
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module">
import React from "react";
import { createRoot } from "react-dom/client";
import { FormBuilder } from "form-page-builder";
createRoot(document.getElementById("root")).render(
React.createElement(FormBuilder, {})
);
</script>
</body>
</html>The ?external=react,react-dom query on the form-page-builder import tells esm.sh to reuse the same react/react-dom module the page already imports, instead of bundling its own copy — so there's no duplicate-React problem, and no build step (Vite/Webpack/etc.) required. Since there's no JSX here, components are created with React.createElement(...) instead of <FormBuilder />; everything else (props, storage adapter, etc.) works the same as in the React/Next.js example above.
Local development
npm install
npm run dev # Vite dev harness at http://localhost:5173, imports FormBuilder from src/
npm run typecheck
npm run test # vitest run -- see tests/
npm run build # tsup -> dist/ (ESM + CJS + .d.ts), the published package
npm run build:demo # vite build -> pages-dist/, the GitHub Pages demo siteTests use Vitest + Testing Library against jsdom, covering rendering, the features prop's gating of each UI surface, the FormBuilderHandle ref API, autosave/initialDocument, and the Preview-mode validation + onSubmit flow. npm run test:watch re-runs on change.
Releasing
Versioning and the changelog are managed by Changesets; publishing to npm is a separate, deliberate step.
- On your PR, describe the change for consumers:
npx changeset(pick patch/minor/major, write a summary, commit the generated file in.changeset/). - Once merged to
main, a bot opens/updates a "Version Packages" PR that bumpspackage.jsonand writesCHANGELOG.mdfrom the accumulated changesets (.github/workflows/version.yml). - Merge that PR when you're ready to ship.
- Cut a GitHub Release tagged
vX.Y.Zmatching the new version — this triggers .github/workflows/release.yml, which builds and runsnpm publish --provenancevia npm's Trusted Publishing (OIDC), so noNPM_TOKENsecret is needed.
Pushes to main also rebuild and redeploy the live demo via .github/workflows/pages.yml.
