Modules

Forms

Dynamic forms stored in the database - single page or stepped, conditional fields, file uploads, stored or email-only submissions.

Forms as one declarative definition stored in the database, one renderer and one server pipeline: single page or stepped, conditional fields, file uploads, captcha, access control, and stored or email-only submissions.

Requires@frigear-nu/module-kit, @frigear-nu/auth, @frigear-nu/users, @frigear-nu/mcp, @frigear-nu/notify (email delivery; the host picks the driver), @frigear-nu/ui and @nuxthub/core with KV (rate limiting) and blob (uploads) enabled. Optional@frigear-nu/menu (menu entries) and @frigear-nu/admin (the admin pages live in its shell, so they only exist when it is installed). The pages follow the UX patterns and are built from the shared components of @frigear-nu/ui.

Design

  • A form is data. The definition is JSON in the forms table, so editors can change fields without a deploy.
  • One compiler. buildFormSchema(definition, answers) turns a definition into a zod schema. The client uses it per step, the server for the whole form, so both sides agree on what is required.
  • Submissions are snapshots. Each submission stores the definition (and its version) it was answered against, so old submissions keep rendering after the form changes.
  • Storing is optional. storage: 'none' never persists answers: they are only emitted through forms:submitted (and emailed).
  • Delivery never loses a stored submission. The row is written first; delivery errors are recorded on it and can be retried from the admin.
  • Staff access is the host's decision. There is no shared permission system; see Authorization.
  • Forms are never deleted. DELETE archives, so submissions always keep their form.

Quick setup

export default defineNuxtConfig({
  forms: {
    settings: {
      pages: { form: true, admin: true, adminForm: true, adminSubmissions: true, adminSubmission: true },
    },
  },
})

Pages are opt-in and use @nuxt/ui. <FormRenderer slug="..." /> can also be placed in any page.

// server/plugins/forms-policy.ts - who may manage forms (denied by default)
export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('forms:authorize', async (ctx) => {
    ctx.allowed = await isStaff(ctx.userId)
  })
})
Every host app and test fixture must generate and commit a migration for the module's tables. See Database and Module Kit.

Options

All options live under forms.

OptionTypeDefaultPurpose
settings.pages.formstring | booleanoffPublic form page; must contain :slug. true = /forms/:slug.
settings.pages.adminstring | booleanoffAdmin list. true = /admin/forms. Admin pages need @frigear-nu/admin.
settings.pages.adminFormstring | booleanoffAdmin editor; must contain :id. true = /admin/forms/:id.
settings.pages.adminSubmissionsstring | booleanoffSubmissions of one form; must contain :id. true = /admin/forms/:id/submissions.
settings.pages.adminSubmissionstring | booleanoffOne submission; must contain :id and :submissionId. true = /admin/forms/:id/submissions/:submissionId.
settings.rateLimitnumber10Submissions per client and form per window; 0 disables rate limiting (including password-attempt limiting).
settings.rateLimitWindowSecondsnumber600Length of the window.
settings.mcpbooleanfalseExpose this module's tools on the MCP endpoint.
seed.enabledbooleanfalseInsert seed.forms at startup (env NUXT_FORMS_SEED_ENABLED). See Seeding and import.
seed.formsSeedForm[][]{ slug, name, status?, definition }; status defaults to draft.
captcha.<provider>.siteKeystring''Public site key; <provider> is turnstile, recaptcha or hcaptcha.
captcha.<provider>.secretKeystring''Secret key, server only.
captcha.defaultProviderCaptchaProvider'turnstile'Provider of a captcha field that names none.
encryptionKeystring''Key for per-form secrets. Falls back to NUXT_ENCRYPTION_KEY, then the session password.

Environment variables:

VariableOption
NUXT_FORMS_SEED_ENABLEDseed.enabled
NUXT_PUBLIC_FORMS_CAPTCHA_TURNSTILE_SITE_KEY, ..._RECAPTCHA_SITE_KEY, ..._HCAPTCHA_SITE_KEYcaptcha.<provider>.siteKey
NUXT_FORMS_CAPTCHA_TURNSTILE_SECRET_KEY, ..._RECAPTCHA_SECRET_KEY, ..._HCAPTCHA_SECRET_KEYcaptcha.<provider>.secretKey
NUXT_PUBLIC_FORMS_CAPTCHA_DEFAULT_PROVIDERcaptcha.defaultProvider
NUXT_FORMS_ENCRYPTION_KEYencryptionKey

