Form definition
A form is a document. The builder edits it, the preview draws it, and the public embed fetches the published copy. Host chrome — admin UI kit, theme, commerce platform — stays outside this document so the same form can move to another framework.
Keep the definition in a package with no UI dependencies. Prisma rows, CMS documents, and API JSON all coerce into this shape.
The form
type FormStatus = "DRAFT" | "LIVE" | "ARCHIVED";
type FieldWidth = 100 | 50 | 33 | 25;
interface FormDefinition {
id: string;
name: string;
status: FormStatus;
heading: string;
subheading: string;
buttonLabel: string;
buttonAlign: "left" | "center";
successMessage: string;
/** Integration key, or null when the only target is an email notification. */
destination: string | null;
notifyOnSubmission: boolean;
spamProtection: boolean;
fields: FormFieldDefinition[];
/** Ordered step titles. Omit or leave a single entry for a one-page form. */
steps?: string[];
}
interface FormFieldDefinition {
id: string;
type: string;
label: string;
/** Submission key. Slug of the label until the author overrides it. */
name: string;
placeholder: string;
required: boolean;
width: FieldWidth;
options: string[];
/** 1-based. Ignored when the form has a single step. */
step?: number;
conditional?: ConditionalLogic | null;
}
The submit button lives on the form (buttonLabel, buttonAlign). It collects
nothing, so it stays out of fields. Every consumer would otherwise special-case
the last element.
Field catalog
One manifest per type, ordered the way the palette shows them. control is how
the field draws, separate from type, so a new type that draws like an existing
one (a URL that renders as a text input) needs no renderer change.
interface FormFieldManifest {
type: string;
label: string; // "Short text", "Dropdown"
tag: string; // three letters: TXT, EML, SEL — stands in for an icon
control:
| "input" | "textarea" | "select" | "choice" | "checkbox"
| "file" | "rating" | "slider" | "heading" | "hidden";
hasOptions: boolean;
supportsRequired: boolean;
supportsPlaceholder: boolean;
defaults: {
label: string;
placeholder: string;
options: string[];
required: boolean;
width: FieldWidth;
};
}
Start from this vocabulary. Names are stable identifiers; adding a type is a new manifest row, and renaming one is a migration.
| Type | Control | Notes |
|---|---|---|
| TEXT, EMAIL, PHONE, NUMBER, DATE, TIME | input | |
| TEXTAREA | textarea | |
| SELECT | select | hasOptions |
| RADIO | choice | hasOptions |
| CHECKBOX | checkbox | the placeholder is the consent line |
| FILE_UPLOAD | file | see Uploads |
| RATING, SLIDER | rating, slider | |
| HEADING | heading | display only; supportsRequired: false |
| HIDDEN | hidden | submits a value, draws nothing |
An unknown type degrades to a text input. A form authored against a newer
catalog still opens in an older build.
Names, widths, rows
nameis the submission key: lowercase, underscore-separated, ASCII (toFieldName). Underscores survive spreadsheet headers and CRM property names. Suffix_2,_3when the key is taken.- Changing the label updates
nameonly until the author has edited the name. - Widths are percentages (
100 | 50 | 33 | 25) so a row packer can add them. Three thirds are33and total99— treat anything up to101as a full row. - Pack fields into rows: a full-width field is its own row; narrower fields accumulate until the next would overflow. Renderers emit these rows. Each row records the index of its first field in the flat array so a drop can become an absolute insertion point.
Conditional rules
A field may show or hide from the current values of other fields. The builder preview and the public embed evaluate the same object.
interface ConditionalLogic {
enabled: boolean;
operator: "AND" | "OR";
action: "show" | "hide";
conditions: Array<{
fieldName: string;
operator:
| "equals" | "not_equals" | "contains" | "not_contains"
| "greater_than" | "less_than" | "is_empty" | "is_not_empty";
value: string | number | boolean | null;
}>;
}
Offer only the operators that make sense for the watched field's type. A rule that references a missing field is inactive, and the field stays in its default visibility.
Validation
Return warnings. A half-built form is a normal intermediate state, and refusing to save one loses work. The caller decides what to surface and what to block publish on.
| Code | When |
|---|---|
empty-label | label is blank |
empty-name | submission key is blank (hidden fields included — the key is the point) |
duplicate-name | two fields submit as the same key |
options-required | a select or radio has no options |
no-fields | the form has no fields |
no-submit-target | no destination and email notification is off |
On save, fill any missing name from the label. That pass is idempotent.
Uploads
A file field stores a reference, not bytes. The browser requests a presigned PUT scoped to that file, uploads straight to object storage, and the submission records the resulting id. The form POST stays small.
Locales
A locale is another FormDefinition for the same form id: same field ids and
names, translated labels, placeholders, options, heading, and button. Submissions
record which locale was used. Detecting locales is the host's job.
Pair with form-builder-canvas to edit this document, form-preview and
form-web-component to draw it, and form-integrations for destination.