| 1 | # cloudflare-tanstack-rpc-drizzle |
| 2 | |
| 3 | A full-stack example that wires together four pieces: |
| 4 | |
| 5 | - **TanStack Start** (React) frontend, deployed as a Cloudflare Worker + assets via `Cloudflare.Website.Vite`. |
| 6 | - **Effect RPC** backend, deployed as a separate `Cloudflare.Workers.RpcWorker`. |
| 7 | - **Drizzle + Neon Postgres**, reached through a `Cloudflare.Hyperdrive.Connection` pool, with migrations generated by `Drizzle.Schema`. |
| 8 | - **Atom RPC** — Effect 4's native `effect/reactivity/AtomRpc` plus the React bindings from `@effect/atom-react` — for reactive queries and mutations in the browser. |
| 9 | |
| 10 | ## Architecture |
| 11 | |
| 12 | ``` |
| 13 | Browser (React) |
| 14 | │ useAtomValue / useAtomSet |
| 15 | ▼ |
| 16 | AtomRpc client (TodoRpcs) src/rpc-client.ts |
| 17 | │ HTTP POST /rpc (JSON) |
| 18 | ▼ |
| 19 | TanStack Start worker src/routes/rpc.ts (server route) |
| 20 | │ env.BACKEND.fetch(...) (private service binding) |
| 21 | ▼ |
| 22 | Backend RpcWorker src/backend/api.ts |
| 23 | │ Drizzle.Postgres over Hyperdrive |
| 24 | ▼ |
| 25 | Neon Postgres branch |
| 26 | ``` |
| 27 | |
| 28 | The browser cannot use a Cloudflare service binding directly, so the `AtomRpc` |
| 29 | client points at a same-origin `/rpc` route, and the TanStack Start server |
| 30 | forwards that request to the private `BACKEND` worker over the service binding. |
| 31 | This keeps the backend off the public internet and avoids CORS. |
| 32 | |
| 33 | ## Atom RPC |
| 34 | |
| 35 | Effect 4 ships atom RPC in core — no third-party `@effect-atom` package is |
| 36 | needed (that one targets Effect 3). The only extra dependency is |
| 37 | `@effect/atom-react`, which provides the React hooks (`useAtomValue`, |
| 38 | `useAtomSet`, `RegistryProvider`) and is versioned in lockstep with `effect`. |
| 39 | |
| 40 | ```ts |
| 41 | // src/rpc-client.ts |
| 42 | export class TodoClient extends AtomRpc.Service<TodoClient>()("TodoClient", { |
| 43 | group: TodoRpcs, |
| 44 | protocol: RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe( |
| 45 | Layer.provide(FetchHttpClient.layer), |
| 46 | Layer.provide(RpcSerialization.layerJson), |
| 47 | ), |
| 48 | }) {} |
| 49 | |
| 50 | export const listTodosAtom = TodoClient.query("listTodos", undefined, { |
| 51 | reactivityKeys: ["todos"], |
| 52 | }); |
| 53 | export const createTodoAtom = TodoClient.mutation("createTodo"); |
| 54 | ``` |
| 55 | |
| 56 | ```tsx |
| 57 | // src/routes/index.tsx |
| 58 | const todos = AsyncResult.getOrElse(useAtomValue(listTodosAtom), () => []); |
| 59 | const createTodo = useAtomSet(createTodoAtom); |
| 60 | // ... |
| 61 | createTodo({ payload: { text }, reactivityKeys: ["todos"] }); |
| 62 | ``` |
| 63 | |
| 64 | `reactivityKeys: ["todos"]` ties the list query to the mutations: when a |
| 65 | mutation runs with the same key, the list query is invalidated and refetched |
| 66 | automatically. |
| 67 | |
| 68 | The same `TodoRpcs` group is the single source of truth — it is served by the |
| 69 | backend and consumed by the browser client, so one `Schema` codec round-trips |
| 70 | every value over the wire. |
| 71 | |
| 72 | ## Running |
| 73 | |
| 74 | Requires Cloudflare and Neon credentials (same as the other Neon examples): |
| 75 | |
| 76 | - `CLOUDFLARE_API_TOKEN` (or `alchemy profile edit --add Cloudflare`) |
| 77 | - `NEON_API_KEY` |
| 78 | |
| 79 | ```sh |
| 80 | bun install |
| 81 | |
| 82 | # Deploy the backend RpcWorker, Neon branch, Hyperdrive, and the TanStack site. |
| 83 | bun alchemy deploy |
| 84 | |
| 85 | # Tear everything down. |
| 86 | bun alchemy destroy |
| 87 | ``` |
| 88 | |
| 89 | `Drizzle.Schema` generates migration SQL into `./migrations` on deploy, and |
| 90 | `Neon.Branch` applies any pending migrations transactionally before the workers |
| 91 | go live. |
| 92 | |
| 93 | ## Tests |
| 94 | |
| 95 | ```sh |
| 96 | bun test # deploys, exercises /rpc end to end, then destroys |
| 97 | NO_DESTROY=1 bun test # keep the deployment around between runs |
| 98 | ``` |