ObjectStackObjectStack

Forms (Public + Internal)

Render any FormView either publicly (anonymous, /f/:slug) or internally (authed operators, /forms/:name). The same metadata drives both, with URL prefill, configurable post-submit behavior, and declarative open-form actions.

Forms

ObjectStack Forms are Airtable-style metadata-driven forms with two render modes that share one spec and one renderer:

ModeRouteAuthSpec sourceSubmit target
Public/console/f/:sluganonymousGET /api/v1/forms/:slug (resolved by sharing.publicLink)POST /api/v1/forms/:slug/submit (field whitelist + declaration-derived publicFormGrant)
Internal/console/forms/:nameauthedGET /api/v1/meta/view/:name (+ meta/object/:object)POST /api/v1/data/:object (full RBAC)

Both modes:

  • Use the same FormView Zod schema (@objectstack/spec/ui)
  • Render through the same FormPage renderer shipped by the ObjectUI console (a separate package/repo)
  • Honor ?prefill_<field>=<value> URL params
  • Honor submitBehavior (thank-you / redirect / continue / next-record) — with mode-aware defaults when it is omitted (see §8)

A public form is the Salesforce Web-to-Lead style embeddable form — declare a FormView with sharing.allowAnonymous: true, give it a publicLink, and the framework wires the anonymous REST endpoints automatically.

Architecture at a glance

Visitor                        Framework                       Driver
───────                        ─────────                       ──────
GET  /api/v1/forms/:slug   →   rest-server scans views     →
                               match sharing.publicLink    →   (no DB read)
                           ←   { form, objectSchema }

POST /api/v1/forms/:slug/  →   field whitelist (form)
     submit                →   derive publicFormGrant:
                               { object: <form target> }   →
                               protocol.createData()       →   INSERT
                           ←   { object, id, record }

Only the spec'd whitelist of form fields is accepted; everything else (status, owner, internal_notes, …) is stripped server-side. Lifecycle hooks then stamp server-controlled defaults.

