Create with the Dynamic Form Builder
A ready-made /forms experience: a visual form builder plus a runtime renderer on top of the schema-driven CollectionForm/VForm runtime. An admin arranges a collection's fields into ordered sections, sets width / required / readonly / hidden, and authors conditional logic, then saves the result as a reusable, named form definition. Definitions are stored as items in an ordinary collection and merged onto the live schema at render time via buildFieldsFromDefinition — the schema is never mutated.
This is a two-step workflow: author a definition with FormBuilder, then fill it with DynamicForm. That is why this is a create-* workflow skill, not just a component install.
Relationship to VForm (do not confuse)
@buildpad/ui-forms(this — plural) = the Form Builder module.@buildpad/ui-form(singular) = the low-levelVFormruntime the builder wraps.
The builder emits fields consumed by CollectionForm → VForm. Do not re-implement VForm's controlled-component pattern here — the runtime handles state and submission. See vform-usage for the substrate.
Prerequisite: the definitions collection
Definitions are persisted as items in a single collection (default fb_definitions, overridable via formsCollection). It is an ordinary user collection — not a daas_-prefixed system collection — and must exist before the builder can save:
| field | type | notes |
|---|---|---|
id |
uuid (PK) | auto |
name |
string | form name |
target_collection |
string | collection the form targets |
key |
string (nullable) | optional discriminator so multiple forms can share one collection (e.g. per issue type) |
definition |
json | the FormDefinition body |
If the collection is missing, the list and builder render a FormsEmptyState hint instead of failing. With DaaS schema rights that hint offers a one-click Create collection action. Use create-collection or create-migration to provision it explicitly if you prefer.
Authoring (arranging existing fields) needs only item
create/update/readon the definitions collection plusreadon the target collection's schema — not admin/DDL rights. Only provisioning (creating a collection or a new real column) needs schema rights.
Installation
The Forms module is opt-in — it is not part of bootstrap.
# 1. Add the builder + renderer + /forms pages + useFormDefinitions hook
npx @buildpad/cli@latest add form-builder forms-routes --cwd /path/to/project
# 2. Add the DaaS proxy routes it needs (if not already present)
npx @buildpad/cli@latest add api-routes --cwd /path/to/project
The docs use the shorthand
buildpad add form-builder forms-routes; thenpx @buildpad/cli@latest add …form is the equivalent no-install invocation used across these skills.
If the CLI command fails,
@buildpad/cliIS published to npm — verify withnpm view @buildpad/cli versionbefore assuming otherwise or reaching for a local clone. The CLI fetches its registry fromraw.githubusercontent.com, so confirm the environment can reach both npm and the GitHub raw CDN. A local clone is only needed for CLI development, never to consume components.
The module must render under the authenticated layout (app/(authenticated)/ with a DaaSProviderWrapper) so DaaSProvider header injection is in scope — the hooks call /api/items/* (and, when provisioning in proxy mode, /api/fields/* and /api/collections/*).
DaaS CORS must be configured first.
DynamicForm(viaCollectionForm) fetches/users/me,/permissions/me, and item endpoints from the browser withcredentials: 'include'. DaaS's defaultcors_origins: ["*"]is incompatible with credentialed requests — the browser blocks every preflight (Access-Control-Allow-Origin header must not be the wildcard '*'). Set explicit origins first (see daas-platform "CORS must use explicit origins" — Bugs 17+25 — and debugging-and-error-recovery). This is not form-builder-specific: it affects every authenticated Buildpad component.
The Routes
app/forms/
├── page.tsx # /forms — list of saved definitions
├── new/page.tsx # /forms/new — create (auto-create or bind existing)
├── [id]/page.tsx # /forms/[id] — the FormBuilder editor
└── [id]/fill/page.tsx # /forms/[id]/fill — render with DynamicForm to create an item
Step 1 — Author with FormBuilder
// app/forms/new/page.tsx
import { FormBuilder } from "@/components/ui/form-builder";
// Start from scratch — auto-create a full fb_ collection on save (needs schema rights):
<FormBuilder => router.push(`/forms/${def.id}`)} />
// Bind to an existing collection (hybrid storage):
<FormBuilder targetCollection="issues" => router.push(`/forms/${def.id}`)} />
// app/forms/[id]/page.tsx — edit an existing definition (target comes from the definition)
<FormBuilder definitionId={id} />
| Prop | Type | Purpose |
|---|---|---|
targetCollection |
string |
Required when creating a bound form; omit when editing or "start from scratch" |
definitionId |
string | number |
Edit an existing definition |
formsCollection |
string (default fb_definitions) |
Definitions collection name |
onSaved |
(def: FormDefinition) => void |
Called after a successful save with the persisted definition |
The Build tab is a three-pane workspace — field-type palette (+ Add extra field + existing collection fields) on the left, the section canvas in the middle, a per-field settings panel (width / required / hidden / conditions) on the right — plus a Preview tab that renders the in-progress form live.
Step 2 — Fill with DynamicForm
// app/forms/[id]/fill/page.tsx
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { DynamicForm } from "@/components/ui/form-builder";
export default function FillFormPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
return (
<DynamicForm
definitionId={id}
=> router.push("/forms")}
=> router.push("/forms")}
/>
);
}
| Prop | Type | Default | Purpose |
|---|---|---|---|
definitionId |
string | number |
— (required) | The saved definition to render |
formsCollection |
string |
fb_definitions |
Definitions collection name |
itemId |
string | number |
— | Provide to switch the form to edit mode |
onSuccess |
(data?) => void |
— | Called after a successful create/update |
onCancel |
() => void |
— | Called on cancel |
scopeField |
string |
'resource_uri' |
Target-collection field holding the scope URI (multi-tenant) |
injectActiveScope |
boolean |
true |
Inject the active scope URI as a create-time default |
The List View & Empty State
// app/forms/page.tsx
import { FormsEmptyState, type FormDefinition } from "@/components/ui/form-builder";
import { useFormDefinitions } from "@/lib/buildpad/hooks";
const { list } = useFormDefinitions();
// list().then(setDefinitions).catch(...) — render FormsEmptyState on error/missing collection
Data Model & Hook (quick reference)
- Hook
useFormDefinitions()from@/lib/buildpad/hooks→list,get,create,update,remove,resolveScreen(the API keeps the legacy screen name for a resolved definition), plusloading/error.DEFAULT_FORMS_COLLECTION = 'fb_definitions'. - Types
FormDefinition,FormSection,FormFieldConfigare re-exported from the component barrel@/components/ui/form-builder(as used in the list-view example above).FieldConditionis the exception — it is deliberately not re-exported from the barrel, so import it from@/lib/buildpad/types.
For the full data model, conditions cookbook, storage strategies, and multi-tenant scope behaviour, read references/form-builder.instructions.md.
See It Live
Forms Storybook (port 6010): pnpm --filter @buildpad/ui-forms storybook. The FormBuilder (DaaS) story connects to a real DaaS instance via the storybook-host proxy.
Related
- create-collection — provision the
fb_definitionsor target collections. - create-feature/vform-usage — the VForm runtime the builder wraps.
- buildpad-reference — full component catalog.