1---
2title: Full-stack TanStack Start + RPC + Drizzle
3description: Build a reactive full-stack app on Cloudflare — a TanStack Start UI that drives an Effect RPC backend over Drizzle and Neon Postgres, with browser state wired through Effect 4's native atom RPC.
4sidebar:
5 order: 6
6---
7
8This guide ties four pieces into one deployable app:
9
10- **[TanStack Start](/cloudflare/frontend/frontends)** — the React frontend, deployed as a
11 Cloudflare Worker + assets via `Cloudflare.Website.Vite`.
12- **[Effect RPC](/cloudflare/apis/effect-rpc)** — a typed backend served by a separate
13 `Cloudflare.Workers.RpcWorker`.
14- **[Drizzle + Neon Postgres](/cloudflare/data/shared-database)** — reached through a
15 `Cloudflare.Hyperdrive.Connection` pool, with migrations generated by `Drizzle.Schema`.
16- **Atom RPC** — Effect 4's native `effect/reactivity/AtomRpc`, plus
17 the React bindings from `@effect/atom-react`, for reactive queries and
18 mutations in the browser.
19
20The full project lives in
21[`examples/cloudflare-tanstack-rpc-drizzle`](https://github.com/alchemy-run/alchemy/tree/main/examples/cloudflare-tanstack-rpc-drizzle).
22We'll build a Todo app and follow a single value — a `Todo` — from the
23Postgres row all the way to a checkbox in the browser.
24
25## The shape
26
27Data flows through five hops, and one `RpcGroup` value pins the types at every
28boundary:
29
30```
31Browser (React)
32 │ useAtomValue / useAtomSet (src/routes/index.tsx)
33 ▼
34AtomRpc client (TodoRpcs) (src/rpc-client.ts)
35 │ HTTP POST /rpc (JSON)
36 ▼
37TanStack Start worker — /rpc proxy (src/routes/rpc.ts)
38 │ env.BACKEND.fetch(...) (private service binding)
39 ▼
40Backend RpcWorker (TodoRpcs) (src/backend/api.ts)
41 │ Drizzle.Postgres over Hyperdrive
42 ▼
43Neon Postgres branch
44```
45
46The browser can't talk to a Cloudflare service binding directly, so the atom
47client posts to a same-origin `/rpc` route and the frontend Worker forwards the
48request to the private backend over its service binding. The backend stays off
49the public internet and there's no CORS to configure.
50
51## 1. The shared RPC contract
52
53Everything starts from one module imported by **both** ends — the backend that
54serves the procedures and the browser client that calls them. One `Schema`
55codec round-trips every value, so the React UI is typed against the exact
56shapes the Postgres-backed handlers return.
57
58```typescript
59// src/backend/rpc.ts
60import * as Schema from "effect/Schema";
61import { Rpc, RpcGroup } from "effect/rpc";
62
63export class Todo extends Schema.Class<Todo>("Todo")({
64 id: Schema.Number,
65 text: Schema.String,
66 done: Schema.Boolean,
67 createdAt: Schema.Date,
68}) {}
69
70export class TodoNotFound extends Schema.TaggedErrorClass<TodoNotFound>()(
71 "TodoNotFound",
72 { message: Schema.String, id: Schema.Number },
73) {}
74
75export class TodoRpcs extends RpcGroup.make(
76 Rpc.make("listTodos", { success: Schema.Array(Todo) }),
77 Rpc.make("createTodo", { payload: { text: Schema.String }, success: Todo }),
78 Rpc.make("toggleTodo", {
79 payload: { id: Schema.Number, done: Schema.Boolean },
80 success: Todo,
81 error: TodoNotFound,
82 }),
83 Rpc.make("deleteTodo", {
84 payload: { id: Schema.Number },
85 success: Schema.Number,
86 error: TodoNotFound,
87 }),
88) {}
89```
90
91`TodoRpcs` is a plain value-level description — nothing executes yet. See the
92[Effect RPC guide](/cloudflare/apis/effect-rpc) for a deeper tour of `Rpc.make` and
93schema-backed errors.
94
95## 2. The database
96
97The Drizzle table is ordinary Postgres schema:
98
99```typescript
100// src/backend/schema.ts
101import { defineRelations } from "drizzle-orm";
102import { boolean, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
103
104export const Todos = pgTable("todos", {
105 id: serial("id").primaryKey(),
106 text: text("text").notNull(),
107 done: boolean("done").notNull().default(false),
108 createdAt: timestamp("created_at", { withTimezone: true })
109 .notNull()
110 .defaultNow(),
111});
112
113export const relations = defineRelations({ Todos }, () => ({}));
114```
115
116`Drizzle.Schema` turns that table into migration SQL at deploy time, and
117`Neon.Branch` applies any pending migrations transactionally before the workers
118go live. A `Cloudflare.Hyperdrive.Connection` pool sits in front of the branch so the
119Worker reaches Postgres through Cloudflare's connection pooler:
120
121```typescript
122// src/backend/database.ts
123import * as Alchemy from "alchemy";
124import * as Cloudflare from "alchemy/Cloudflare";
125import * as Drizzle from "alchemy/Drizzle";
126import * as Neon from "alchemy/Neon";
127import * as Effect from "effect/Effect";
128
129export const NeonDatabase = Effect.gen(function* () {
130 const { stage } = yield* Alchemy.Stack;
131
132 const schema = yield* Drizzle.Schema("Schema", {
133 schema: "./src/backend/schema.ts",
134 out: "./migrations",
135 });
136
137 const project = stage.startsWith("pr-")
138 ? yield* Neon.Project.ref("Database", { stage: `staging-${stage}` })
139 : yield* Neon.Project("Database", { region: "aws-us-east-1" });
140
141 const branch = yield* Neon.Branch("Branch", {
142 project,
143 migrations: schema,
144 });
145
146 return { project, branch, schema };
147});
148
149export const Hyperdrive = Effect.gen(function* () {
150 const { branch } = yield* NeonDatabase;
151 return yield* Cloudflare.Hyperdrive.Connection("Hyperdrive", {
152 origin: branch.origin,
153 caching: { disabled: true },
154 });
155});
156```
157
158:::note
159Hyperdrive caches read query results for 60 seconds by default. This todo app
160wants writes to be visible immediately, so it disables caching and keeps
161Hyperdrive purely for connection pooling. Tune this to your own consistency
162needs — a common pattern is to run **two** connections, one cached (for reads
163that tolerate eventual consistency, in exchange for speed) and one uncached (for
164reads that must be immediately consistent).
165:::
166
167:::tip
168The `pr-*` branch above lets ephemeral preview stages reference a shared
169`staging` Postgres project instead of provisioning their own. That pattern has
170its own walk-through in [Shared database across stages](/cloudflare/data/shared-database).
171:::
172
173## 3. The backend RPC Worker
174
175`Cloudflare.Workers.RpcWorker` takes the `RpcGroup` directly in props and serves it.
176We pass `workersDev: false` because this backend is only ever reached through the
177private service binding — there's no need for a public `workers.dev` URL. Inside
178the constructor we bind Hyperdrive once and build a Drizzle client; the handlers then run a
179query per procedure. Database failures are unexpected, so we `Effect.orDie` them
180into defects — that keeps each handler's typed error channel aligned with its RPC
181schema (`never` for list/create, `TodoNotFound` for toggle/delete).
182
183Note the pipe order for `toggleTodo`/`deleteTodo`: `Effect.orDie` comes **before**
184the `flatMap`, so only the database errors become defects. The `TodoNotFound`
185we raise inside the `flatMap` stays a normal, typed failure the client can catch.
186
187```typescript
188// src/backend/api.ts
189import * as Cloudflare from "alchemy/Cloudflare";
190import * as Drizzle from "alchemy/Drizzle/Postgres";
191import { eq } from "drizzle-orm";
192import * as Effect from "effect/Effect";
193import * as Layer from "effect/Layer";
194import { RpcSerialization, RpcServer } from "effect/rpc";
195import { Hyperdrive } from "./database.ts";
196import { Todo, TodoNotFound, TodoRpcs } from "./rpc.ts";
197import { relations, Todos } from "./schema.ts";
198
199export default class Backend extends Cloudflare.Workers.RpcWorker<Backend>()(
200 "Backend",
201 { main: import.meta.filename, workersDev: false, schema: TodoRpcs },
202 Effect.gen(function* () {
203 const conn = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive);
204 const db = yield* Drizzle.Postgres(conn.connectionString, { relations });
205
206 const handlers = TodoRpcs.toLayer({
207 listTodos: () =>
208 db
209 .select()
210 .from(Todos)
211 .orderBy(Todos.id)
212 .pipe(
213 Effect.map((rows) => rows.map((row) => new Todo(row))),
214 Effect.orDie,
215 ),
216
217 createTodo: ({ text }) =>
218 db
219 .insert(Todos)
220 .values({ text })
221 .returning()
222 .pipe(
223 Effect.map(([row]) => new Todo(row)),
224 Effect.orDie,
225 ),
226
227 toggleTodo: ({ id, done }) =>
228 db
229 .update(Todos)
230 .set({ done })
231 .where(eq(Todos.id, id))
232 .returning()
233 .pipe(
234 Effect.orDie, // DB errors -> defects; TodoNotFound below stays a failure
235 Effect.flatMap(([row]) =>
236 row
237 ? Effect.succeed(new Todo(row))
238 : new TodoNotFound({ message: `Todo ${id} not found`, id }),
239 ),
240 ),
241
242 deleteTodo: ({ id }) =>
243 db
244 .delete(Todos)
245 .where(eq(Todos.id, id))
246 .returning()
247 .pipe(
248 Effect.orDie,
249 Effect.flatMap(([row]) =>
250 row
251 ? Effect.succeed(row.id)
252 : new TodoNotFound({ message: `Todo ${id} not found`, id }),
253 ),
254 ),
255 });
256
257 return RpcServer.toHttpEffect(TodoRpcs).pipe(
258 Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerJson)),
259 );
260 }).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)),
261) {}
262```
263
264A few things worth calling out:
265
266- `new Todo(row)` works because the Drizzle row's shape matches the `Todo`
267 schema (including `createdAt: Schema.Date`). The class instance is then
268 encoded by the RPC server and decoded back into a `Todo` on the client.
269- A missing row yields `new TodoNotFound({ message, id })` — a yieldable tagged
270 error — which the client receives as a typed value, not an HTTP status code.
271- The serialization (`RpcSerialization.layerJson`) **must** match the client.
272
273## 4. Wire it into the Stack
274
275The frontend is a `Cloudflare.Website.Vite` Worker. The backend is injected as a
276private `BACKEND` service binding via `env`, exactly like binding any other
277resource into an SSR Worker (see [Frontend frameworks](/cloudflare/frontend/frontends)):
278
279```typescript
280// alchemy.run.ts
281import * as Alchemy from "alchemy";
282import * as Cloudflare from "alchemy/Cloudflare";
283import * as Drizzle from "alchemy/Drizzle";
284import * as Neon from "alchemy/Neon";
285import * as Effect from "effect/Effect";
286import * as Layer from "effect/Layer";
287import Backend from "./src/backend/api.ts";
288import { Hyperdrive, NeonDatabase } from "./src/backend/database.ts";
289
290export class Website extends Cloudflare.Website.Vite<Website>()("Website", {
291 compatibility: { flags: ["enable_request_signal"] },
292 env: { BACKEND: Backend },
293}) {}
294
295export type WebsiteEnv = Cloudflare.InferEnv<typeof Website>;
296
297export default Alchemy.Stack(
298 "CloudflareTanstackRpcDrizzleExample",
299 {
300 providers: Layer.mergeAll(
301 Cloudflare.providers(),
302 Drizzle.providers(),
303 Neon.providers(),
304 ),
305 state: Alchemy.localState(),
306 },
307 Effect.gen(function* () {
308 const { branch } = yield* NeonDatabase;
309 const hd = yield* Hyperdrive;
310 const website = yield* Website;
311
312 // `Backend` doesn't need to be yielded here: the `BACKEND` service binding
313 // on `Website` already pulls it into the resource graph, and it has no
314 // public URL of its own (`workersDev: false`), so there's nothing to return.
315
316 return {
317 websiteUrl: website.url.as<string>(),
318 branchId: branch.branchId,
319 hyperdriveId: hd.hyperdriveId,
320 };
321 }),
322);
323```
324
325`WebsiteEnv` is the typed environment for the frontend Worker. The `/rpc` route
326reads `env.BACKEND` from it.
327
328## 5. The `/rpc` proxy route
329
330The browser can't open a service binding, so a TanStack Start server route
331forwards RPC traffic to the backend. An `ANY` handler passes whatever
332method/body the RPC protocol sends straight through to the binding — the
333incoming `Request` is forwarded as-is:
334
335```typescript
336// src/routes/rpc.ts
337import { createFileRoute } from "@tanstack/react-router";
338import { env } from "cloudflare:workers";
339
340export const Route = createFileRoute("/rpc")({
341 server: {
342 handlers: {
343 ANY: async ({ request }) => env.BACKEND.fetch(request),
344 },
345 },
346});
347```
348
349To type `env.BACKEND`, augment the `cloudflare:workers` module with `WebsiteEnv`
350in an ambient declaration:
351
352```typescript
353// src/env.d.ts
354import type { WebsiteEnv } from "../alchemy.run.ts";
355
356declare module "cloudflare:workers" {
357 namespace Cloudflare {
358 interface Env extends WebsiteEnv {}
359 }
360}
361```
362
363## 6. The atom RPC client
364
365Effect 4 ships atom RPC in core — there's no third-party `@effect-atom` package
366to add (that one targets Effect 3). `AtomRpc.Service` turns the shared
367`RpcGroup` into a client whose `.query()` and `.mutation()` methods return
368**atoms**. The transport is a plain HTTP client over `fetch`, pointed at the
369same-origin `/rpc` route:
370
371```typescript
372// src/rpc-client.ts
373import * as Layer from "effect/Layer";
374import * as FetchHttpClient from "effect/http/FetchHttpClient";
375import * as AtomRpc from "effect/reactivity/AtomRpc";
376import { RpcClient, RpcSerialization } from "effect/rpc";
377import { TodoRpcs } from "./backend/rpc.ts";
378
379export class TodoClient extends AtomRpc.Service<TodoClient>()("TodoClient", {
380 group: TodoRpcs,
381 protocol: RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
382 Layer.provide(FetchHttpClient.layer),
383 Layer.provide(RpcSerialization.layerJson),
384 ),
385}) {}
386
387// Created once at module scope so their identity is stable across renders.
388export const listTodosAtom = TodoClient.query("listTodos", undefined, {
389 reactivityKeys: ["todos"],
390});
391export const createTodoAtom = TodoClient.mutation("createTodo");
392export const toggleTodoAtom = TodoClient.mutation("toggleTodo");
393export const deleteTodoAtom = TodoClient.mutation("deleteTodo");
394```
395
396The `reactivityKeys: ["todos"]` on the query is the key to reactivity: when a
397mutation runs with a matching key (step 8), the list query is invalidated and
398refetched automatically — no manual cache busting.
399
400:::note
401`RpcSerialization.layerJson` here must match the backend's serialization from
402step 3. Mismatch the codecs and the wire won't decode.
403:::
404
405## 7. Provide a registry
406
407Atoms resolve against an `AtomRegistry`. `@effect/atom-react` — versioned in
408lockstep with `effect`, so no version juggling — provides `RegistryProvider`.
409Wrap it once at the root:
410
411```tsx
412// src/routes/__root.tsx
413import { RegistryProvider } from "@effect/atom-react";
414import { Outlet, createRootRoute } from "@tanstack/react-router";
415
416export const Route = createRootRoute({ component: RootComponent });
417
418function RootComponent() {
419 return (
420 // One AtomRegistry for the whole app — every query/mutation atom
421 // resolves against it.
422 <RegistryProvider>
423 <Outlet />
424 </RegistryProvider>
425 );
426}
427```
428
429## 8. The UI
430
431`useAtomValue` subscribes to the list query and re-renders as its
432`AsyncResult` moves through waiting → success. `useAtomSet` turns a mutation
433atom into a setter; calling it with `reactivityKeys: ["todos"]` invalidates the
434list:
435
436```tsx
437// src/routes/index.tsx
438import { useAtomSet, useAtomValue } from "@effect/atom-react";
439import * as AsyncResult from "effect/reactivity/AsyncResult";
440import { useState } from "react";
441import {
442 createTodoAtom,
443 deleteTodoAtom,
444 listTodosAtom,
445 toggleTodoAtom,
446} from "../rpc-client.ts";
447
448function TodoForm() {
449 const createTodo = useAtomSet(createTodoAtom);
450 const [text, setText] = useState("");
451
452 return (
453 <form
454 onSubmit={(e) => {
455 e.preventDefault();
456 const value = text.trim();
457 if (!value) return;
458 createTodo({ payload: { text: value }, reactivityKeys: ["todos"] });
459 setText("");
460 }}
461 >
462 <input value={text} onChange={(e) => setText(e.target.value)} />
463 <button type="submit">Add</button>
464 </form>
465 );
466}
467
468function TodoList() {
469 const atom = useAtomValue(listTodosAtom);
470 const toggleTodo = useAtomSet(toggleTodoAtom);
471 const deleteTodo = useAtomSet(deleteTodoAtom);
472
473 const todos = AsyncResult.getOrElse(atom, () => []);
474
475 if (AsyncResult.isWaiting(atom) && !todos.length) {
476 return <p>Loading todos…</p>;
477 }
478
479 return (
480 <ul>
481 {todos.map((todo) => (
482 <li key={todo.id}>
483 <input
484 type="checkbox"
485 checked={todo.done}
486 onChange={() =>
487 toggleTodo({
488 payload: { id: todo.id, done: !todo.done },
489 reactivityKeys: ["todos"],
490 })
491 }
492 />
493 <span>{todo.text}</span>
494 <button
495 type="button"
496 onClick={() =>
497 deleteTodo({ payload: { id: todo.id }, reactivityKeys: ["todos"] })
498 }
499 >
500 Delete
501 </button>
502 </li>
503 ))}
504 </ul>
505 );
506}
507```
508
509`todos` are decoded `Todo` instances — the same class the backend constructed
510from a Postgres row, round-tripped through the shared `Schema` codec. No
511hand-written DTOs, no `fetch` URLs, no response parsing.
512
513## 9. Deploy
514
515Deploying provisions the Neon branch, runs the generated migrations, creates the
516Hyperdrive pool, and uploads both Workers. Configure [Cloudflare](/cloudflare/setup/)
517and [Neon](/neon/setup) credentials first with `alchemy profile edit` (or an
518`.env` file in CI). A deploy with no credentials fails and prints the command
519to run.
520
521```sh
522bun install
523bun alchemy deploy
524```
525
526Open the `websiteUrl` from the deploy output and add a few todos — each
527checkbox toggle and delete round-trips through the full stack and the list
528refreshes itself.
529
530## Recap
531
532- One `RpcGroup` (`TodoRpcs`) is the single source of truth — served by the
533 backend, consumed by the browser, typed end to end.
534- The **backend** is a `Cloudflare.Workers.RpcWorker` running Drizzle over a Neon branch
535 via Hyperdrive; DB errors `orDie` into defects so typed channels match the RPC
536 schemas.
537- The **frontend** is a `Cloudflare.Website.Vite` Worker that binds the backend
538 privately and exposes a same-origin `/rpc` proxy.
539- The **browser** uses Effect 4's native `AtomRpc` (`effect/reactivity`)
540 plus `@effect/atom-react` hooks; `reactivityKeys` wire mutations to refetch
541 the affected query.
542
543## Where to go next
544
545- [Effect RPC](/cloudflare/apis/effect-rpc) — the RPC server/client model in depth,
546 including streaming and Durable Object backends.
547- [Frontend frameworks](/cloudflare/frontend/frontends) — `Cloudflare.Website.Vite` and
548 `Cloudflare.Website.StaticSite` for every framework.
549- [Shared database across stages](/cloudflare/data/shared-database) — the `pr-*` branch
550 referencing pattern used in `database.ts`.