Forms

Forms definition

What a form is made of, from fields and conditions to open periods, the email it sends and captcha.

This page describes the definition: what a form asks, who may answer it and where the answers go. It is for app developers who write forms in code or want to know what the builder can do. Set up the module first: Overview.

The definition

The client and the server compile the same definition into one validation schema, so both agree on what is required. Each submission stores a copy of the definition it was answered against, so old submissions still render after the form changes.

Write a definition in code with defineForm() from @frigear-nu/forms/utils. These are its types, abridged:

type FormDefinition = {
  mode: 'single' | 'stepped' // 'single' has exactly one step and no stepper
  storage: 'db' | 'none' // default 'db'
  steps: FormStep[]
  submit?: {
    label?: Translated
    successMessage?: Translated
    redirect?: string // same-origin path
    allowResubmit?: { keep: string[] } // offers "send another"; these field keys stay filled in
  }
  delivery?: {
    email?: {
      to: string[] // literal addresses; ${...} placeholders are rejected
      subject?: Translated // may use {{variables}}
      body?: Translated // plain text with {{variables}}, up to 10,000 characters per language
      routes?: { field: string, to: Record<string, string[]> } // recipients by one field's answer, falling back to `to`
      replyToField?: string // key of an email field
    }
  }
  access?: FormAccess // default { availability: 'web', auth: 'none' }
}

type FormStep = { id: string, title?: Translated, description?: Translated, showIf?: Condition, fields: FormField[] }

type FormField = {
  key: string // unique across the form; the key of the answer
  type: 'text' | 'email' | 'tel' | 'number' | 'textarea' | 'select' | 'radio' | 'checkbox' | 'date' | 'file' | 'markdown' | 'captcha'
  label?: Translated
  description?: Translated
  placeholder?: Translated
  content?: Translated // markdown fields
  required?: boolean
  options?: { value: string, label: Translated }[] // select / radio
  multiple?: boolean // file fields
  autocomplete?: string // defaults: 'email' for email, 'tel' for tel ('tel-national' next to a country selector)
  validation?: { min?, max?, minLength?, maxLength?, pattern?, maxFiles?, maxSize?, accept?: string[], message?: Translated }
  phone?: PhoneConfig // tel fields
  captcha?: CaptchaConfig // captcha fields
  showIf?: Condition
}

type Condition = { field: string, op: 'eq' | 'neq' | 'in' | 'empty' | 'notEmpty' | 'truthy', value?: unknown }

type FormAccess = {
  availability: 'web' | 'api' // 'api' = no public page
  auth: 'none' | 'user' | 'token' | 'password'
  schedule?: Schedule // default always open; see Open period
}

Translated is a string or a { da, en } map. It falls back to en, then to the first value.

storage: 'none' never stores the answers. They only reach the forms:submitted hook and the email.

Rules a definition must follow

The module checks these rules when a definition is saved or seeded.

  • Conditions. showIf sees only the answers of input fields earlier in the form. value is the compared value for eq and neq, and a list for in. empty, notEmpty and truthy (an alias of notEmpty) ignore it.
  • Hidden fields. A hidden field is not required, its answer is dropped, and a stale answer never keeps another field shown.
  • Keys. Field keys start with a letter and contain letters, digits and underscores. Keys and step ids are unique.
  • Fields. select and radio need options. markdown needs content and collects no answer. file needs storage: 'db'. captcha is always required while visible.
  • Files. maxSize defaults to 10 MB, and maxFiles to 10, or 1 without multiple.
  • Access. auth: 'token' requires availability: 'api', because nobody can type a token into a page.
  • Email. routes.field must be an earlier input field, and replyToField an email field.

Phone fields

A tel field is one free-form input by default. phone.mode: 'country' adds a searchable country selector.

type PhoneConfig = {
  mode?: 'simple' | 'country' // default 'simple'
  countries?: string[] // ISO 3166-1 alpha-2, in display order; default all. Enforced on the server.
  defaultCountry?: string // default 'DK' when offered, else the first offered country
}

{ key: 'phone', type: 'tel', phone: { mode: 'country', countries: ['DK', 'SE', 'NO'], defaultCountry: 'DK' } }
  • One string. With a selector, the answer is +<dial code><number>, such as +4512345678. So submissions, emails and the API never see two values.
  • Trunk prefix. A leading 0 is dropped. Italian numbers keep it.
  • Length. Country mode needs an international (E.164) number of at most 15 digits, with at least 4 after the code.
  • Helpers. @frigear-nu/forms/utils exports composePhone, parsePhone, phoneCountries and allPhoneCountries.

