← Files WixARCHIVED FILE
skills/wix-headless-templates/forms/seed/SEED.md
12.6 KB · Oct 8, 2026 · 12:02 UTC
# Forms — seed contract
`seed-forms.mjs` takes a plain-data plan and creates the site's forms. You write labels and
kinds; the seed derives everything the API demands — per-field UUIDs, the two-level options
nesting, the validation block that must exist even when empty, the choice enum that has to
agree with the component's options, the per-component prefill key, the immutable `target`, a
layout referencing every field, and rules keyed by field id. Those are the rules a hand-built
payload gets wrong while still returning `200`.
**Seed BEFORE building the form UI.** This is the one vertical that does not run in parallel
with the client: the page renders the fields this creates, and imports the id it returns.
**Additive only** — an existing form with the same name is left exactly as it is and reported
with `created: false`. Re-running never makes a second copy and never edits one.
## Run
```bash
node <SKILL_ROOT>/templates/forms/seed/seed-forms.mjs plan.json # from the project root
```
## Plan
```json
{
"forms": [
{
"name": "Contact",
"submitText": "Send enquiry",
"thankYou": "Thanks, we will reply within two working days.",
"fields": [
{ "label": "Your name", "kind": "text", "required": true },
{ "label": "Email", "kind": "email", "required": true },
{ "label": "Phone", "kind": "phone", "country": "GB" },
{ "label": "Project type", "kind": "select", "choices": ["Brand identity", "Website redesign"], "other": true },
{ "label": "Tell us more", "kind": "textarea", "hidden": true },
{ "label": "Services", "kind": "multi", "choices": ["SEO", "Copywriting"] },
{ "label": "Budget", "kind": "number", "min": 500, "max": 50000, "step": 100 },
{ "label": "Start date", "kind": "date", "min": "$now" },
{ "label": "I accept the terms", "kind": "checkbox", "required": true }
],
"rules": [
{ "when": { "field": "Project type", "isNot": "Brand identity" }, "show": ["Tell us more"] }
]
},
{
"name": "Application",
"submitText": "Apply",
"nextText": "Continue",
"previousText": "Back",
"steps": [
{ "name": "About you", "fields": ["First name", "Last name", "Email"] },
{ "name": "Your work", "fields": ["Portfolio", "CV"] }
],
"deadline": "2026-12-31T23:59:59Z",
"redirect": "https://example.com/thanks",
"fields": [
{ "label": "First name", "kind": "firstName", "required": true },
{ "label": "Last name", "kind": "lastName", "required": true },
{ "label": "Email", "kind": "email", "required": true },
{ "label": "Portfolio", "kind": "url" },
{ "label": "CV", "kind": "file", "required": true, "formats": ["DOCUMENT"], "fileLimit": 2 }
]
}
]
}
```
### Form keys
| key | meaning |
|---|---|
| `name` | the form's name in the owner's dashboard, and the idempotency key |
| `submitText` | the submit button's wording (default `"Submit"`) |
| `nextText` · `previousText` | multi-step: the next / back button wording |
| `steps` | `[{ name, fields: [labels] }]` — one page per entry, in order; a field named on no step lands on the last page. Omit for a single page |
| `rules` | `[{ when, show?, hide?, require? }]` — see Rules |
| `thankYou` · `thankYouSeconds` | the owner's thank-you text shown after a submit; optional auto-hide seconds |
| `redirect` | send the visitor to this URL after a submit instead (same tab) |
| `deadline` | ISO date-time after which the form stops accepting submissions |
| `maxSubmissions` · `perVisitor` | close the form after this many submissions in total / per visitor |
| `requiredIndicator` | how required fields are marked: `ASTERISK` (default) · `TEXT` · `NONE` |
### Field keys
| key | meaning |
|---|---|
| `label` | what the visitor reads — also the basis of the storage key |
| `kind` | one of the kinds below |
| `required` | default `false`; on a `checkbox` it means "must be ticked" |
| `placeholder` · `description` | optional; passed to the control (description renders as help text). A placeholder is at most 100 characters |
| `default` | the prefill: text, a number, `true` for a checkbox, a choice value (or an array of them for `multi` / `tags`), a date (`"2026-01-31"` or `"$now+2d"`) |
| `hidden` | start hidden — for a field a rule shows |
| `choices` | `select` · `radio` · `multi` · `tags` — strings, or `{ value, label }` |
| `other` · `otherPlaceholder` | a free-text "Other" entry on a choice field: `true` (labelled "Other") or the label |
| `min` · `max` | `number` · `rating` bounds; on `date` · `time` · `datetime` an ISO value or `$now`, `$now+2d`, `$now-1M` (units `y M d h m`) |
| `step` | `number`: the allowed increment (`0.01` for money, `1` for whole numbers) |
| `minLength` · `maxLength` | text length bounds |
| `pattern` · `patternMessage` | a regex the text must match, and the owner's wording when it does not |
| `minItems` · `maxItems` | `multi` · `tags` bounds |
| `fileLimit` · `formats` | `file` — how many files (default 1, max 30); allowed families out of `IMAGE` `DOCUMENT` `VIDEO` `AUDIO` `ARCHIVE` |
| `buttonText` | `file` — the upload button's wording |
| `country` · `countries` | `phone`: default country (ISO-2) and the allowed countries; `address`: `countries` restricts the country list |
| `parts` | `address`: per-subfield required flags, e.g. `{ "postalCode": false, "addressLine2": false }` (`addressLine2: false` also hides it) |
### Kinds
`text` · `textarea` · `email` · `phone` · `url` · `firstName` · `lastName` · `company` ·
`date` · `time` · `datetime` · `number` · `rating` · `select` · `radio` · `multi` · `tags` ·
`checkbox` · `file` · `address`
`email`, `phone`, `firstName`, `lastName`, `company` are the CONTACTS kinds — Wix maps them
onto the owner's CRM contact, so prefer them over a plain `text` field for those.
A required `address` requires country, address line, city and postal code; `parts` overrides
that per subfield.
### Limits
**The site's plan caps the forms.** A free site allows 4 forms, 10 fields per form, 3 steps and 3
rules; a paid plan lifts them. The seed reads the live limits (Get Restrictions) and checks the
whole plan against them before creating anything, naming each form and the cap it breaks; nothing
is created. Trim the form, or the owner upgrades the plan
(`https://www.wix.com/upgrade/website?metaSiteId=<siteId>`) and the same plan runs unchanged. Do
not split one form in two to fit: it spends a form slot and splits a visitor's answers across two
records. The example above sits at nine fields on purpose.
**`file` needs a paid site plan.** Verified live: on a free site the create fails as a whole with
`FILE_UPLOAD_RESTRICTIONS_ERROR` (the seed reports it by field label; nothing is created). No API
call lifts it — the owner upgrades the site in the dashboard, then the same plan runs unchanged.
When the brief needs the upload now and the site is free, use a `url` kind for that field and add a
`capabilities.mediaUpload` policy to the plan with `"audience": "visitors"` (Astro stack only; a
policy admits logged-in members only unless it says so): the site uploads the file itself
through the shared endpoint and submits the uploaded file's URL as the field's value. Say which way
you went in the closing message; a "link to your CV" text field is not a substitute for an upload.
### Rules
A rule shows, hides or requires fields while a condition on another field holds — the
dashboard's Rules tab, written as data. `when` names the deciding field by label and ONE test:
| test | holds when |
|---|---|
| `"is": value` · `"isNot": value` | the field equals / does not equal the value (a choice value, text, a number) |
| `"in": [values]` | the field's value is one of the list |
| `"includes": value` | a `multi` / `tags` selection contains the value (or text contains it) |
| `"checked": true` | a `checkbox` is ticked |
| `"isEmpty": true` · `"isNotEmpty": true` | the field is empty / filled |
Effects: `show: [labels]` (those fields are created hidden and appear while the condition
holds), `hide: [labels]`, `require: [labels]`. A field a rule hides is cleared and not submitted.
## Result
```json
{ "forms": [ { "name": "Contact",
"formId": "0e0…",
"created": true,
"fields": [ { "target": "your_name_k3f9x2", "label": "Your name" }, … ],
"fieldsLive": 9,
"steps": 1,
"rules": 1,
"degraded": [] } ] }
```
**`formId` is what the page imports.** `fields[].target` is each input's `name` — the page
does not need them (it renders `form.fields` from the live schema), but they are the keys the
owner's submissions are stored under.
**`degraded` must be empty.** It is the read-back check: the seed re-reads the created form and
compares each field's `componentType` against what it sent, and the step and rule counts. A
non-empty list means something was accepted and then stored as something else — almost always a
choice field that lost its options and became a plain text box. Fix the plan, delete nothing, and
create the form again under a new name; a `200` on create does not mean the field survived.
## What the seed does
1. Installs the Wix Forms app (idempotent).
2. Lists existing forms in the `wix.form_app.form` namespace — a name match is skipped.
3. Expands each field, builds the steps and rules, creates the form, reads it back and checks
every `componentType`, the step count and the rule count.
## Traps this seed already handles
Listed because a hand-rolled payload hits all of them, and each returns `200` first:
- **A choice field declares its options twice** — the component's `options[]` and the
validation `enum` (or `items: { itemType, stringOptions.enum }` for a multi). Disagree and the
field is created as a plain text box. With an `other` entry no enum is written: the free text
is by definition outside the list. For a multi, `itemType` sits inside `items` beside the
options block; one level up the create is a `400` whose message blames the options block.
- **A rule's expression root is an `and` / `or` group**, even for one condition. A bare
condition at the root is a `400` (`UNGROUPED_RULE_EXPRESSION_ROOT`). Every condition carries a
`value`, `isEmpty` / `isNotEmpty` / `checked` included (`RULE_CONDITION_VALUE_MISSING` without).
- **`validation` must be present even when empty**, nested under the *input-type* block, not
the component one. Absent, the target is not registered as an accepted value and every
submission is rejected with `UNKNOWN_VALUE_ERROR` on a key that IS in the schema.
- **`required` lives at `inputOptions.required`**, never inside a validation block. A checkbox
that must be ticked additionally needs `validation.enum: [true]` — `required` alone only checks
that a value is present, and `false` is a value.
- **The prefill key differs per component**: `options[].default` on a choice, `checked` on a
checkbox, `defaultValue` on a rating, `default` elsewhere. The wrong key is silently ignored.
- **Date bounds live under the format's own block** (`dateOptions` / `timeOptions` /
`dateTimeOptions`), as strings, and accept `$now±N`.
- **`steps` must reference every field, the submit button included.** A field missing from the
layout never appears in the owner's dashboard — they cannot edit what the site renders.
- **Rules reference fields by id** (`fieldOptions.fieldId`, condition `target`), so a rule
written by hand against labels never fires.
- **`target` is immutable** and must be unique within the form: letters, digits and single
underscores, starting with a letter.
- **A checkbox label is rich content** (Ricos), not a string — the owner may put a link to the
terms in it. The app flattens it to text.
- **Use `formFields`, never `fields`** — the latter is the legacy API.
- **A `file` field fails the whole create on a free site** (`FILE_UPLOAD_RESTRICTIONS_ERROR`, a
plan restriction, not a payload bug); the seed names the field and the two ways out.
## Supplied content
The general rules are in `templates/shared/SUPPLIED-CONTENT.md`. For forms the user usually hands
over the form itself: a list of questions, or a PDF or screenshot of the form they use today. Each
question becomes a field, with the `kind` its answer implies (an email address → `email`, a fixed set
of answers → `select`, `radio` or `multi`, a long answer → `textarea`, an attachment → `file`); a
required mark becomes `required`; "if yes, …" becomes a rule; page breaks become `steps`; the
button label becomes `submitText`; the confirmation wording becomes `thankYou`. Question wording
verbatim.
SHA-256: ef6b781d8324442f2cef6e6fc3002d1fd8aec03ac21451e0ca4c23eecc8c34be