Skip to content

Todo App

This recipe extends the Quick Start into a runnable, deployable project. By the end you have a todo app running against a real D1 database with per-user auth.

my-todo-app/
├── package.json
├── wrangler.jsonc
├── worker.ts
├── src/
│ ├── shared/
│ │ └── schema.ts # imported by both frontend and Worker
│ ├── main.tsx # React entry
│ ├── App.tsx
│ └── TodoList.tsx
├── vite.config.ts
└── tsconfig.json

The shared directory is the whole point. Everything under it is imported by both the client bundle (Vite → browser) and the Worker bundle (Wrangler → Cloudflare Workers runtime).

  1. Terminal window
    pnpm create vite my-todo-app --template react-ts
    cd my-todo-app
    pnpm add @sh1n4ps/plasma-core @sh1n4ps/plasma-client @sh1n4ps/plasma-server @sh1n4ps/plasma-react @sh1n4ps/plasma-devtools
    pnpm add -D wrangler @cloudflare/workers-types
  2. src/shared/schema.ts
    import {
    defineMutators, defineSchema, eq, id, int, ref, table, text,
    } from "@sh1n4ps/plasma-core"
    export const users = table("users", {
    id: id(),
    email: text().unique(),
    name: text(),
    })
    export const todos = table("todos", {
    id: id(),
    title: text(),
    done: int().default(0),
    userId: ref(() => users.id, { onDelete: "cascade" }),
    updatedAt: int(),
    }, {
    auth: {
    read: (ctx: Ctx, row) => row.userId === ctx.userId,
    write: (ctx: Ctx, row) => row.userId === ctx.userId,
    },
    })
    export const schema = defineSchema({ users, todos })
    export const SCHEMA_VERSION = "todos-v1"
    export interface Ctx {
    userId: string
    }
    export const mutators = defineMutators<typeof schema, Ctx>()({
    createTodo: async ({ db, args, ctx }) => {
    await db.insert(todos).values({
    id: args.id,
    title: args.title,
    userId: ctx.userId,
    updatedAt: args.updatedAt,
    })
    },
    markDone: async ({ db, args }) => {
    await db.update(todos)
    .set({ done: 1, updatedAt: args.updatedAt })
    .where(eq(todos.id, args.id))
    },
    editTitle: async ({ db, args }) => {
    await db.update(todos)
    .set({ title: args.title, updatedAt: args.updatedAt })
    .where(eq(todos.id, args.id))
    },
    deleteTodo: async ({ db, args }) => {
    await db.delete(todos).where(eq(todos.id, args.id))
    },
    })

    Note the auth predicates on the todos table — one user’s todos are invisible to another because row.userId === ctx.userId is the read/write gate.

  3. worker.ts
    import {
    createSyncHandler, ensureSchema, fromD1,
    } from "@sh1n4ps/plasma-server"
    import { schema, mutators, SCHEMA_VERSION } from "./src/shared/schema"
    interface Env {
    DB: D1Database
    }
    async function verifyJWT(_token: string) {
    // Replace with your real JWT verification.
    return { id: "demo-user", email: "demo@example.com" }
    }
    export default {
    async fetch(req: Request, env: Env): Promise<Response> {
    const executor = fromD1(env.DB)
    await ensureSchema({ schema, executor })
    const handler = createSyncHandler({
    schema,
    mutators,
    executor,
    schemaVersion: SCHEMA_VERSION,
    auth: async (req) => {
    const token = req.headers.get("authorization")?.replace("Bearer ", "")
    if (!token) return { ok: false, reason: "no token" }
    const user = await verifyJWT(token)
    return {
    ok: true,
    clientGroupID: user.id,
    clientID: req.headers.get("x-client") ?? crypto.randomUUID(),
    ctx: { userId: user.id },
    }
    },
    })
    return handler(req)
    },
    }
  4. {
    "$schema": "node_modules/wrangler/config-schema.json",
    "name": "my-todo-app",
    "main": "./worker.ts",
    "compatibility_date": "2026-01-01",
    "compatibility_flags": ["nodejs_compat"],
    "d1_databases": [
    {
    "binding": "DB",
    "database_name": "my-todo-app-dev",
    "database_id": "your-d1-id-here"
    }
    ]
    }

    Create the D1:

    Terminal window
    pnpm wrangler d1 create my-todo-app-dev
    # Paste the printed database_id into wrangler.jsonc
  5. src/main.tsx
    import { createRoot } from "react-dom/client"
    import { createPlasmaClient } from "@sh1n4ps/plasma-client"
    import { PlasmaProvider } from "@sh1n4ps/plasma-react"
    import { App } from "./App"
    import { schema, mutators, SCHEMA_VERSION } from "./shared/schema"
    export const plasma = createPlasmaClient({
    schema,
    mutators,
    dbName: "todos-db",
    endpoint: "/sync",
    clientGroupID: "demo-user",
    schemaVersion: SCHEMA_VERSION,
    getContext: async () => ({ userId: "demo-user" }),
    authHeaders: async () => ({
    "authorization": `Bearer ${localStorage.getItem("token") ?? "demo"}`,
    }),
    })
    plasma.start()
    createRoot(document.getElementById("root")!).render(
    <PlasmaProvider client={plasma}>
    <App />
    </PlasmaProvider>,
    )
  6. src/TodoList.tsx
    import { useState } from "react"
    import { asc, eq } from "@sh1n4ps/plasma-core"
    import { useLiveQuery, useMutation } from "@sh1n4ps/plasma-react"
    import { plasma } from "./main"
    import { mutators, todos } from "./shared/schema"
    export function TodoList() {
    const rows = useLiveQuery(
    () => plasma.db
    .select()
    .from(todos)
    .where(eq(todos.done, 0))
    .orderBy(asc(todos.updatedAt)),
    [],
    )
    const createTodo = useMutation<typeof mutators, "createTodo">("createTodo")
    const markDone = useMutation<typeof mutators, "markDone">("markDone")
    const deleteTodo = useMutation<typeof mutators, "deleteTodo">("deleteTodo")
    const [draft, setDraft] = useState("")
    return (
    <div>
    <form onSubmit={(e) => {
    e.preventDefault()
    if (!draft.trim()) return
    createTodo.mutate({
    id: plasma.newId(),
    title: draft,
    updatedAt: Date.now(),
    })
    setDraft("")
    }}>
    <input
    value={draft}
    onChange={(e) => setDraft(e.target.value)}
    placeholder="New todo…"
    />
    <button type="submit">Add</button>
    </form>
    <ul>
    {rows.map((t) => (
    <li key={t.id}>
    {t.title}
    <button onClick={() => markDone.mutate({
    id: t.id, updatedAt: Date.now(),
    })}>done</button>
    <button onClick={() => deleteTodo.mutate({ id: t.id })}>×</button>
    </li>
    ))}
    </ul>
    </div>
    )
    }
  7. src/App.tsx
    import { PlasmaDevtools } from "@sh1n4ps/plasma-devtools"
    import { plasma } from "./main"
    import { TodoList } from "./TodoList"
    import { schema } from "./shared/schema"
    export function App() {
    return (
    <>
    <TodoList />
    {import.meta.env.DEV && (
    <PlasmaDevtools client={plasma} dbName="todos-db" schema={schema} />
    )}
    </>
    )
    }
  8. vite.config.ts
    import react from "@vitejs/plugin-react"
    import { defineConfig } from "vite"
    export default defineConfig({
    plugins: [react()],
    server: {
    proxy: {
    "/sync": "http://localhost:8787",
    },
    },
    })

    This makes endpoint: "/sync" on the client resolve to the local Worker during pnpm dev.

  9. Terminal A:

    Terminal window
    pnpm wrangler dev --local

    Terminal B:

    Terminal window
    pnpm dev

    Open http://localhost:5173. Add a todo. See it appear optimistically. Refresh the page — the todo persists (it’s in IndexedDB). Kill Terminal A (the Worker) and add another todo — still works, the outbox accumulates. Restart the Worker; the outbox flushes.

  10. Terminal window
    pnpm wrangler deploy

    Update the frontend’s endpoint to point at the deployed Worker URL, then build and deploy the frontend to Cloudflare Workers Static Assets (or any other host).

Beyond a working todo app:

  • Instant UI updates — every mutation returns synchronously from IDB. No spinners on the happy path.
  • Offline resilience — kill the Worker, close the tab, restore connection tomorrow. The outbox waits.
  • Realtime across tabs — open the same URL twice. Add a todo in one tab; it appears in the other on the next pull (default 5s poll, or immediately if you wire the WebSocket).
  • Per-user isolation — try two different authorization tokens. Each user sees only their own todos.
  • Add per-todo comments as a joined comments table and use innerJoin in useLiveQuery.
  • Add reactions with a crdtOrSet<string>() column so multiple tabs adding the same emoji converge.
  • Add a crdtPnCounter for “how many todos have I completed” that shows the current sum across all active tabs.
  • Add file attachments with a file().nullable() column and R2 wiring (see Files and blobs).