Collect data from the public with web forms
Collect web-to-lead, contact and intake submissions from anonymous visitors through one public form, without opening the underlying data to guests.
Scenario
I want anonymous visitors to submit a form — a "Contact us" / "Request a demo" / intake form — that creates a record. But I must not give them access to the base, and they must only be able to set the fields on the form.
Recommended solution
Declare a public form view; the submit route derives a narrow authorization from that declaration. No guest profile, no broad grant — and it works even under secure-by-default auth (ADR-0056 Option A).
1. Mark a form view public
formViews: {
contact: {
type: 'simple',
data: { provider: 'object', object: 'showcase_inquiry' },
sections: [
{ name: 'about_you', label: 'Tell us about yourself', columns: 1, fields: [
{ field: 'name', required: true },
{ field: 'email', required: true },
{ field: 'company' },
{ field: 'message', required: true },
]},
],
sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' },
submitBehavior: { kind: 'thank-you', title: 'Thanks!', message: 'We will be in touch.' },
},
}The submitBehavior above sets the copy, not the behavior: thank-you is already the default on the public path, because an anonymous submitter may not read the record back. The internal path defaults the other way — a type: 'form' action lands the operator on the record it just created — so do not carry a public form's confirmation panel over to an internal one by habit. See submitBehavior is mode-aware.
That sharing block wires the anonymous endpoints automatically:
GET /api/v1/forms/contact-us → resolved form + whitelisted schema
POST /api/v1/forms/contact-us/submit → INSERT one showcase_inquiry2. The field whitelist is the security boundary
Only the fields listed in sections[].fields are accepted on submit; everything else is stripped server-side. Server-controlled fields (status, source, owner stamps) are set by a defaults hook, never by the submitter. The submit route derives a narrow publicFormGrant: { object: 'showcase_inquiry' } — create + read-back of the row it just made, and nothing else.
System-managed anchors (owner_id, organization_id, audit columns, id) are never accepted from a public submission — not even when a section declares them, and not through the zero-sections all-fields fallback. The route filter and the data layer enforce the same set (PUBLIC_FORM_SERVER_MANAGED_FIELDS from @objectstack/spec/security), so an anonymous visitor cannot forge record ownership or tenant placement regardless of how the form is declared (#3022).
3. Keep the object private once inside
Set sharingModel: 'private' on the object so submissions are staff-scoped after creation. The public path only ever inserts; it never lists.
4. Withdraw a public form
To stop taking submissions, keep the form's publicLink and set a switch to false: enabled: false or allowAnonymous: false. Both anonymous endpoints then answer 404 FORM_NOT_FOUND, and nothing is created.
A withdrawal is a kill switch across metadata layers. The anonymous endpoints read the form view layered: the Default Organization's legacy copy of the view, if one exists, is preferred for the form's body, while a withdrawal in either layer closes the form, fail-closed. An organization copy is a legacy row stored before ADR-0131 D6. No write can edit it now: every organization-scoped save or publish is refused with 403 NOT_OVERRIDABLE. Its withdrawal still closes the form. The environment-wide definition is the one switch an author edits. An environment-wide withdrawal closes the form even beneath an open legacy copy. To publish the form again, save it environment-wide with both switches on; a form a legacy copy withdraws stays closed. The promotion ceremony (ADR-0131 C7) carries the legacy layer to the environment layer.
What counts as a withdrawal. Only an explicit false withdraws, on a sharing that keeps its publicLink. A switch that is simply absent is not a withdrawal. Removing the sharing block, clearing the publicLink, or deleting the view at one layer does not withdraw the form at the other layers.
Which form a withdrawal closes. The anonymous endpoints judge each form by the name of the view item they serve. Beneath the Default Organization's legacy copy they read the environment-wide view list, and a form is closed when the environment-wide item of the same name explicitly withdraws a form in the same place (form, the same formViews entry, or the view's own config) or under the same public link. A different view is a different form: a different view that uses the same public link (for example, another app's "contact us" form) neither closes this one nor is closed by it.
Forms a package ships. A package's form is part of the environment-wide definition, not a separate layer beneath it. A definition parsed by the stack schema (strict defineStack, the default) gets the schema's default enabled: false, so a shipped form that keeps its link without setting enabled: true counts as withdrawn. A definition loaded without that parse (defineStack(..., { strict: false }) or a hand-built manifest) is judged as written: a switch it leaves out is absent, which is not a withdrawal, so set enabled: false explicitly to ship a form closed. The environment-wide definition is the administrator's switch: an environment-wide save may open a form that the package ships closed.
Known limit: packages and names. A withdrawal of a view name closes that name in every package. When two packages each ship a view of the same name, one package's withdrawal also closes the other package's form of that name, so this may close more than was meant.
Known limit: legacy copies. The endpoints match a legacy copy's form against the environment-wide definition by place and link. If a legacy copy keeps its form open under a different place and link than the environment-wide definition, an environment-wide withdrawal does not close it, and no write can edit the legacy copy, until the promotion ceremony carries it to the environment layer.
Why
Authorization is derived from the declaration, not configured separately — so the grant can't drift wider than the form. There is no standing "anonymous can write to this object" rule to misconfigure: the only thing the public can do is create one record through one whitelisted form. This is the difference from Airtable, where interfaces can't be shared publicly at all (only forms can) — here the same FormView metadata renders both internally (authed) and publicly (anonymous) through one renderer.
Runnable example
- Object:
examples/app-showcase/src/data/objects/inquiry.object.ts. - Public form:
examples/app-showcase/src/ui/views/inquiry.view.ts.
Anti-patterns
- Granting a
guestprofile broad write to the object. Let the form declaration derive a narrow grant instead. - Relying on the UI to hide server-controlled fields. The submit whitelist is the boundary; fields not listed are stripped regardless of the client.
- Leaving the object's
sharingModelopen so submissions are world-readable. Keep itprivate; the public path only inserts.
See also
- Decision record: ADR-0056 (public forms — Option A).
- Guide: Forms (Public + Internal).
- Reference: Sharing, View / FormView schema.
Audience-based interfaces
Serve one dataset to different audiences: give end users a curated app and keep builder surfaces like Studio and raw tables out of their view by default.
Automation
Hooks, flows, workflows, approvals, scheduled jobs, durable webhooks, and connectors — the process engine that reacts to your data.