Tutorial 8: Create a CRUD application
Objective: a to-do app where each member creates, reads, updates and deletes their own tasks, with an edit form and live updates.
Prerequisites: Tutorial 7. This project reuses its user schema, sign-in APIs and screens.
tasks/
├── api/
│ ├── login.api.iwc
│ ├── me.api.iwc
│ ├── register.api.iwc
│ ├── task-create.api.iwc
│ ├── task-delete.api.iwc
│ ├── task-list.api.iwc
│ └── task-update.api.iwc
├── components/
│ ├── header.component.iwc
│ └── stat.component.iwc
├── database/
│ ├── task.schema.iwc
│ └── user.schema.iwc
├── layouts/
│ ├── admin.layout.iwc
│ └── main.layout.iwc
├── styles/
│ └── main.css
├── tests/
│ ├── admin.test.iwc
│ └── tasks.test.iwc
├── ui/
│ ├── admin.ui.iwc
│ ├── home.ui.iwc
│ ├── login.ui.iwc
│ ├── not-found.ui.iwc
│ ├── register.ui.iwc
│ ├── task-edit.ui.iwc
│ └── tasks.ui.iwc
├── app.iwc
└── package.iwcStep 1: The table, with ownership
{
"name": "task",
"fields": {
"owner": { "type": "relation", "to": "user.schema.iwc", "required": true, "onDelete": "cascade" },
"title": { "type": "string", "required": true, "minLength": 2, "maxLength": 120 },
"notes": { "type": "text", "maxLength": 2000 },
"priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal", "required": true },
"due": { "type": "date" },
"done": { "type": "boolean", "default": false, "required": true, "index": true }
},
"owner": "owner",
"access": { "read": ["admin", "owner"], "create": "auth", "update": "owner", "delete": "owner" },
"seed": [
{ "id": "t-1", "owner": "u-member", "title": "Book the venue", "priority": "high", "due": "2026-11-20", "done": false },
{ "id": "t-2", "owner": "u-member", "title": "Send invitations", "priority": "normal", "done": true },
{ "id": "t-3", "owner": "u-admin", "title": "Review the budget", "priority": "low", "done": false }
]
}"owner": "owner"names the field that holds the creating user. IWC sets it on create — the client cannot choose it.access: admins and the owner can read; any signed-in user can create; only the owner can update or delete. Someone else's task looks exactly like a missing one (404), so ids can't be probed."index": trueondonespeeds up "open tasks" queries.
The user schema is the one from Tutorial 7; app.iwc adds one key, database, explained in Tutorial 9:
{
"name": "Tasks",
"iwc": 1,
"theme": {
"tokens": {
"color-primary": "#5b4cf5",
"color-on-primary": "#ffffff",
"color-muted": "#5d6073",
"color-surface": "#f6f4ff",
"color-border": "#e6e6ef",
"color-danger": "#c0262d",
"radius": "12px",
"font-body": "system-ui, sans-serif"
}
},
"styles": ["styles/main.css"],
"head": { "title": "%s · Tasks" },
"auth": { "user": "user.schema.iwc", "identity": "email", "defaultRole": "member", "loginPage": "/login" },
"database": { "url": { "env": "TASKS_DATABASE_URL" } },
"notFound": "not-found.ui.iwc"
}Step 2: The four operations
Create
{
"name": "createTask",
"method": "POST",
"path": "/tasks",
"operation": "db.create",
"resource": "task.schema.iwc",
"request": {
"payload": {
"title": { "type": "string", "required": true, "minLength": 2, "maxLength": 120 },
"notes": { "type": "string", "maxLength": 2000 },
"priority": { "type": "string", "enum": ["low", "normal", "high"] },
"due": { "type": "date" }
}
},
"response": { "status": 201, "fields": { "id": "string", "title": "string" } },
"security": { "auth": true, "rateLimit": "60/1m" }
}Read (for API clients — pages read the table directly)
{
"name": "listTasks",
"method": "GET",
"path": "/tasks",
"description": "The signed-in user's tasks (admins see everyone's).",
"operation": "db.list",
"resource": "task.schema.iwc",
"request": { "query": { "done": { "type": "boolean" }, "priority": { "type": "string", "enum": ["low", "normal", "high"] }, "page": { "type": "integer", "min": 1 } } },
"response": { "type": "list", "fields": { "id": "string", "title": "string", "priority": "string", "due": "string", "done": "boolean" } },
"security": { "auth": true }
}db.list understands ?page, ?limit, ?sort=-createdAt, ?q= and filters for declared query fields such as ?done=false.
Update
{
"name": "updateTask",
"method": "PATCH",
"path": "/tasks/:id",
"operation": "db.update",
"resource": "task.schema.iwc",
"request": {
"payload": {
"title": { "type": "string", "minLength": 2, "maxLength": 120 },
"notes": { "type": "string", "maxLength": 2000 },
"priority": { "type": "string", "enum": ["low", "normal", "high"] },
"due": { "type": "date" },
"done": { "type": "boolean" }
}
},
"response": { "fields": { "id": "string", "title": "string", "done": "boolean" } },
"security": { "auth": true }
}Only the fields in payload can change. Every one is optional, so a client can send just {"done": true}.
Delete
{
"name": "deleteTask",
"method": "DELETE",
"path": "/tasks/:id",
"operation": "db.delete",
"resource": "task.schema.iwc",
"security": { "auth": true }
}Step 3: The task list
{
"name": "My tasks",
"route": "/tasks",
"layout": "main.layout.iwc",
"auth": "required",
"title": "My tasks",
"sections": [
{
"type": "section",
"className": "page",
"layout": { "direction": "column", "gap": "24px" },
"elements": [
{ "type": "text", "variant": "h1", "bind": "user.name", "prefix": "Tasks for " },
{
"type": "column",
"className": "panel",
"elements": [
{ "type": "text", "variant": "h3", "content": "New task" },
{
"type": "form",
"inputs": [
{ "name": "title", "type": "text", "label": "Title", "required": true },
{ "name": "priority", "type": "select", "label": "Priority", "value": "normal", "options": [{ "label": "Low", "value": "low" }, { "label": "Normal", "value": "normal" }, { "label": "High", "value": "high" }] },
{ "name": "due", "type": "date", "label": "Due" }
],
"submit": { "api": "task-create.api.iwc", "label": "Add task", "success": { "reset": true, "reload": true } }
}
]
},
{
"type": "stack",
"className": "task-list",
"layout": { "direction": "column", "gap": "8px" },
"data": {
"source": "DATABASE.tasks",
"columns": ["title", "priority", "due", "done", "owner"],
"filter": { "owner": { "user": "id" } },
"sort": { "field": "createdAt", "order": "desc" },
"live": true
},
"item": {
"type": "row",
"className": "task",
"layout": { "align": "center", "gap": "12px" },
"attributes": { "data-done": "=done ? 'yes' : 'no'" },
"elements": [
{ "type": "column", "className": "grow", "elements": [
{ "type": "text", "variant": "label", "bind": "title" },
{ "type": "text", "variant": "caption", "content": "=concat(priority, ' priority', due ? concat(' · due ', format(due, 'date-short')) : '')" }
] },
{ "type": "button", "label": "Done", "variant": "secondary", "action": "api", "api": "task-update.api.iwc", "value": { "done": true }, "success": { "reload": true }, "visible": "=!done" },
{ "type": "button", "label": "Reopen", "variant": "ghost", "action": "api", "api": "task-update.api.iwc", "value": { "done": false }, "success": { "reload": true }, "visible": "=done" },
{ "type": "button", "label": "Edit", "variant": "ghost", "action": "navigate", "target": "/tasks/:id" },
{ "type": "button", "label": "Delete", "variant": "ghost", "action": "api", "api": "task-delete.api.iwc", "success": { "reload": true } }
]
},
"empty": { "type": "text", "className": "muted", "content": "No tasks yet — add your first one above." }
}
]
}
]
}- The form creates a task, then
resets andreloads the list in place. - The
stackreadsDATABASE.tasks, only the columns it needs, filtered to the signed-in user ({"user": "id"}), newest first.live: truere-renders it in every open tab when the table changes. - Inside
item, the Done / Reopen buttons call the update API with a JSONvalue. The API's:idis filled from the current record, so each button acts on its own row. visibleexpressions show either Done or Reopen;attributessetsdata-donefor the strike-through style.
Step 4: The edit page
{
"name": "Edit task",
"route": "/tasks/:id",
"layout": "main.layout.iwc",
"auth": "required",
"title": "Edit task",
"sections": [
{
"type": "section",
"className": "page narrow",
"data": { "source": "DATABASE.tasks", "mode": "single", "columns": ["title", "notes", "priority", "due", "done"], "filter": { "id": { "param": "id" } } },
"layout": { "direction": "column", "gap": "16px" },
"elements": [
{ "type": "link", "label": "← All tasks", "target": "/tasks" },
{ "type": "text", "variant": "h1", "content": "Edit task" },
{
"type": "form",
"inputs": [
{ "name": "title", "type": "text", "label": "Title", "required": true, "value": "item.title" },
{ "name": "notes", "type": "textarea", "label": "Notes", "value": "item.notes" },
{ "name": "priority", "type": "select", "label": "Priority", "value": "item.priority", "options": [{ "label": "Low", "value": "low" }, { "label": "Normal", "value": "normal" }, { "label": "High", "value": "high" }] },
{ "name": "due", "type": "date", "label": "Due", "value": "item.due" },
{ "name": "done", "type": "checkbox", "label": "Done", "value": "item.done" }
],
"submit": { "api": "task-update.api.iwc", "label": "Save", "success": { "navigate": "/tasks" } }
}
],
"empty": { "type": "text", "content": "That task doesn't exist, or isn't yours." }
}
]
}"mode": "single" with "filter": {"id": {"param": "id"}} loads the task from the URL. Each input's value prefills it (item.title); the form posts to the update API at /api/tasks/:id. If the task isn't yours, the empty element is shown.
Step 5: Header, layout, styles
{
"name": "Header",
"root": {
"type": "navbar",
"className": "site-header",
"elements": [
{ "type": "link", "label": "Tasks", "target": "/", "className": "brand" },
{
"type": "row",
"className": "account-actions",
"layout": { "align": "center", "gap": "12px" },
"elements": [
{ "type": "link", "label": "Sign in", "target": "/login", "visible": { "auth": "signed-out" } },
{ "type": "link", "label": "My tasks", "target": "/tasks", "visible": { "auth": "signed-in" } },
{ "type": "link", "label": "Admin", "target": "/admin", "visible": { "roles": ["admin"] } },
{ "type": "button", "label": "Sign out", "variant": "ghost", "action": "logout", "target": "/", "visible": { "auth": "signed-in" } }
]
}
]
}
}{
"name": "MainLayout",
"slots": { "header": "header.component.iwc", "content": "PAGE_CONTENT" },
"structure": { "header": { "position": "sticky" } }
}Step 6: Test the whole cycle
{
"name": "tasks",
"cases": [
{ "name": "the task list needs sign-in", "request": { "path": "/tasks" }, "expect": { "status": 303 } },
{ "name": "a member sees only their own tasks", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "path": "/tasks" }, "expect": { "status": 200, "contains": ["Book the venue", "Send invitations"], "excludes": ["Review the budget"] } },
{ "name": "creating a task", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "method": "POST", "path": "/api/tasks", "body": { "title": "Order flowers", "priority": "high", "due": "2026-11-25" } }, "expect": { "status": 201, "body": { "title": "Order flowers" } }, "save": { "taskId": "id" } },
{ "name": "it appears in the API listing", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "path": "/api/tasks?done=false" }, "expect": { "status": 200, "contains": ["Order flowers", "Book the venue"], "excludes": ["Send invitations"] } },
{ "name": "marking it done", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "method": "PATCH", "path": "/api/tasks/{{taskId}}", "body": { "done": true } }, "expect": { "status": 200, "body": { "done": true } } },
{ "name": "another member cannot change it", "as": { "email": "admin@example.com", "password": "admin-pass-123" }, "request": { "method": "PATCH", "path": "/api/tasks/{{taskId}}", "body": { "title": "Hacked" } }, "expect": { "status": 404 } },
{ "name": "nor delete it", "as": { "email": "admin@example.com", "password": "admin-pass-123" }, "request": { "method": "DELETE", "path": "/api/tasks/{{taskId}}" }, "expect": { "status": 404 } },
{ "name": "the edit page is prefilled", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "path": "/tasks/{{taskId}}" }, "expect": { "status": 200, "contains": ["value=\"Order flowers\"", "2026-11-25"] } },
{ "name": "the owner deletes it", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "method": "DELETE", "path": "/api/tasks/{{taskId}}" }, "expect": { "status": 200 } },
{ "name": "titles are validated", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "method": "POST", "path": "/api/tasks", "body": { "title": "x" } }, "expect": { "status": 422 } },
{ "name": "the owner cannot be chosen by the client", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "method": "POST", "path": "/api/tasks", "body": { "title": "Sneaky", "owner": "u-admin" } }, "expect": { "status": 422 } }
]
}iwc test ✓ tasks › the task list needs sign-in
✓ tasks › a member sees only their own tasks
✓ tasks › creating a task
✓ tasks › it appears in the API listing
✓ tasks › marking it done
✓ tasks › another member cannot change it
✓ tasks › nor delete it
✓ tasks › the edit page is prefilled
✓ tasks › the owner deletes it
✓ tasks › titles are validated
✓ tasks › the owner cannot be chosen by the clientsave stores the new task's id, and later cases use it as {{taskId}}.
Troubleshooting
| Problem | Fix |
|---|---|
| A task you can see in the database answers 404 | The table's access rules hide it from this user, as intended. |
iwc check reports a binding to a field the data source doesn't load | Add the field to the data source's columns (only listed columns are selected). |
| Delete buttons delete the wrong row | Put the API button inside the item, so :id comes from that record. |
Next: Connect a database.
Source: apps/marketing/content/docs/tutorials/crud.md