Forms Overview
Forms capture input from submitters and route it somewhere useful — into a table, through an automation, into the built-in submission ledger, or any combination of the three. They are declared in the top-level forms array, each one reachable at its own URL, embeddable inside a page, and addressable from an automation trigger.
A form is the public-facing counterpart to a table: where a table defines what data looks like, a form defines how people contribute it. The same form can write directly to a tables[] entry, fire a notify-sales automation, or simply land in the platform's form_submissions ledger for later review.
forms:
- id: 1
name: contact
title: Contact Sales
path: /contact
submitTo:
table: leads
fields:
- { kind: table-field, column: email, required: true }
- { kind: table-field, column: message }
Form Properties
Each entry in the forms array accepts the following properties.
| Property | Description |
|---|---|
id |
Unique positive integer identifying the form. Must be unique across forms[]. |
name |
Kebab-case unique identifier (^[a-z][a-z0-9-]*, 1–64 chars). Referenced by page components (formRef) and the form automation trigger. Must be unique across forms[]. |
title |
Human-friendly title shown to submitters. Supports $t: i18n keys. |
description |
Optional intro paragraph rendered above the first field. |
path |
Optional friendly public URL (e.g. /contact). When set, the form is served there and at the canonical /forms/{name}. See Public Routes. |
submitTo |
Where the submission is persisted and/or routed: a table, an automation, the ledger, or a combination. Required. See Submit Targets. |
fields |
Ordered list of field definitions rendered in the form. At least one required. See Form Fields. |
layout |
Rendering mode: single-page (default), multi-step, or one-question. See Multi-Step Forms. |
steps |
Step definitions for multi-step / one-question layouts. See Multi-Step Forms. |
fieldGroups |
Labeled section dividers grouping fields within a single-page layout. |
display |
Cosmetic options (columns, progress bar, submit label, per-form theme). See Multi-Step Forms. |
access |
Access control: all, authenticated, or a role list, with an optional redirectTo. See Access Control. |
availability |
Submission window (opensAt, closesAt) and submission cap (maxSubmissions), with an optional custom closed-form page. |
antiSpam |
Honeypot, sliding-window rate limits, and a CAPTCHA connection stub. |
analytics |
Per-form opt-out (enabled: false) excluding the form from aggregate analytics. Defaults to enabled. |
prefill |
Map of form field → $query.{name} / $user.{prop} / $parent.{path} reference or literal default. See Form Fields. |
submitter |
Save-and-resume, edit-after-submit, and unique-submission rules. |
onSuccess |
Post-submit behavior (success page, redirect, reset, toast, inline message). See Submissions. |
onError |
What to show when a submission fails server-side (toast, inline message, error page). See Submissions. |
Submit Targets
The submitTo object decouples a form from any single persistence model. At least one of table, automation, or storeSubmission: true (the default) must be set — otherwise the submission would be discarded silently and the schema rejects the config.
| Property | Description |
|---|---|
table |
Persist the submission as a record in this table. References a tables[].name; validated against the app's tables[] array. |
automation |
Invoke this automation after the writes commit. References an automations[].name; validated against the app's automations[] array. |
mapping |
Map form field names to destination column names. Defaults to identity (form field name = table column name). A mapping target that does not exist on the table fails validation. |
storeSubmission |
Persist the submission in the built-in form_submissions ledger. Defaults to true — set false to opt out (and then table or automation is mandatory). |
submitTo:
table: support_tickets
automation: page-on-call
mapping:
userEmail: email # form field 'userEmail' -> table column 'email'
Tables, automations, the ledger — or all three. A form can dual-write to a table and the ledger, fire an automation, or do everything at once. The table write and the ledger write happen inside a single transaction; the automation runs only after both commit. See Submissions for the full dual-write contract.
Public Routes
Every form is reachable at the canonical route /forms/{name} regardless of configuration — a form without a path is not private, it is simply only reachable there. When path is set, the form is served at that custom path and at /forms/{name} simultaneously (no redirect; both URLs render the same form).
| Rule | Description |
|---|---|
| Canonical route | /forms/{name} always serves the form (HTTP 200). An unknown name returns 404. |
| Custom path | Optional. Must start with /, 2–256 URL-safe characters; static (no :id dynamic segments). |
| Reserved prefixes | /api/, /admin/, /forms/, and /auth/ are rejected so a form cannot shadow a built-in route. |
| Collision rule | A forms[].path MUST NOT collide with any pages[].path. Collisions fail validation with an error naming both. |
| Embed route | /forms/{name}/embed serves a minimal HTML shell for iframe embedding on third-party sites. |
Access Control
The access object reuses the shared permission model used by tables, pages, buckets, automations, and agents.
| Property | Description |
|---|---|
require |
'all' (everyone, including anonymous), 'authenticated' (any logged-in user), or a role list (['admin', 'editor']). |
redirectTo |
Optional path (e.g. /login) to redirect denied submitters to, instead of returning a 401/403. Must start with /. |
forms:
- id: 2
name: member-survey
title: Member Survey
access:
require: authenticated
redirectTo: /login
submitTo: { table: survey_responses }
fields:
- { kind: standalone, name: rating, inputType: rating, required: true }
$user.* references require authentication. A form whose access.require is 'all' (the default) may not carry a per-field defaultValue referencing $user.* — the value would always resolve empty for anonymous visitors and could leak session state. Set access.require to authenticated or a role list, or use top-level prefill (which silently drops unresolvable $user references on public forms). See Form Fields.
Embedding a Form in a Page
A top-level form can be rendered inline inside a page via the form control's formRef. The form is defined once and reused anywhere — fields, validation, conditional logic, multi-step layout, file uploads, and onSuccess/onError all flow from app.forms[]. The host page's access control is intersected with the form's own rules at render time.
pages:
- id: 1
name: landing
path: /
components:
- type: form
formRef: contact # renders the top-level "contact" form inline
props:
label: Get in touch # display-only override of the submit label
See Form Controls for the page-component side of formRef.
Related Pages
- Form Fields — standalone vs table-bound fields, prefill, inline relationship create.
- Conditional Logic — visible / required / disabled rules.
- Multi-Step Forms — steps, branching, one-question layouts, display overrides.
- Submissions — the dual-write ledger, success and error handling.
- File Uploads — attachment fields backed by buckets.
- Tables Overview — the data models forms write to.
- Form Controls — embedding a form inside a page.
- Auth — roles and authentication behind
access.