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.
Project layout
Section titled “Project layout”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.jsonThe 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).
-
Bootstrap the workspace
Section titled “Bootstrap the workspace”Terminal window pnpm create vite my-todo-app --template react-tscd my-todo-apppnpm add @sh1n4ps/plasma-core @sh1n4ps/plasma-client @sh1n4ps/plasma-server @sh1n4ps/plasma-react @sh1n4ps/plasma-devtoolspnpm add -D wrangler @cloudflare/workers-types -
Write the shared schema
Section titled “Write the shared schema”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
authpredicates on thetodostable — one user’s todos are invisible to another becauserow.userId === ctx.userIdis the read/write gate. -
Write the Worker
Section titled “Write the Worker”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)},} -
wrangler.jsonc
Section titled “wrangler.jsonc”{"$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 -
The React entry
Section titled “The React entry”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>,) -
The TodoList component
Section titled “The TodoList component”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()) returncreateTodo.mutate({id: plasma.newId(),title: draft,updatedAt: Date.now(),})setDraft("")}}><inputvalue={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>)} -
The App shell
Section titled “The App shell”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} />)}</>)} -
Vite proxy for
Section titled “Vite proxy for /sync”/syncvite.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 duringpnpm dev. -
Run it
Section titled “Run it”Terminal A:
Terminal window pnpm wrangler dev --localTerminal B:
Terminal window pnpm devOpen
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. -
Deploy
Section titled “Deploy”Terminal window pnpm wrangler deployUpdate the frontend’s
endpointto point at the deployed Worker URL, then build and deploy the frontend to Cloudflare Workers Static Assets (or any other host).
What you get
Section titled “What you get”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
authorizationtokens. Each user sees only their own todos.
Extending
Section titled “Extending”- Add per-todo comments as a joined
commentstable and useinnerJoininuseLiveQuery. - Add reactions with a
crdtOrSet<string>()column so multiple tabs adding the same emoji converge. - Add a
crdtPnCounterfor “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).
What to read next
Section titled “What to read next”- Auth and permissions — replace the demo JWT with real auth
- Deployment — production wrangler wiring
- Offline notes — recipe with file attachments + offline mode