Full-stack TanStack Start + RPC + Drizzle
This guide ties four pieces into one deployable app:
- TanStack Start — the React frontend, deployed as a
Cloudflare Worker + assets via
Cloudflare.Website.Vite. - Effect RPC — a typed backend served by a separate
Cloudflare.Workers.RpcWorker. - Drizzle + Neon Postgres — reached through a
Cloudflare.Hyperdrive.Connectionpool, with migrations generated byDrizzle.Schema. - Atom RPC — Effect 4’s native
effect/unstable/reactivity/AtomRpc, plus the React bindings from@effect/atom-react, for reactive queries and mutations in the browser.
The full project lives in
examples/cloudflare-tanstack-rpc-drizzle.
We’ll build a Todo app and follow a single value — a Todo — from the
Postgres row all the way to a checkbox in the browser.
The shape
Section titled “The shape”Data flows through five hops, and one RpcGroup value pins the types at every
boundary:
Browser (React) │ useAtomValue / useAtomSet (src/routes/index.tsx) ▼AtomRpc client (TodoRpcs) (src/rpc-client.ts) │ HTTP POST /rpc (JSON) ▼TanStack Start worker — /rpc proxy (src/routes/rpc.ts) │ env.BACKEND.fetch(...) (private service binding) ▼Backend RpcWorker (TodoRpcs) (src/backend/api.ts) │ Drizzle.postgres over Hyperdrive ▼Neon Postgres branchThe browser can’t talk to a Cloudflare service binding directly, so the atom
client posts to a same-origin /rpc route and the frontend Worker forwards the
request to the private backend over its service binding. The backend stays off
the public internet and there’s no CORS to configure.
1. The shared RPC contract
Section titled “1. The shared RPC contract”Everything starts from one module imported by both ends — the backend that
serves the procedures and the browser client that calls them. One Schema
codec round-trips every value, so the React UI is typed against the exact
shapes the Postgres-backed handlers return.
import * as Schema from "effect/Schema";import { Rpc, RpcGroup } from "effect/unstable/rpc";
export class Todo extends Schema.Class<Todo>("Todo")({ id: Schema.Number, text: Schema.String, done: Schema.Boolean, createdAt: Schema.Date,}) {}
export class TodoNotFound extends Schema.TaggedErrorClass<TodoNotFound>()( "TodoNotFound", { message: Schema.String, id: Schema.Number },) {}
export class TodoRpcs extends RpcGroup.make( Rpc.make("listTodos", { success: Schema.Array(Todo) }), Rpc.make("createTodo", { payload: { text: Schema.String }, success: Todo }), Rpc.make("toggleTodo", { payload: { id: Schema.Number, done: Schema.Boolean }, success: Todo, error: TodoNotFound, }), Rpc.make("deleteTodo", { payload: { id: Schema.Number }, success: Schema.Number, error: TodoNotFound, }),) {}TodoRpcs is a plain value-level description — nothing executes yet. See the
Effect RPC guide for a deeper tour of Rpc.make and
schema-backed errors.
2. The database
Section titled “2. The database”The Drizzle table is ordinary Postgres schema:
import { defineRelations } from "drizzle-orm";import { boolean, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
export const Todos = pgTable("todos", { id: serial("id").primaryKey(), text: text("text").notNull(), done: boolean("done").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(),});
export const relations = defineRelations({ Todos }, () => ({}));Drizzle.Schema turns that table into migration SQL at deploy time, and
Neon.Branch applies any pending migrations transactionally before the workers
go live. A Cloudflare.Hyperdrive.Connection pool sits in front of the branch so the
Worker reaches Postgres through Cloudflare’s connection pooler:
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Drizzle from "alchemy/Drizzle";import * as Neon from "alchemy/Neon";import * as Effect from "effect/Effect";
export const NeonDatabase = Effect.gen(function* () { const { stage } = yield* Alchemy.Stack;
const schema = yield* Drizzle.Schema("Schema", { schema: "./src/backend/schema.ts", out: "./migrations", });
const project = stage.startsWith("pr-") ? yield* Neon.Project.ref("Database", { stage: `staging-${stage}` }) : yield* Neon.Project("Database", { region: "aws-us-east-1" });
const branch = yield* Neon.Branch("Branch", { project, migrationsDir: schema.out, });
return { project, branch, schema };});
export const Hyperdrive = Effect.gen(function* () { const { branch } = yield* NeonDatabase; return yield* Cloudflare.Hyperdrive.Connection("Hyperdrive", { origin: branch.origin, caching: { disabled: true }, });});3. The backend RPC Worker
Section titled “3. The backend RPC Worker”Cloudflare.Workers.RpcWorker takes the RpcGroup directly in props and serves it.
We pass url: false because this backend is only ever reached through the
private service binding — there’s no need for a public workers.dev URL. Inside
Init we bind Hyperdrive once and build a Drizzle client; the handlers then run a
query per procedure. Database failures are unexpected, so we Effect.orDie them
into defects — that keeps each handler’s typed error channel aligned with its RPC
schema (never for list/create, TodoNotFound for toggle/delete).
Note the pipe order for toggleTodo/deleteTodo: Effect.orDie comes before
the flatMap, so only the database errors become defects. The TodoNotFound
we raise inside the flatMap stays a normal, typed failure the client can catch.
import * as Cloudflare from "alchemy/Cloudflare";import * as Drizzle from "alchemy/Drizzle";import { eq } from "drizzle-orm";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import { RpcSerialization, RpcServer } from "effect/unstable/rpc";import { Hyperdrive } from "./database.ts";import { Todo, TodoNotFound, TodoRpcs } from "./rpc.ts";import { relations, Todos } from "./schema.ts";
export default class Backend extends Cloudflare.Workers.RpcWorker<Backend>()( "Backend", { main: import.meta.filename, url: false, schema: TodoRpcs }, Effect.gen(function* () { const conn = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive); const db = yield* Drizzle.postgres(conn.connectionString, { relations });
const handlers = TodoRpcs.toLayer({ listTodos: () => db .select() .from(Todos) .orderBy(Todos.id) .pipe( Effect.map((rows) => rows.map((row) => new Todo(row))), Effect.orDie, ),
createTodo: ({ text }) => db .insert(Todos) .values({ text }) .returning() .pipe( Effect.map(([row]) => new Todo(row)), Effect.orDie, ),
toggleTodo: ({ id, done }) => db .update(Todos) .set({ done }) .where(eq(Todos.id, id)) .returning() .pipe( Effect.orDie, // DB errors -> defects; TodoNotFound below stays a failure Effect.flatMap(([row]) => row ? Effect.succeed(new Todo(row)) : new TodoNotFound({ message: `Todo ${id} not found`, id }), ), ),
deleteTodo: ({ id }) => db .delete(Todos) .where(eq(Todos.id, id)) .returning() .pipe( Effect.orDie, Effect.flatMap(([row]) => row ? Effect.succeed(row.id) : new TodoNotFound({ message: `Todo ${id} not found`, id }), ), ), });
return RpcServer.toHttpEffect(TodoRpcs).pipe( Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerJson)), ); }).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)),) {}A few things worth calling out:
new Todo(row)works because the Drizzle row’s shape matches theTodoschema (includingcreatedAt: Schema.Date). The class instance is then encoded by the RPC server and decoded back into aTodoon the client.- A missing row yields
new TodoNotFound({ message, id })— a yieldable tagged error — which the client receives as a typed value, not an HTTP status code. - The serialization (
RpcSerialization.layerJson) must match the client.
4. Wire it into the Stack
Section titled “4. Wire it into the Stack”The frontend is a Cloudflare.Website.Vite Worker. The backend is injected as a
private BACKEND service binding via env, exactly like binding any other
resource into an SSR Worker (see Frontend frameworks):
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Drizzle from "alchemy/Drizzle";import * as Neon from "alchemy/Neon";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import Backend from "./src/backend/api.ts";import { Hyperdrive, NeonDatabase } from "./src/backend/database.ts";
export class Website extends Cloudflare.Website.Vite<Website>()("Website", { compatibility: { flags: ["nodejs_compat", "enable_request_signal"] }, env: { BACKEND: Backend }, assets: { runWorkerFirst: true },}) {}
export type WebsiteEnv = Cloudflare.InferEnv<typeof Website>;
export default Alchemy.Stack( "CloudflareTanstackRpcDrizzleExample", { providers: Layer.mergeAll( Cloudflare.providers(), Drizzle.providers(), Neon.providers(), ), state: Alchemy.localState(), }, Effect.gen(function* () { const { branch } = yield* NeonDatabase; const hd = yield* Hyperdrive; const website = yield* Website;
// `Backend` doesn't need to be yielded here: the `BACKEND` service binding // on `Website` already pulls it into the resource graph, and it has no // public URL of its own (`url: false`), so there's nothing to return.
return { websiteUrl: website.url.as<string>(), branchId: branch.branchId, hyperdriveId: hd.hyperdriveId, }; }),);WebsiteEnv is the typed environment for the frontend Worker. The /rpc route
reads env.BACKEND from it.
5. The /rpc proxy route
Section titled “5. The /rpc proxy route”The browser can’t open a service binding, so a TanStack Start server route
forwards RPC traffic to the backend. An ANY handler passes whatever
method/body the RPC protocol sends straight through to the binding — the
incoming Request is forwarded as-is:
import { createFileRoute } from "@tanstack/react-router";import { env } from "cloudflare:workers";
export const Route = createFileRoute("/rpc")({ server: { handlers: { ANY: async ({ request }) => env.BACKEND.fetch(request), }, },});To type env.BACKEND, augment the cloudflare:workers module with WebsiteEnv
in an ambient declaration:
import type { WebsiteEnv } from "../alchemy.run.ts";
declare module "cloudflare:workers" { namespace Cloudflare { interface Env extends WebsiteEnv {} }}6. The atom RPC client
Section titled “6. The atom RPC client”Effect 4 ships atom RPC in core — there’s no third-party @effect-atom package
to add (that one targets Effect 3). AtomRpc.Service turns the shared
RpcGroup into a client whose .query() and .mutation() methods return
atoms. The transport is a plain HTTP client over fetch, pointed at the
same-origin /rpc route:
import * as Layer from "effect/Layer";import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";import * as AtomRpc from "effect/unstable/reactivity/AtomRpc";import { RpcClient, RpcSerialization } from "effect/unstable/rpc";import { TodoRpcs } from "./backend/rpc.ts";
export class TodoClient extends AtomRpc.Service<TodoClient>()("TodoClient", { group: TodoRpcs, protocol: RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe( Layer.provide(FetchHttpClient.layer), Layer.provide(RpcSerialization.layerJson), ),}) {}
// Created once at module scope so their identity is stable across renders.export const listTodosAtom = TodoClient.query("listTodos", undefined, { reactivityKeys: ["todos"],});export const createTodoAtom = TodoClient.mutation("createTodo");export const toggleTodoAtom = TodoClient.mutation("toggleTodo");export const deleteTodoAtom = TodoClient.mutation("deleteTodo");The reactivityKeys: ["todos"] on the query is the key to reactivity: when a
mutation runs with a matching key (step 8), the list query is invalidated and
refetched automatically — no manual cache busting.
7. Provide a registry
Section titled “7. Provide a registry”Atoms resolve against an AtomRegistry. @effect/atom-react — versioned in
lockstep with effect, so no version juggling — provides RegistryProvider.
Wrap it once at the root:
import { RegistryProvider } from "@effect/atom-react";import { Outlet, createRootRoute } from "@tanstack/react-router";
export const Route = createRootRoute({ component: RootComponent });
function RootComponent() { return ( // One AtomRegistry for the whole app — every query/mutation atom // resolves against it. <RegistryProvider> <Outlet /> </RegistryProvider> );}8. The UI
Section titled “8. The UI”useAtomValue subscribes to the list query and re-renders as its
AsyncResult moves through waiting → success. useAtomSet turns a mutation
atom into a setter; calling it with reactivityKeys: ["todos"] invalidates the
list:
import { useAtomSet, useAtomValue } from "@effect/atom-react";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";import { useState } from "react";import { createTodoAtom, deleteTodoAtom, listTodosAtom, toggleTodoAtom,} from "../rpc-client.ts";
function TodoForm() { const createTodo = useAtomSet(createTodoAtom); const [text, setText] = useState("");
return ( <form onSubmit={(e) => { e.preventDefault(); const value = text.trim(); if (!value) return; createTodo({ payload: { text: value }, reactivityKeys: ["todos"] }); setText(""); }} > <input value={text} onChange={(e) => setText(e.target.value)} /> <button type="submit">Add</button> </form> );}
function TodoList() { const atom = useAtomValue(listTodosAtom); const toggleTodo = useAtomSet(toggleTodoAtom); const deleteTodo = useAtomSet(deleteTodoAtom);
const todos = AsyncResult.getOrElse(atom, () => []);
if (AsyncResult.isWaiting(atom) && !todos.length) { return <p>Loading todos…</p>; }
return ( <ul> {todos.map((todo) => ( <li key={todo.id}> <input type="checkbox" checked={todo.done} onChange={() => toggleTodo({ payload: { id: todo.id, done: !todo.done }, reactivityKeys: ["todos"], }) } /> <span>{todo.text}</span> <button type="button" onClick={() => deleteTodo({ payload: { id: todo.id }, reactivityKeys: ["todos"] }) } > Delete </button> </li> ))} </ul> );}todos are decoded Todo instances — the same class the backend constructed
from a Postgres row, round-tripped through the shared Schema codec. No
hand-written DTOs, no fetch URLs, no response parsing.
9. Deploy
Section titled “9. Deploy”Deploying provisions the Neon branch, runs the generated migrations, creates the
Hyperdrive pool, and uploads both Workers. If you haven’t configured Cloudflare
and Neon credentials already (e.g. using alchemy login or an .env file),
Alchemy will guide you through this process during your first deploy.
bun installbun alchemy deployOpen the websiteUrl from the deploy output and add a few todos — each
checkbox toggle and delete round-trips through the full stack and the list
refreshes itself.
- One
RpcGroup(TodoRpcs) is the single source of truth — served by the backend, consumed by the browser, typed end to end. - The backend is a
Cloudflare.Workers.RpcWorkerrunning Drizzle over a Neon branch via Hyperdrive; DB errorsorDieinto defects so typed channels match the RPC schemas. - The frontend is a
Cloudflare.Website.ViteWorker that binds the backend privately and exposes a same-origin/rpcproxy. - The browser uses Effect 4’s native
AtomRpc(effect/unstable/reactivity) plus@effect/atom-reacthooks;reactivityKeyswire mutations to refetch the affected query.
Where to go next
Section titled “Where to go next”- Effect RPC — the RPC server/client model in depth, including streaming and Durable Object backends.
- Frontend frameworks —
Cloudflare.Website.ViteandCloudflare.Website.StaticSitefor every framework. - Shared database across stages — the
pr-*branch referencing pattern used indatabase.ts.