Skip to content
ONIWC
iwc 0.1.0

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.

Project files
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.iwc

Step 1: The message table

database/message.schema.iwc
{
  "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:

actions/post-message.action.iwc
{
  "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" } }
  ]
}
api/message-post.api.iwc
{
  "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

ui/room.ui.iwc
{
  "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": true is the whole real-time feature. When anyone posts, every open room re-renders the message list in place.
  • attributes sets data-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.
styles/main.css
.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

Terminal
iwc dev --open

Sign 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

tests/chat.test.iwc
{
  "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 } }
  ]
}
Terminal
iwc test
Output
  ✓ 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 failed

How 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.

Next: Build an offline-capable application.

Source: apps/marketing/content/docs/tutorials/realtime.md