Open period

access.schedule limits when a form accepts answers, such as a sign-up season. from is inclusive and to exclusive. A yearly period whose end is before its start wraps over new year.

// Every year from 1 Jan 23:00 to 1 Feb 23:00 (Oslo wall-clock time).
access: {
  availability: 'web',
  auth: 'none',
  schedule: {
    type: 'yearly',
    timezone: 'Europe/Oslo',
    from: { month: 1, day: 1, time: '23:00' },
    to: { month: 2, day: 1, time: '23:00' },
  },
}

// One fixed period (either end may be left out for an open-ended window).
schedule: { type: 'range', from: '2026-03-01T10:00:00Z', to: '2026-03-15T10:00:00Z' }

Outside the period, the public page shows a "closed" notice with the next opening, if there is one. The definition and submit routes answer 403 with data: { closed: true, opensAt }. For your own interface, @frigear-nu/forms/utils exports isFormOpen and nextFormOpening.

Email body and variables

Without delivery.email.body, the email lists every visible answer. The body is picked by the submission's locale, then en. subject takes the same variables.

delivery: {
  email: {
    to: ['office@example.com'],
    subject: 'New message from {{ answer.name }}',
    body: { en: 'Hi team,\n\n{{answer.name}} wrote via {{form.name}}:\n{{answer.message}}\n\nAll answers:\n{{answers}}' },
  },
}
VariableReplaced with
{{form.name}}, {{form.slug}}, {{form.version}}The form's name, slug and version.
{{submission.id}}The stored submission id. Empty when storage: 'none'.
{{submission.createdAt}}, {{submission.locale}}The ISO timestamp and locale of the submission.
{{answers}}Every visible answer except captcha, as label: value lines. The HTML version is a table.
{{answer.<fieldKey>}}One answer, formatted as in the default email: option labels, joined lists, file names, yes or no.
{{files}}The names of the files stored with the submission.
{{replyTo}}The reply-to address from replyToField.
  • Empty values. Unknown variables, hidden fields and unanswered fields render empty. Spaces inside the braces are ignored.
  • Unknown variables. The builder warns about them (unknownBodyVariables), but never blocks the save.
  • No re-scan. A value is inserted once, so an answer that contains {{form.name}} stays literal.
  • Escaping. The body is plain text. The HTML version escapes every value and turns newlines into <br>. The subject collapses line breaks, so an answer cannot add email headers.
  • Preview. composeEmail, renderTemplateText and renderTemplateHtml in @frigear-nu/forms/utils render the email, so the builder previews the exact result.

Captcha

A captcha field renders Cloudflare Turnstile, Google reCAPTCHA v2 (checkbox) or hCaptcha.

type CaptchaConfig = {
  provider?: 'turnstile' | 'recaptcha' | 'hcaptcha' // default: captcha.defaultProvider
  siteKey?: string // public; overrides the configured site key for this field
  theme?: 'auto' | 'light' | 'dark'
  size?: 'normal' | 'compact'
}

{ key: 'human', type: 'captcha', captcha: { provider: 'turnstile' } }
  • Site key. The field's siteKey, else the configured one. The builder warns when neither exists.
  • Secret key. The form's own encrypted secret, else the configured one. With neither, the check fails closed.
  • Browser. The provider script loads lazily. The widget resets after every failed submit. In the admin preview the field is a stub that supplies a dummy token.
  • Server. The server verifies the token, with the visitor's IP, before anything is stored or delivered.
  • Form access tokens. A submission with a valid form access token (access.auth: 'token') skips the captcha.
The captcha token stays in the form state. It is never stored, delivered or shown in the preview JSON.
OutcomeStatus
Token missing or invalid400, with an issue on the captcha field key in data.issues
No secret key for the provider500
Provider unreachable502

To replace the network call, in your app or in tests, answer the forms:captcha:verify hook.

Visual builder

The builder at /admin/forms/:id edits the same definition without code. Its Builder, Delivery & storage, Access and JSON tabs all change one unsaved copy. Save form works only while the definition is valid.

  • Fields. Drag a field type from the palette, or click it. Reorder fields and steps by dragging or with the arrows. Switching to Single page merges all steps.
  • Languages. Every text has a language button. Translating gives one row per locale of your app, plus any code you add. Empty languages are dropped.

Next steps

  • Build a form – write a definition in code and seed it.
  • Server API – the access modes, form access tokens and passwords behind access.
  • Reference – the forms:submitted hook that receives every answer.
Copyright © 2026