Tutorial 5: Build a form
Objective: a contact form that stores enquiries in the database, validated on the server, with errors shown under each field.
Prerequisites: Tutorial 4.
In IWC a form is three pieces: a schema (the table), an API (the request contract and what it does) and a form element (the inputs). The finished project:
contact-form/
├── api/
│ └── contact.api.iwc
├── database/
│ └── enquiry.schema.iwc
├── styles/
│ └── main.css
├── tests/
│ └── contact.test.iwc
├── ui/
│ └── contact.ui.iwc
├── app.iwc
└── package.iwcStep 1: The table
Create database/enquiry.schema.iwc:
{
"name": "enquiry",
"fields": {
"name": { "type": "string", "required": true, "maxLength": 80 },
"email": { "type": "email", "required": true },
"topic": { "type": "enum", "values": ["order", "returns", "wholesale", "other"], "required": true },
"message": { "type": "text", "required": true, "maxLength": 2000 },
"newsletter": { "type": "boolean", "default": false }
},
"access": { "read": "none", "create": "public", "update": "none", "delete": "none" }
}| Part | Meaning |
|---|---|
fields | Columns with their types and rules. email is validated and lower-cased; enum limits values; text is long text |
access | Who may do what through generic APIs and data sources. Anyone may create (submit the form); nobody may read, update or delete enquiries through the API |
IWC creates the table in .iwc/dev.db (SQLite) on the next compile; no migration command is needed in development.
Step 2: The API
Create api/contact.api.iwc:
{
"name": "contact",
"method": "POST",
"path": "/contact",
"description": "Stores an enquiry from the contact form.",
"operation": "db.create",
"resource": "enquiry.schema.iwc",
"request": {
"payload": {
"name": { "type": "string", "required": true, "minLength": 2, "maxLength": 80 },
"email": { "type": "email", "required": true },
"topic": { "type": "string", "required": true, "enum": ["order", "returns", "wholesale", "other"] },
"message": { "type": "string", "required": true, "minLength": 10, "maxLength": 2000 },
"newsletter": { "type": "boolean" }
}
},
"response": { "status": 201, "fields": { "id": "string" } },
"security": { "rateLimit": "5/1m" }
}operation: "db.create"inserts the validated payload intoresource.request.payloadis the contract: every field the API accepts, with its rules. Fields not listed are refused, so nobody can set a column the form doesn't show.response.fieldslists the only fields returned.security.rateLimitallows 5 submissions per minute per client.
The API is mounted under /api: POST /api/contact.
Step 3: The page and the form
{
"name": "Contact",
"route": "/",
"title": "Contact us",
"sections": [
{
"type": "section",
"className": "contact",
"layout": { "direction": "column", "gap": "16px" },
"elements": [
{ "type": "text", "variant": "h1", "content": "Contact us" },
{ "type": "text", "content": "We reply within one working day." },
{
"type": "form",
"inputs": [
{ "name": "name", "type": "text", "label": "Your name", "required": true, "autocomplete": "name" },
{ "name": "email", "type": "email", "label": "Email", "required": true, "autocomplete": "email" },
{
"name": "topic",
"type": "select",
"label": "Topic",
"required": true,
"options": [
{ "label": "An order", "value": "order" },
{ "label": "Returns", "value": "returns" },
{ "label": "Wholesale", "value": "wholesale" },
{ "label": "Something else", "value": "other" }
]
},
{ "name": "message", "type": "textarea", "label": "Message", "required": true, "hint": "At least 10 characters." },
{ "name": "newsletter", "type": "checkbox", "label": "Send me the monthly newsletter" }
],
"submit": { "api": "contact.api.iwc", "label": "Send message", "success": { "message": "Thanks — we'll be in touch soon.", "reset": true } }
}
]
}
]
}| Input property | Meaning |
|---|---|
name | The payload key — it must match the API's field |
type | text email select textarea checkbox … (all types) |
required | Marks the field in the browser. Enforcement is the API's required |
options | For select and radio: strings, or {label, value} |
hint | Help text under the field, read by screen readers |
autocomplete | Browser autofill hints |
submit.api posts the form as JSON to the API; success shows a message and clears the inputs on a 2xx answer. On a 422 each error appears under its field.
Step 4: Style it
.contact { max-width: 560px; margin: 0 auto; padding: 64px 16px; }
.iwc-form { display: flex; flex-direction: column; gap: 14px; }
.iwc-field { display: flex; flex-direction: column; gap: 6px; }
.iwc-field input, .iwc-field select, .iwc-field textarea { padding: 10px 12px; border: 1px solid var(--color-border); border-radius: var(--radius); font: inherit; }
.iwc-field-error { color: var(--color-danger); font-size: 14px; }.iwc-field-error and --color-danger style the error messages.
Step 5: Try the failure path
With iwc dev running, submit the form with A as the name and hi as the message. The terminal logs the reason:
11:28:14 api POST /api/contact 422 0ms
↳ validation_failed: Some fields are invalid (message must be at least 10 characters; name must be at least 2 characters)The response is always the same shape, which the form maps onto its fields:
{"error":{"code":"validation_failed","fields":{"message":"must be at least 10 characters","name":"must be at least 2 characters"},"message":"Some fields are invalid"}}To call the API from a terminal or another server (no browser, no session), mark the request as a non-browser client. Browsers send CSRF tokens; a signed-out script sends X-IWC-Client: native instead:
curl -s -X POST localhost:3000/api/contact \
-H 'content-type: application/json' -H 'X-IWC-Client: native' \
-d '{"name":"Asha","email":"asha@example.com","topic":"order","message":"Where is my order?"}'
# {"id":"cbea4553-8687-4bb4-98f4-1c976e0884aa"}Step 6: Test it
{
"name": "contact",
"cases": [
{
"name": "the page shows the form",
"request": { "path": "/" },
"expect": { "status": 200, "contains": ["Contact us", "name=\"email\"", "Send message", "At least 10 characters."] }
},
{
"name": "a valid enquiry is stored",
"request": { "method": "POST", "path": "/api/contact", "body": { "name": "Asha", "email": "Asha@Example.com", "topic": "order", "message": "Where is order #1042?", "newsletter": true } },
"expect": { "status": 201 }
},
{
"name": "invalid fields come back with a message each",
"request": { "method": "POST", "path": "/api/contact", "body": { "name": "A", "email": "not-an-email", "topic": "spam", "message": "short" } },
"expect": { "status": 422, "contains": ["validation_failed", "email", "topic", "message"] }
},
{
"name": "fields the API did not declare are refused",
"request": { "method": "POST", "path": "/api/contact", "body": { "name": "Asha", "email": "a@example.com", "topic": "other", "message": "Hello there, team!", "status": "vip" } },
"expect": { "status": 422, "contains": ["status"] }
},
{
"name": "nobody can read the enquiries over the API",
"request": { "path": "/api/contact" },
"expect": { "status": 405 }
}
]
}iwc test ✓ contact › the page shows the form
✓ contact › a valid enquiry is stored
✓ contact › invalid fields come back with a message each
✓ contact › fields the API did not declare are refused
✓ contact › nobody can read the enquiries over the API
5 passed, 0 failedTroubleshooting
| Message | Fix |
|---|---|
form elements need "submit": {"api": ...} | Every form posts to an API. |
forms submit to POST/PUT/PATCH/DELETE apis | The API is GET; forms write. |
… is not an accepted field (422) | The input's name isn't declared in request.payload. |
403 csrf | The page was open across a server restart; reload it. IWC forms send the CSRF token for you. |
Next: Connect an API.
Source: apps/marketing/content/docs/tutorials/build-a-form.md