Self-authorizing forms (ADR-0056 Option A). The submit route does not require a deployment-configured profile. It derives a narrow publicFormGrant from the form's own declaration — { object: <the form's target object> } — and the SecurityPlugin authorizes only create + the immediate read-back on exactly that object, never anything else and never the anonymous fall-open. So public forms work under the unconditional anonymous-deny posture (v17 retired the requireAuth: false opt-out, #3963) with no guest_portal permission set. The guest_portal permission set + anonymous flag are still attached for back-compat (object hooks that detect a guest via a falsy ctx.user?.id), but they are no longer the authorization mechanism.

1. Declare the form view

// hotcrm/src/views/lead.view.ts  (https://github.com/objectstack-ai/hotcrm)
import { defineView } from '@objectstack/spec';

export default defineView({
  /* ...default `list` / named `listViews` for `lead` live here too — the
     container's target object is derived from a view's `data.object`... */
  formViews: {
    web_to_lead: {
      type: 'simple',
      data: { provider: 'object', object: 'lead' },
      sections: [
        {
          label: 'Tell us about yourself',
          columns: 2,
          fields: [
            { field: 'first_name', required: true },
            { field: 'last_name',  required: true },
            { field: 'email',      required: true },
            { field: 'phone' },
            { field: 'company',    required: true },
            { field: 'title' },
            { field: 'industry' },
            { field: 'website' },
            { field: 'annual_revenue' },
            { field: 'no_of_employees' },
            { field: 'description' },
          ],
        },
      ],
      sharing: {
        enabled: true,
        allowAnonymous: true,         // ← required
        publicLink: '/forms/contact-us', // ← the slug ":contact-us" wires this view to the public route
      },
    },
  },
});

Rules:

  • The slug in publicLink (contact-us) becomes the :slug segment in the REST URL.
  • Anything not in the sections[].fields[] whitelist is silently stripped at submit time. Treat the whitelist as the form's authoritative "what the public is allowed to set" list.
  • A form whose sections declare no fields collects nothing, so the submit is refused (400 VALIDATION_ERROR) rather than accepting whatever the caller sent (#6920). Its GET /forms/:slug publishes no schema either (#6601) — declare the fields and both planes come alive together.
  • Multiple form views per object are fine — only the one(s) with sharing.allowAnonymous === true are exposed.

2. (Optional) Create the guest_portal permission set

Authorization no longer depends on this set — the declaration-derived publicFormGrant (see the note above) is what permits the insert. You only need a guest_portal permission set if you rely on the legacy back-compat path (e.g. an older runtime, or object hooks that branch on the guest_portal permission). When present it is still attached to the anonymous context, so keep it INSERT-only on the target object — the guest-safe shape (ADR-0090 D9).

// hotcrm/src/security/guest-portal.permission.ts
import { definePermissionSet } from '@objectstack/spec/security';

// Guest-safe capability: INSERT-only on the intake objects. (`isProfile` no
// longer exists — the Profile concept was removed by ADR-0090 D2.)
export default definePermissionSet({
  name: 'guest_portal',
  label: 'Public Form Submitters',
  objects: {
    lead: { allowCreate: true },  // no read/edit/delete
    case: { allowCreate: true },
  },
});

3. Stamp server-controlled defaults in a hook

Anonymous submitters cannot be trusted to set lead_source, status, owner, etc. A beforeInsert hook keyed off the absence of ctx.user.id is the canonical place to:

  • Apply safe defaults (status='new', lead_source='web', …)
  • Strip any internal fields that slipped past the whitelist
// hotcrm/src/objects/lead.hook.ts
import type { Hook, HookContext } from '@objectstack/spec/data';

const leadGuestDefaults: Hook = {
  name: 'lead_guest_defaults',
  object: 'lead',
  events: ['beforeInsert'],
  priority: 200,
  handler: async (ctx: HookContext) => {
    const isGuest = !ctx.previous && !ctx.user?.id;
    if (!isGuest) return;
    // For inserts the incoming record lives at ctx.input.data, not on ctx.input itself.
    const rec = ctx.input.data as Record<string, unknown>;
    if (!rec.lead_source) rec.lead_source = 'web';
    if (!rec.status)      rec.status      = 'new';
    delete rec.owner;
    delete rec.is_converted;
    delete rec.converted_account;
    delete rec.converted_contact;
    delete rec.converted_opportunity;
    delete rec.converted_date;
  },
};

export default leadGuestDefaults;

Why both !ctx.previous and !ctx.user?.id? ctx.previous is set on update/delete but undefined on insert, so the combined check protects the "new record by an anonymous caller" path specifically. Backend users (or automated jobs) bypass the guest branch.

4. The REST contract

Both routes are mounted under the active environment's API base. For a standalone environment that is /api/v1; for scoped deployments it is /api/v1/environments/:environmentId.

GET /api/v1/forms/:slug

Returns the form spec + a restricted object schema for the whitelisted fields:

{
  "slug": "contact-us",
  "object": "lead",
  "label": null,
  "form": { "type": "simple", "sections": [/* … */], "sharing": {/* … */} },
  "objectSchema": {
    "name": "lead",
    "label": "Lead",
    "fields": { "first_name": {/* … */}, "email": {/* … */}, "company": {/* … */} }
  }
}

The top-level label is view.label ?? form.label — it is not taken from a section label. The Section-1 example sets neither, so it comes back null; add a label to the form view if you want a value here.

objectSchema.fields contains only the fields referenced by the form, so a public client can render labels, types, and select options without an auth-protected meta lookup.

POST /api/v1/forms/:slug/submit

curl -X POST http://localhost:3000/api/v1/forms/contact-us/submit \
  -H 'Content-Type: application/json' \
  -d '{
    "first_name": "Ada",
    "last_name":  "Lovelace",
    "company":    "Analytical Engines Ltd",
    "email":      "ada@example.com",
    "status":     "qualified",        // ← stripped (not in whitelist)
    "owner":      "00000000-0000-0000-0000-000000000001"  // ← stripped
  }'

Response on success (HTTP 201 Created):

{ "object": "lead", "id": "r7p8cUZoBJbFWudt", "record": {
    "id": "r7p8cUZoBJbFWudt",
    "first_name": "Ada", "last_name": "Lovelace",
    "status": "new",         // ← hook default
    "lead_source": "web",    // ← hook default
    "owner": null            // ← whitelist stripped, hook deleted
} }

Errors:

StatusCodeWhen
400 INVALID_REQUESTmissing / blank slug (an empty body is coerced to {} and surfaces as VALIDATION_FAILED below, not here)
400 VALIDATION_ERRORthe form's sections declare no fields, so it collects nothing — wire the fields and resubmit (#6920)
400 VALIDATION_FAILEDobject schema validators fail (required, format, length, …)
403 PERMISSION_DENIEDthe resolved profile does not allow create on the target object
404 FORM_NOT_FOUNDslug not registered on any sharing.allowAnonymous: true view
5xx (generic)driver / hook threw — submit errors are mapped by mapDataError; there is no dedicated FORM_SUBMIT_FAILED code

The companion GET /api/v1/forms/:slug route returns 500 FORM_RESOLVE_FAILED if form resolution itself throws.

GET /api/v1/forms/:slug/lookup/:field — the public picker

Lookup, master-detail and user fields are stripped from a public form by default — surfacing one to anonymous visitors means exposing a record search to the internet, so it is opt-in per field, like Airtable's "Allow linking to existing records" toggle. The opt-in is a publicPicker block on the field's entry in sections[].fields[] (declarable since #7467):

sections: [{
  label: 'About you',
  fields: [
    'company',
    { field: 'owner', publicPicker: { displayFields: ['name'], maxResults: 10 } },
  ],
}]
publicPicker keyMeaning
displayFieldsFields projected into each result row (plus id); the visitor's q is contains-matched against the first entry. At most 5; omitted → ['name'].
maxResultsRows per request, integer 1–50 (default 20). 50 is a hard server ceiling; there is no pagination on this surface (offset is pinned to 0), so a leaked endpoint cannot enumerate the table.
filterStatic pre-filter rows (same { field, operator, value } dialect as list-view filters), ANDed ahead of the visitor's search.
objectThe object to search. Optional — omit it and the server resolves the target from the field's own definition on the parent object (its reference, or a legacy referenceTo / target / options.objectName on a pre-fold stored row). Declare it only to search something other than what the field points at.

Those four keys are the whole block. It admits exactly what the route enforces — an unknown subkey, a 6th display field, or maxResults: 51 is a parse error at authoring time, not a silently-adjusted request.

Result ordering is fixed: the first displayFields entry, ascending. It is not configurable — publicPicker.sort is an unknown subkey, and #7485 retired the route's read of one — so a picker's rows arrive in one predictable order whatever the target object's own default ordering is.

curl 'http://localhost:3000/api/v1/forms/contact-us/lookup/owner?q=ada'
{ "data": [{ "id": "usr_1", "name": "Ada Lovelace" }],
  "total": 1, "truncated": false, "displayFields": ["name"] }

Errors:

StatusCodeWhen
400 INVALID_REQUESTmissing / blank slug or field
403 LOOKUP_NOT_PUBLICthe field has no publicPicker block — the deliberate loud default (#3022); also any server-managed anchor (owner_id, organization_id, …), which never gets a picker even if one is declared
404 FORM_NOT_FOUNDslug not registered on any sharing.allowAnonymous: true view
500 LOOKUP_TARGET_MISSINGthe referenced object could not be resolved from either publicPicker.object or the field definition — the field names no target object at all (or its object metadata is unreachable). Until #7486 this also fired for a perfectly well-formed field, because the fallback read only the legacy spellings and not the canonical reference; declaring object was the workaround and is no longer needed.

Auth model

  • None of the three routes calls enforceAuth, so they work under the always-on anonymous-deny default (there is no requireAuth knob to configure since v17).
  • The execution context the submit route hands to ObjectQL is { publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true } with no userId. The Security plugin honors publicFormGrant first — a create + read-back grant scoped to exactly the declared object — so authorization holds even without a guest_portal profile. permissions: ['guest_portal'] is retained for back-compat. The lookup route's search context is { permissions: ['guest_portal'], anonymous: true } — no publicFormGrant (it reads the picker's target object, not the form's), which is why its result set is bounded by the picker declaration instead.
  • No CSRF or auth header is needed; embed the form on any domain.

5. Embedding from a front-end

// minimal client — frontend frameworks/SDKs ship their own <PublicForm />
async function loadForm(slug: string) {
  const r = await fetch(`/api/v1/forms/${slug}`);
  if (!r.ok) throw new Error(`form ${slug} not found`);
  return r.json(); // { form, objectSchema }
}

async function submit(slug: string, payload: Record<string, unknown>) {
  const r = await fetch(`/api/v1/forms/${slug}/submit`, {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify(payload),
  });
  if (!r.ok) throw new Error(await r.text());
  return r.json();
}

The ObjectUI console (a separate package/repo) ships a unified FormPage renderer at /console/f/:slug (public) and /console/forms/:name (internal) that does exactly this.

6. Internal forms (/console/forms/:name)

Sometimes you want the same FormView metadata to power an authed operator flow — "new lead", "new ticket", a queue triage screen. Set the FormView up normally (no sharing.allowAnonymous) and navigate to /console/forms/<view_name>:

// hotcrm/src/views/lead.view.ts (internal form variant)
export default defineView({
  formViews: {
    quick_create: {
      type: 'simple',
      data: { provider: 'object', object: 'lead' },
      sections: [
        {
          label: 'Lead',
          columns: 2,
          fields: [
            { field: 'first_name', required: true },
            { field: 'last_name', required: true },
            { field: 'email', required: true },
            { field: 'company' },
            { field: 'lead_source' },
          ],
        },
      ],
    },
  },
});

Operators hit /console/forms/quick_create. The renderer:

  • Loads metadata via GET /api/v1/meta/view/quick_create
  • Pulls the object schema via GET /api/v1/meta/object/lead
  • Submits to POST /api/v1/data/lead carrying the auth cookie

Permissions are enforced server-side just like every other authed write — no guest_portal profile required.

7. URL prefill

Both modes accept ?prefill_<field_name>=<value> query params. Use this to seed forms from email links, CRM segments, or campaign pages:

/console/f/contact-us?prefill_company=Acme&prefill_email=ada@example.com
/console/forms/quick_create?prefill_lead_source=event_booth_2026

The renderer maps each prefill_<name> param to the corresponding form field. Values are still subject to validation and (for public mode) the server-side whitelist — prefill is a UX shortcut, not a permissions bypass.

8. submitBehavior — what happens after submit

Add a submitBehavior discriminated union to a FormView to control the post-submit experience:

formViews: {
  web_to_lead: {
    type: 'simple',
    sections: [/* … */],
    sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' },

    // Default: show a thank-you panel
    submitBehavior: {
      kind: 'thank-you',
      title: 'Thanks!',
      message: 'A specialist will reach out within 24 hours.',
    },

    // Or: redirect to an in-app path (relative only — see below)
    // submitBehavior: { kind: 'redirect', url: '/thanks', delayMs: 500 },

    // Or: reset the form for another response (kiosks, batch entry)
    // submitBehavior: { kind: 'continue' },

    // Or: advance to the next record in a queue (internal mode only)
    // submitBehavior: { kind: 'next-record' },
  },
},
kindRenderer behavior
thank-youReplace the form with a confirmation panel (title, message).
redirectwindow.location.assign(url) after delayMs (defaults to 0).
continueRe-read prefill values and reset state — user can submit another response without reload.
next-recordStub for queue contexts; falls back to thank-you when no queue is wired.

What a redirect url may be

url is not a free-form address. It was ruled on 2026-08-11 (#7496) and the schema enforces it, so a URL outside this shape is a parse error at authoring time rather than a surprise in the browser:

  1. Relative paths only. The value must start with a single /. Absolute URLs (https://example.com/thanks, and equally javascript: / data:), protocol-relative //example.com/thanks, backslashes, and smuggled whitespace or control characters are all refused. A post-submit redirect is authored metadata that sends a real browser somewhere — leaving it open to any address makes every form an open redirect waiting for one careless copy-paste. To send someone out of the app deliberately, that is an app navigation item ({ type: 'url', url }), which is declared for external addresses.
  2. Interpolation only from declared record fields, spelled {{record.field_name}} — the same double-brace template dialect the rest of the platform uses, narrowed to the record that was just submitted and to a flat field name. Every interpolated value is URL-escaped when the redirect is built, so a token is a value in the path or query and can never add path structure.
  3. A verbatim redirect on the resolved relative path is the intended consumption — what the renderer navigates to is exactly this string with its tokens substituted.
// ✅ accepted
submitBehavior: { kind: 'redirect', url: '/thanks' }
submitBehavior: { kind: 'redirect', url: '/records/{{record.id}}', delayMs: 500 }
submitBehavior: { kind: 'redirect', url: '/thanks?ref={{record.public_ref}}' }

// ❌ refused, with the rule named in the error
submitBehavior: { kind: 'redirect', url: 'https://example.com/thanks' }  // absolute
submitBehavior: { kind: 'redirect', url: '//example.com/thanks' }        // protocol-relative
submitBehavior: { kind: 'redirect', url: 'thanks' }                      // document-relative
submitBehavior: { kind: 'redirect', url: '/u/{{os.user.id}}' }           // not a record field

Widening this — an allowlist of absolute origins, say — waits for measured demand; the ruling took the narrowest shape deliberately.

Current renderer status (2026-08-11). The console shipping at the pinned .objectui-sha redirects verbatim on this value: it does not yet substitute {{record.…}} tokens or escape them. The three rules above are the contract, and the consumer half is tracked in objectui#4190. Until it lands, a token would reach the browser literally — so write a plain path unless you are following that issue.

The default is mode-aware

submitBehavior is optional, and what you get for omitting it depends on the mode — a public collection form and an authed create have different right answers (ruled 2026-08-10 on #7245):

ModeDefault when submitBehavior is omittedWhy
Public (/console/f/:slug){ kind: 'thank-you' } — the confirmation panelThe anonymous submitter may not read the record back, so a receipt is all there is to show.
Internal (/console/forms/:name)Redirect to the created recordAn operator who just created a record belongs on that record, not on a "submission received" receipt.

An explicit submitBehavior overrides the default in either mode, so nothing here removes an option — it only changes what an author gets for declaring nothing.

Current renderer status (2026-08-10). The console shipping at the pinned .objectui-sha still applies thank-you as the default in both modes. The table above is the contract; the renderer half is tracked in ObjectUI and linked from the #7245 thread. Until it lands, declare submitBehavior explicitly on an internal FormView that must land somewhere specific.

9. type: 'form' action — declarative form launchers

App actions can declare type: 'form' to open a FormView without resorting to free-form URLs. The target is the FormView name; the runtime navigates to /console/forms/:name inside the console shell — the operator keeps the sidebar, navigation and breadcrumb, and a successful submit lands on the created record per the mode-aware default above. The full contract is stated on the Action Protocol page.

// hotcrm/src/actions/new-lead.action.ts
import { Action } from '@objectstack/spec/ui';

export default Action.create({
  name: 'new_lead',
  label: 'New Lead',
  type: 'form',
  target: 'quick_create', // FormView name
});

Compared to { type: 'url', target: '/console/forms/quick_create' }, the 'form' variant is portable across runtimes — Studio/console/native shells can route it through their own form renderer instead of a raw navigation.

10. Record detail is driven by semantic roles, not a form binding

There is no object-level key that pins a FormView to the record-detail / edit screen (the once-documented defaultDetailForm was never implemented and has been removed from ObjectSchema). Record-detail rendering is derived from the object's cross-surface semantic roles (ADR-0085):

  • nameField — which field is the record's display name.
  • highlightFields — the most important fields (detail highlight strip, default list columns, cards).
  • stageField — the linear-lifecycle field behind the detail progress stepper (false suppresses detection).
  • fieldGroups + Field.group — semantic grouping, rendered identically on forms, modals, and detail pages.

When a record page needs a bespoke layout beyond what the roles derive, assign the object a custom Page schema — that is the supported per-page customization path. FormViews remain the metadata for the two form experiences this guide covers: public collection (/f/:slug) and internal entry (/forms/:name).

11. Security checklist (public mode)

  • Whitelist enforced server-side — clients cannot widen the field set by hand-crafting JSON.
  • Hook strips server-controlled fieldsowner, status, internal_notes, is_* flags, conversion fields are removed even if they survive the whitelist.
  • Grant is create + read-back only — the declaration-derived publicFormGrant authorizes only insert (and the immediate read-back) on the form's target object; anonymous callers cannot read other records, edit, or delete via the form path.
  • No tenancy leakage — submissions land on the project resolved from hostname/path, not from a client-supplied tenant id.
  • Rate limiting / captcha — not built in. Add a reverse-proxy rate limit (e.g. nginx, Cloudflare) or fronting plugin if the form sits on the public internet.
  • Schema disclosureGET /forms/:slug returns labels and select options for whitelisted fields. If any of those are commercially sensitive, do not include them in the form.

See also

On this page