Definition

Author definitions with defineForm() from @frigear-nu/forms/utils. These are the real 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 (falls back to en, then the first value).

Rules enforced when a definition is saved or seeded:

  • Conditions. showIf only sees answers of fields earlier in the form and must reference an input field. value is the compared value for eq/neq, a list for in, and unused for empty, notEmpty and truthy (an alias of notEmpty). A hidden field's stale answer never keeps another field shown; hidden fields are not required and their answers are dropped.
  • 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, maxFiles to 10 (1 unless multiple).
  • Access. auth: 'token' requires availability: 'api', because a bearer token cannot be typed 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' } }
  • The answer is one string. With a selector it is +<dial code><number> (+4512345678; a leading trunk 0 is dropped, Italian numbers keep it), so submissions, emails and the API never see two values.
  • Country mode requires an international number of at most 15 digits (E.164) with at least 4 digits after the code.
  • Helpers composePhone, parsePhone, phoneCountries and allPhoneCountries are exported from @frigear-nu/forms/utils.

Open period (seasonal forms)

access.schedule limits when a form accepts answers. A yearly season whose end is before its start wraps over new year. from is inclusive, to exclusive.

// 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 when there is one) and the definition and submit endpoints answer 403 with data: { closed: true, opensAt }. isFormOpen and nextFormOpening are exported from the utils for custom UI.

Email body and variables

