standards
SDK

Forms

Build multi-step data-collection forms that write into one or more records.

A form collects data and writes it into records. Unlike a detail view, which edits one record in place, a form can populate several records at once — an onboarding form that creates a contact and their company, for example.

Each target record is a slot. Fields point at a slot and an attribute of that slot's object, so the same form can lay out fields from different objects side by side. (A form slot is a target record — unrelated to document attributes.)

A minimal form

import { form } from "@stndrds/schema";

export const CONTACT_INTAKE = form("contact-intake", "New contact")
  .slot("contact", "contacts", { label: "Contact", mode: "create" })
  .step("identity", "Identity")
  .field("contact", "firstName", { required: true })
  .field("contact", "lastName", { required: true })
  .field("contact", "email")
  .build();

The builder reads top to bottom: form(name, label) opens it, .slot() declares what the form writes into, .step() opens a step, and .field() adds a field to the current step. .build() freezes the definition.

Builder methods

MethodEffect
.slot(id, objectName, options)Declare a target record. Options: label (required), mode, color, icon.
.step(id, label)Open a step. Calling it again closes the previous one.
.description(text)Help text — on the form before any step, on the step after one.
.icon(name)Form icon.
.status(value)"draft" (the default), "published", or "archived". Only published forms are offered to users.
.metadata(object)Arbitrary key/value data carried with the form.
.build()Finalize. Validates slot references, prefill expressions, and free-field types.

Inside a step you get .field(), .freeField(), .row(), .heading(), .text(), and .separator() — covered below.

Slots

A slot binds a name to an object and says how the record is chosen:

.slot("holder", "contacts", { label: "Account holder", mode: "select", color: "blue", icon: "user" })
.slot("coHolder", "contacts", { label: "Co-holder", mode: "optional", color: "red", icon: "user" })
ModeBehavior
createAlways creates a new record.
selectThe user picks an existing record.
optionalThe user can pick an existing record or skip the slot entirely. A skipped slot ignores its fields.
create_if_not_emptyCreates a new record only if the user fills at least one of the slot's fields; otherwise nothing is written.

label is required. color and icon identify the slot in the form UI, so fields from different records stay visually distinct.

Steps

Every field lives in a step. One step gives a single-page form; several steps give a wizard.

form("onboarding", "Onboarding")
  .slot("contact", "contacts", { label: "Contact", mode: "create" })
  .slot("company", "companies", { label: "Company", mode: "create_if_not_empty" })
  .step("identity", "Identity")
  .description("Who are we onboarding?")
  .field("contact", "firstName", { required: true })
  .field("contact", "lastName", { required: true })
  .step("company", "Company")
  .field("company", "name")
  .field("company", "website")
  .build();

Calling .step() again closes the previous step and opens the next — no .done() needed. .description() adds help text under the step title.

Layout

Within a step, four methods shape the layout:

MethodEffect
.heading(content, level?)A heading, level 1, 2, or 3.
.text(content)A paragraph of explanatory text.
.separator()A horizontal rule.
.row(id)Opens a row — fields added inside sit side by side. Close it with .endRow().
.heading("1. Identity", 1)
.row("name-row")
  .field("holder", "lastName", { required: true })
  .field("coHolder", "lastName")
.endRow()
.separator()

Fields added directly to the step (outside any row) each take a full row.

Fields

.field(slotId | slotId[], attribute, options?) adds a field bound to an attribute of the slot's object:

.field("contact", "email", { label: "Work email", required: true, tooltip: "We only use this for the contract." })

required here is form-level: it makes the field mandatory in this form even if the underlying attribute is optional.

To ask the same question for several slots at once, pass an array of slot ids:

.field(["holder", "coHolder"], "taxCountry")

Free fields

A free field collects an answer that isn't stored on any object — a consent checkbox, a one-off question. Declare it with an attribute builder, inline:

.freeField(text({ name: "referral", label: "How did you hear about us?" }))
.freeField(
  select({ name: "riskProfile", label: "Risk profile" }).options([
    { value: "prudent", label: "Prudent" },
    { value: "balanced", label: "Balanced" },
    { value: "dynamic", label: "Dynamic" },
  ]),
  { required: true },
)

Free fields can't use every attribute type: relations (single and multi), formulas, rollups, documents, rich text, and user references have no record to attach to, so .build() rejects them.

Prefill expressions

A free field can start pre-filled from slot data with prefillExpression, which interpolates values from the form's slots:

.freeField(text({ name: "fullName", label: "Full name" }), {
  prefillExpression: "{{ holder.firstName }} {{ holder.lastName }}",
})

The expression resolves once, server-side, when the submission is created — not when the form is rendered on the client. Empty references interpolate as an empty string, so a partially filled slot yields a partial value; if every reference is empty, the field stays empty.

Registration

Forms go in the formRegistry, alongside objects and views:

import { formRegistry } from "@stndrds/schema";

formRegistry.register(CONTACT_INTAKE);

Pass the registry to SchemaModule.forRoot({ formRegistry }) — see Server setup.

A form is "draft" until you publish it, and only published forms are offered to users:

form("contact-intake", "New contact").status("published");

Filling forms

On the frontend, a set of form hooks drives a submission through its steps: useForms and useFormByName load the definition, useCreateFormSubmission starts one, then useSaveFormStep and useAdvanceFormStep move through it. A submission holds the answers until it is completed, so a user can leave a long form and come back to it.