Build a real-time chat app in 15 minutes

End-to-end Lunora walkthrough — schema, query, mutation, subscription, React, sharding.

Last updated:

This tutorial builds a working multi-channel chat in about fifteen minutes of typing. By the end you'll have a typed schema, a query that auto-subscribes, a mutation that broadcasts deltas, a React UI that reacts to them, and the messages table sharded by channelId so it scales past one Durable Object.

You'll need the prerequisites from Getting started: Node 22+, pnpm 10+, and a Cloudflare account if you want to deploy.

1. Scaffold

pnpm dlx lunorash@alpha init chat
cd chat
pnpm install

The template gives you a lunora/schema.ts, a starter lunora/messages.ts, and a src/server/index.ts that boots @lunora/runtime's createWorker.

2. Model the data

Replace lunora/schema.ts with two tables: channels (global, so every shard sees them) and messages (sharded by channel, so each room gets its own DO):

import { defineSchema, defineTable, v } from "lunorash/server";

export const schema = defineSchema({
    channels: defineTable({
        name: v.string(),
        topic: v.optional(v.string()),
    })
        .global()
        .index("by_name", ["name"], { unique: true }),

    messages: defineTable({
        channelId: v.id("channels"),
        userId: v.id("users"),
        text: v.string(),
        createdAt: v.number(),
    })
        .shardBy("channelId")
        .index("by_channel", ["channelId", "createdAt"]),
});

.global() puts channels in D1: there are only so many rooms, and you want them addressable from any shard. .shardBy("channelId") puts each channel's messages in its own Durable Object so a single noisy room can't exhaust another room's budget.

3. Generate the migration

channels is global, so it needs a D1 migration. Lunora reads the schema and writes the SQL for you:

pnpm lunora migrate generate init
# wrote lunora/migrations/20240401123456_init.sql

Commit the SQL and the .snapshot.json next to it — both are deterministic.

4. Write the query

Create lunora/messages.ts (the template already has a starter version — replace its contents with the real implementation):

import { mutation, query, v } from "@/lunora/_generated/server";

export const list = query.input({ channelId: v.id("channels"), limit: v.optional(v.number()) }).query(async ({ ctx, args: { channelId, limit } }) => {
    return ctx.db
        .query("messages")
        .withIndex("by_channel", (q) => q.eq("channelId", channelId))
        .order("desc")
        .take(limit ?? 50);
});

Every Lunora query doubles as a subscription. The React side calls useQuery and re-renders whenever a mutation in the same shard touches the messages table.

5. Write the mutation

export const send = mutation.input({ channelId: v.id("channels"), text: v.string() }).mutation(async ({ ctx, args: { channelId, text } }) => {
    if (!ctx.auth.userId) throw new Error("must be signed in");

    await ctx.db.insert("messages", {
        channelId,
        userId: ctx.auth.userId,
        text,
        createdAt: Date.now(),
    });
});

ctx.db.insert types itself against your schema — pass the wrong shape and TypeScript yells. At runtime the insert broadcasts a delta envelope; every client subscribed to list for that channelId re-renders.

6. Wire the React app

src/client/main.tsx already wraps your tree in LunoraProvider. Build the chat panel:

import { api } from "@/lunora/_generated/api";
import { useMutation, useQuery } from "@lunora/react";
import { useState } from "react";

export const Chat = ({ channelId }: { channelId: string }) => {
    const messages = useQuery(api.messages.list, { channelId });
    const send = useMutation(api.messages.send);
    const [text, setText] = useState("");

    if (!messages) return <p>Loading...</p>;

    return (
        <div>
            <ul>
                {messages.toReversed().map((m) => (
                    <li key={m._id}>{m.text}</li>
                ))}
            </ul>
            <form
                onSubmit={(event) => {
                    event.preventDefault();
                    void send({ channelId, text });
                    setText("");
                }}
            >
                <input value={text} onChange={(event) => setText(event.target.value)} />
                <button type="submit">Send</button>
            </form>
        </div>
    );
};

7. Run the dev loop

pnpm dev

Open two browser tabs at http://localhost:5173, point them at the same channelId, and type. The second tab sees the first tab's message land without a refresh: the mutation broadcast travelled through the channel's Durable Object back to every subscriber.

8. Deploy

pnpm lunora deploy

lunora deploy runs codegen, validates wrangler.jsonc, applies any pending D1 migrations through MigrationRunner, and finally invokes wrangler deploy. Your chat is now live on Cloudflare's edge: one DO per channel, hibernated when idle, billed by the millisecond.

Where to next