Tutorial 14: Build a real-time application
Objective: a team chat room where new messages appear in every open browser without reloading.
Prerequisites: Tutorial 7.
IWC's real-time building block is the live data source: "live": true on a DATABASE source makes IWC re-render that block in every open browser when its table changes, over Server-Sent Events (/_iwc/events). There is no client code and no message broker to run. For publishing your own events to channels at scale (presence, typing indicators, tracking), @iwc/realtime connects Pusher or Ably.
chat/
├── actions/
│ └── post-message.action.iwc
├── api/
│ ├── login.api.iwc
│ ├── message-post.api.iwc
│ └── register.api.iwc
├── database/
│ ├── message.schema.iwc
│ └── user.schema.iwc
├── styles/
│ └── main.css
├── tests/
│ └── chat.test.iwc
├── ui/
│ ├── login.ui.iwc
│ ├── register.ui.iwc
│ └── room.ui.iwc
├── app.iwc
└── package.iwcStep 1: The message table
{
"name": "message",
"fields": {
"author": { "type": "relation", "to": "user.schema.iwc", "required": true, "onDelete": "cascade" },
"authorName": { "type": "string", "required": true, "maxLength": 80 },
"body": { "type": "string", "required": true, "minLength": 1, "maxLength": 500 }
},
"owner": "author",
"access": { "read": "auth", "create": "auth", "update": "none", "delete": "owner" },
"seed": [
{ "id": "m-1", "author": "u-asha", "authorName": "Asha", "body": "Morning! Stand-up in 10?" },
{ "id": "m-2", "author": "u-ben", "authorName": "Ben", "body": "On my way." }
]
}authorName is stored with the message so the list needs no join. Reads need sign-in.
Step 2: Post through an action
The action fills in who wrote the message from the session — the client only sends the text:
{
"name": "PostMessage",
"description": "Stores a message under the signed-in user's name.",
"auth": "required",
"input": { "body": { "type": "string", "required": true, "minLength": 1, "maxLength": 500 } },
"steps": [
{ "operation": "create", "resource": "message.schema.iwc", "as": "m", "values": { "author": "=user.id", "authorName": "=user.name", "body": "=input.body" } },
{ "operation": "return", "value": { "id": "=m.id" } }
]
}{
"name": "postMessage",
"method": "POST",
"path": "/messages",
"action": "post-message.action.iwc",
"request": { "payload": { "body": { "type": "string", "required": true, "minLength": 1, "maxLength": 500 } } },
"response": { "status": 201, "fields": { "id": "string" } },
"security": { "auth": true, "rateLimit": "30/1m" }
}Step 3: The room
{
"name": "Room",
"route": "/",
"auth": "required",
"title": "Team chat",
"sections": [
{
"type": "section",
"className": "room",
"layout": { "direction": "column", "gap": "16px" },
"elements": [
{
"type": "row",
"layout": { "justify": "between", "align": "center" },
"elements": [
{ "type": "text", "variant": "h1", "content": "# general" },
{ "type": "button", "label": "Sign out", "variant": "ghost", "action": "logout", "target": "/login" }
]
},
{
"type": "stack",
"className": "messages",
"layout": { "direction": "column", "gap": "10px" },
"data": {
"source": "DATABASE.messages",
"columns": ["authorName", "body", "createdAt", "author"],
"sort": { "field": "createdAt", "order": "asc" },
"limit": 200,
"live": true
},
"item": {
"type": "column",
"className": "bubble",
"attributes": { "data-mine": "=author == user.id ? 'yes' : 'no'" },
"elements": [
{ "type": "text", "variant": "caption", "content": "=concat(authorName, ' · ', format(createdAt, 'time'))" },
{ "type": "text", "bind": "body" }
]
},
"empty": { "type": "text", "className": "muted", "content": "No messages yet. Say hello!" }
},
{
"type": "form",
"className": "composer",
"inputs": [{ "name": "body", "type": "text", "label": "Message", "placeholder": "Write a message…", "required": true }],
"submit": { "api": "message-post.api.iwc", "label": "Send", "success": { "reset": true } }
}
]
}
]
}"live": trueis the whole real-time feature. When anyone posts, every open room re-renders the message list in place.attributessetsdata-mine="yes"on your own messages; the stylesheet aligns them right.format(createdAt, 'time')shows the time in a readable form.- The form clears itself after sending (
reset). The sender's own list updates through the same live channel.
.room { max-width: 720px; margin: 0 auto; padding: 32px 16px; min-height: 100vh; }
.messages { flex: 1; padding: 16px; border: 1px solid var(--color-border); border-radius: var(--radius); min-height: 50vh; }
.bubble { max-width: 80%; padding: 10px 14px; border-radius: var(--radius); background: var(--color-surface); }
.bubble[data-mine="yes"] { align-self: flex-end; background: var(--color-primary); color: var(--color-on-primary); }
.bubble small { opacity: 0.75; }
.muted { color: var(--color-muted); }
.composer { display: flex; gap: 8px; align-items: end; }
.composer .iwc-field { flex: 1; display: flex; flex-direction: column; }
.composer input { padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 10px; font: inherit; }Step 4: Try it
iwc dev --openSign in as asha@example.com / asha-pass-123 in one browser and ben@example.com / ben-pass-1234 in a private window. Send a message from either: it appears in both.
Step 5: Test it
{
"name": "chat",
"cases": [
{ "name": "the room needs sign-in", "request": { "path": "/" }, "expect": { "status": 303 } },
{ "name": "members see the conversation", "as": { "email": "ben@example.com", "password": "ben-pass-1234" }, "request": { "path": "/" }, "expect": { "status": 200, "contains": ["Stand-up in 10?", "On my way.", "data-mine=\"yes\""] } },
{ "name": "posting stores the message under the sender's name", "as": { "email": "asha@example.com", "password": "asha-pass-123" }, "request": { "method": "POST", "path": "/api/messages", "body": { "body": "Starting now" } }, "expect": { "status": 201 } },
{ "name": "everyone sees it", "as": { "email": "ben@example.com", "password": "ben-pass-1234" }, "request": { "path": "/" }, "expect": { "status": 200, "contains": ["Asha · ", "Starting now"] } },
{ "name": "the sender cannot be forged", "as": { "email": "asha@example.com", "password": "asha-pass-123" }, "request": { "method": "POST", "path": "/api/messages", "body": { "body": "hi", "authorName": "Ben" } }, "expect": { "status": 422 } },
{ "name": "empty messages are refused", "as": { "email": "asha@example.com", "password": "asha-pass-123" }, "request": { "method": "POST", "path": "/api/messages", "body": { "body": "" } }, "expect": { "status": 422 } }
]
}iwc test ✓ chat › the room needs sign-in
✓ chat › members see the conversation
✓ chat › posting stores the message under the sender's name
✓ chat › everyone sees it
✓ chat › the sender cannot be forged
✓ chat › empty messages are refused
6 passed, 0 failedHow it scales
- Live updates are pushed by the server that handled the write. On shared hosting (one process per request), live blocks refresh every 30 seconds instead.
"refresh": "30s"re-renders on a timer when push isn't needed.- For several server instances or custom events, use
@iwc/realtime(Pusher, Ably) — see Packages & services.
Source: apps/marketing/content/docs/tutorials/realtime.md