Skill 16 · Wix Headless Templates
Subchapter 16.428
forms/seed/SEED.mdMarkdown13 KBView on GitHub
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 , a
layout referencing every field, and rules keyed by field id. Those are the rules a hand-built
payload gets wrong while still returning .
target200Seed 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.
node <SKILL_ROOT>/templates/forms/seed/seed-forms.mjs plan.json # from the project root{
"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 }
]
}
]
}| 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 |
| 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) |
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.
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.
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.
{ "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.
wix.form_app.form namespace — a name match is skipped.componentType, the step count and the rule count.Listed because a hand-rolled payload hits all of them, and each returns 200 first:
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.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.options[].default on a choice, checked on a
checkbox, defaultValue on a rating, default elsewhere. The wrong key is silently ignored.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.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.formFields, never fields — the latter is the legacy API.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.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.