Without delivery.email.body the notification lists every visible answer. The body is chosen by the submission's locale (falling back to en); subject accepts 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}}ISO timestamp and locale of the submission
{{answers}}All visible answers as label: value lines (a table in the HTML version), without captcha
{{answer.<fieldKey>}}One answer, formatted like the default email: option labels, joined lists, file names, yes/no per locale
{{files}}Names of the files stored with the submission
{{replyTo}}The reply-to address taken from replyToField
  • Whitespace inside the braces is ignored. Unknown variables, hidden fields and unanswered fields render empty; the admin definition check warns about unknown variables (unknownBodyVariables(definition)) but never fails the save.
  • Values are inserted once and never re-scanned, so an answer containing {{form.name}} stays literal.
  • The body is plain text. The HTML version escapes every value and turns newlines into <br>. In the subject, line breaks are collapsed so an answer cannot inject headers.
  • Rendering lives in @frigear-nu/forms/utils (composeEmail, renderTemplateText, renderTemplateHtml), so the builder previews the exact result with sample answers.

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' } }
  • Keys. Site key: the field's siteKey, else the configured one. Secret key: the form's own encrypted secret (see Per-form secrets), else the configured one, else the check fails closed. The admin builder warns when a provider has neither a field siteKey nor a configured site key.
  • Client. The provider script loads lazily in the browser only. The token lives in the form state under the field key, the widget resets after every failed submit, and the token is never stored, delivered or shown in the preview JSON. In the admin preview and "Try it" modal the field is a stub that supplies a dummy token.
  • Server. The token is verified (with the visitor's IP) against the provider's siteverify endpoint before anything is stored or delivered.
  • Token access. Submissions with a valid form API token (access.auth: 'token') skip the captcha.
OutcomeStatus
Token missing or invalid422 with an issue on the captcha field key
Provider has no secret key (fails closed)500
Provider unreachable502

Hosts and tests can replace the network call with the forms:captcha:verify hook.

Visual builder

The admin editor (/admin/forms/:id) has Builder, Delivery & storage, Access and JSON tabs, all editing the same unsaved definition (so they are state tabs, not routes); Save form is only enabled while it passes validation. The heading carries the breadcrumb back to the list, the form's status badge and a ⋯ menu with Submissions, Copy link, Duplicate form and Archive form.

  • Pick Single page or Stepped (switching to single merges all steps), drag field types from the palette (or click to append), and drag fields or step headers to re-order. Every row has up/down buttons for pointer-free use; drag and drop uses the browser's native API.
  • Selecting a field or step opens its settings: label, help text, placeholder, required, options, limits, its step and a showIf condition on an earlier field.
  • Every text has a language button: plain text is the same in every language, and translating turns it into a map with one row per locale (the host's @nuxtjs/i18n locales, plus any code you add). Empty languages are dropped.

Server

Submitting

POST /api/forms/:slug/submit takes JSON { answers, locale }, or multipart/form-data with an answers JSON part, a locale part and one file:<key> part per file. The response is { ok: true, id } (id is null when storage: 'none'). GET /api/forms/:slug returns the published definition without delivery.

StatusCause
401access.auth is not satisfied (sign-in, token or password missing or wrong; data.access: true)
403The form is closed (data: { closed: true, opensAt })
404Unknown or unpublished form
422Validation errors in data.issues, or a failed captcha
429Rate limit hit (Retry-After header)
500 / 502Captcha not configured / unreachable; 502 is also returned when storage: 'none' delivery fails

Access

A form's access decides who may read and submit it. Secrets are never part of the definition.

authSubmitter proves access with
noneNothing
userA signed-in user (cookie session or a token with the forms:submit scope)
tokenAuthorization: Bearer <token> or x-form-token: <token>; tokens are created per form in the admin and shown once
passwordThe shared password in the x-form-password header (FORM_PASSWORD_HEADER on the client)
  • Tokens (ffk_...) are stored as SHA-256 hashes with a display prefix, lastUsedAt and revokedAt.
  • The password is stored as a scrypt hash in form_access. With no password set the form stays closed.
  • Wrong password attempts count against the rate limit per client and form.
  • availability: 'api' only removes the public page; the API still serves the form.

Rate limiting

Submissions are counted per client IP and form in KV with a fixed window (rateLimit per rateLimitWindowSeconds). The count is read then written, so it is not exact under concurrency, which is fine for casual abuse. Over the limit the response is 429 with Retry-After. Wrong password attempts use the same limit and window.

Per-form secrets

Secrets that belong to one form (today the captcha secret keys) live in the form_secrets table, encrypted on the server with AES-256-GCM (a random 12-byte IV per write, scrypt-derived key, stored as { ct, iv, tag }). They are kept apart from the definition, so they never end up in definition versions, submission snapshots, deliveries, imports or the public API. Values are write-only.

  • Key: encryptionKey, else NUXT_ENCRYPTION_KEY, else the session password, so it works out of the box. Set a dedicated key in production so that changing the session password does not affect it.
  • Rotation: changing the key makes stored secrets unreadable. The admin API then reports unreadable: true (the builder shows a warning) and checks fall back to the configured key; entering the secrets again replaces them.
  • Allowed keys: captcha.turnstile.secretKey, captcha.recaptcha.secretKey, captcha.hcaptcha.secretKey.

Seeding and import

Nothing is inserted automatically. Two explicit ways to load definitions; both only insert slugs that do not exist yet and never overwrite existing forms:

  • seed: { enabled: true, forms: [...] } inserts at startup.
  • The admin Import button (POST /api/forms/admin/import) takes { forms: [{ slug, name, definition }] } and returns { inserted, skipped }.

Admin list and actions

The list (/admin/forms) is a PageHeading with New form (a slideover) and Import JSON, a search box and status filter, and a table whose rows open the editor. Each row's ⋯ menu offers Open, Submissions, Copy link and Duplicate form, and Archive form (after a confirmation) in its own group. The same menu is built by useFormActions().menu(form, { extra?, onRemoved? }), which also exposes create, clone, remove (archives), importJson and redeliver, so a host page can offer the same actions. A submission's menu offers Open and Deliver again. The old dashboard.account tile is gone: forms appears in the admin sidebar (admin.nav) only.

Cloning

POST /api/forms/admin/forms/:id/clone copies a form's name and definition into a new draft (slug <slug>-copy, then -copy-2, ...). Submissions, tokens, the password and secrets are not copied.

Authorization

Admin routes require a user holding the forms:manage scope and the forms:authorize hook allowing them, denied by default. Set ctx.allowed from ctx.userId; never read the session from ctx.event, which is empty for bearer requests. The forms:manage and forms:pii scopes are only offered to users the policy lets in.

Auth scopes

ScopeAllows
forms:manageManage forms and submissions (staff), also gated by forms:authorize
forms:submitSubmit forms as the user (and read forms that need a signed-in user)
forms:piiSee submission answers and file names through MCP tools (also needs forms:manage)

MCP tools

With settings.mcp: true:

ToolScopePurpose
list-forms, get-form, submit-formforms:submitBrowse published forms and submit as the user
list-all-forms, get-form-definition, create-form, update-formforms:manageManage forms; delivery settings are never returned or changed
list-submissions, get-submissionforms:manageSubmissions; answers, submitter and file names need forms:pii too

App

Pages

PageDefault pathOption
Public form/forms/:slugpages.form
Admin list/admin/formspages.admin
Admin editor/admin/forms/:idpages.adminForm
Submissions/admin/forms/:id/submissionspages.adminSubmissions
One submission/admin/forms/:id/submissions/:submissionIdpages.adminSubmission

Any can be a custom path string. The admin paths follow the configured Admin base.

Components

ComponentPropsNotes
<FormRenderer>slug?, form?, password?, preview?, locale?Renders a published form (by slug) or a given definition. Emits submitted(id) and previewed(answers).
<FormEmbed>slug, title?, locale?, class?Embeds a published form, honoring its access settings. Registered globally.
<FormPreview>slug, name?, definition?, contentKey?Admin preview.
<FormSubmissionView>submissionRenders a submission against the definition it was answered with.

Embedding in Markdown

FormEmbed is registered globally, so Nuxt Content (MDC) resolves it in any collection without a wrapper. Use the block syntax: the embed renders block elements, so inline :form-embed{...} would place them inside a paragraph.

::form-embed{slug="contact"}
::

::form-embed{slug="contact" title locale="da" class="max-w-2xl my-8"}
::
PropTypeDefaultPurpose
slugstringnoneRequired. Slug of a published form.
titlebooleanfalseShow the form's name as a heading.
localestringapp localeOverrides the locale of the form's translated texts.
classstringnoneExtra classes for the wrapper (it has not-prose).

The embed uses GET /api/forms/:slug/access and follows the access settings:

Form stateRenders
OpenThe form, with a loading skeleton; success message or redirect handled by the renderer
Unknown or unpublishedA "could not be found" alert
auth: 'user'A sign-in prompt linking to the login page (with a redirect back)
auth: 'password'The password gate
availability: 'api' or auth: 'token'Nothing (a console warning in development names the slug)

Composables

Auto-imported, also available from @frigear-nu/forms/composables.

useForm(slug: MaybeRefOrGetter<string>, password?: MaybeRefOrGetter<string | undefined>) // published definition
useFormAccess(slug: MaybeRefOrGetter<string>) // FormAccessInfoDto: access, signedIn, open, opensAt
useFormsLocale() / useFormsLocales() // current locale / available locales
useFormsPaths() // (name, params?) => resolved page path
useFormLink() // { enabled, url(slug), copy(slug) }
useFormStepper(...) // step navigation of the renderer
FORM_PASSWORD_HEADER // 'x-form-password'

Utils

@frigear-nu/forms/utils is pure and safe in the Vue app and in Nitro.

ExportPurpose
defineForm, parseFormDefinition, formDefinitionSchemaAuthor and validate definitions
buildFormSchema, buildFieldSchema, validatedFieldsCompile a definition to zod
evaluateCondition, resolveVisibility, isFieldVisibleConditional visibility
resolveAccess, defaultAccess, accessSchemaEffective access of a definition
isFormOpen, nextFormOpeningOpen period
composeEmail, renderTemplateText, renderTemplateHtml, unknownBodyVariables, availableVariablesEmail rendering
composePhone, parsePhone, phoneCountries, allPhoneCountriesPhone numbers
resolveEmailDelivery, resolveCaptcha, renderMarkdownDelivery recipients, captcha provider, static markdown

The builder helpers (moveField, insertField, setAccess, ...) are exported from the same path.

Hooks

Nitro runtime hooks, registered from a server plugin with nitro.hooks.hook(name, handler).

HookWhenPayload
forms:authorizeA staff route is called{ event, userId: number, allowed: boolean }; set allowed = true to let the user in
forms:submittedA valid submission was received (and stored unless storage: 'none')SubmittedContext (below)
forms:captcha:verifyA captcha token must be verified{ event, provider, token, ip, formId?, secretKey?, secretSource?: 'form' | 'env' | 'unset', valid: boolean | undefined }; set valid to decide, left undefined the provider is asked
forms:deliveredDelivery finished (only fires when a handler delivered or one failed){ submissionId: string | null, formSlug, status: 'delivered' | 'failed', error? }
type SubmittedContext = {
  event: H3Event
  form: { id: string, slug: string, name: string, version: number, definition: FormDefinition }
  answers: Record<string, unknown> // validated; file fields hold the file names
  files: { fieldKey: string, filename: string, contentType: string, size: number, pathname: string | null }[]
  locale: string
  userId: number | null
  submissionId: string | null // null when storage is 'none'
  stored: boolean
  delivered: boolean // set true once the handler delivered; throw to mark delivery failed
}
nitroApp.hooks.hook('forms:submitted', async (ctx) => {
  await fetch('https://example.com/hook', { method: 'POST', body: JSON.stringify(ctx.answers) })
  ctx.delivered = true
})
  • The built-in email delivery listens to forms:submitted and mails delivery.email through @frigear-nu/notify.
  • For storage: 'db' a failing listener sets deliveryStatus = 'failed' (with the error) and the visitor still gets success; Deliver again in the admin re-runs the hooks. For storage: 'none' nothing else keeps the answers, so a failure is returned as 502.

Routes

/api/forms/.... Static admin/* segments win over :slug. "Staff" means forms:manage plus the forms:authorize policy; admin routes also accept bearer tokens with that scope.

MethodPathAuthPurpose
GET/:slugForm accessPublished definition without delivery
GET/:slug/accessNoneWhat the page needs to choose between the form, a gate and "not found"
POST/:slug/submitForm accessSubmit answers (rate limited)
GET/admin/formsStaffList forms with submission counts
POST/admin/formsStaffCreate a form (201; 409 for a taken slug)
GET/admin/forms/:idStaffOne form
PATCH/admin/forms/:idStaffUpdate; a changed definition bumps version
DELETE/admin/forms/:idStaffArchive (never deletes)
POST/admin/forms/:id/cloneStaffClone into a draft (201)
GET/admin/forms/:id/submissionsStaffSubmissions of a form
GET / POST/admin/forms/:id/tokensStaffList tokens / create one (201; the token is only in this response)
DELETE/admin/forms/:id/tokens/:tokenIdStaffRevoke a token
GET / PUT / DELETE/admin/forms/:id/passwordStaffWhether a password is set ({ set }) / set (6-200 chars) / remove
GET / PUT/admin/forms/:id/secretsStaffNames and sources of secrets ({ keys, captcha, unreadable }) / write one { key, value }
DELETE/admin/forms/:id/secrets/:keyStaffRemove one secret (400 for an unknown key)
POST/admin/importStaffImport definitions
GET/admin/submissions/:idStaffOne submission
GET/admin/submissions/:id/files/:fileIdStaffDownload an uploaded file
POST/admin/submissions/:id/redeliverStaffRun the delivery hooks again

Database

TablePurpose
formsslug, name, status (draft, published, archived), definition, version
form_submissionsAnswers, definitionSnapshot, formVersion, userId, locale, deliveryStatus (none, pending, delivered, failed) and deliveryError
form_submission_filesBlob pathname, filename, content type and size per uploaded file
form_access_tokensPer-form API tokens: tokenHash, prefix, lastUsedAt, revokedAt
form_accessOne row per password-protected form: passwordHash
form_secretsOne row per form: secrets (encrypted JSON map of namespaced keys)

Files are only downloadable by staff. Generate and commit migrations for all six tables in every host app and fixture (see Module Kit).

Translations

  • Module chrome (buttons, stepper, admin) uses useModuleT('forms') with English fallbacks; the module contributes forms.* messages (da/en) through registerLocales (see Module Kit). Override any of them in your app's i18n/locales/<code>.json (see overriding module strings), for example { "forms": { "actions": { "submit": "Send inquiry" } } }.
  • Key groups: actions, step, success, error, embed, preview, field, value, status, delivery, validation, admin, schedule, access, menu, captcha, builder.
  • Form content lives in the definition as { da, en } texts.
  • Validation messages of the compiler's own rules use forms.validation.*; a field overrides them with validation.message.
Copyright © 2026