Skip to content

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.Connection pool, with migrations generated by Drizzle.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.

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 branch

The 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.

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.

src/backend/rpc.ts
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.

The Drizzle table is ordinary Postgres schema:

src/backend/schema.ts
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:

src/backend/database.ts
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 },
});
});

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.

src/backend/api.ts
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 the Todo schema (including createdAt: Schema.Date). The class instance is then encoded by the RPC server and decoded back into a Todo on 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.

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):

alchemy.run.ts
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.

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:

src/routes/rpc.ts
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:

src/env.d.ts
import type { WebsiteEnv } from "../alchemy.run.ts";
declare module "cloudflare:workers" {
namespace Cloudflare {
interface Env extends WebsiteEnv {}
}
}

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:

src/rpc-client.ts
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.

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:

src/routes/__root.tsx
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>
);
}

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:

src/routes/index.tsx
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.

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.

Terminal window
bun install
bun alchemy deploy

Open 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.RpcWorker running Drizzle over a Neon branch via Hyperdrive; DB errors orDie into defects so typed channels match the RPC schemas.
  • The frontend is a Cloudflare.Website.Vite Worker that binds the backend privately and exposes a same-origin /rpc proxy.
  • The browser uses Effect 4’s native AtomRpc (effect/unstable/reactivity) plus @effect/atom-react hooks; reactivityKeys wire mutations to refetch the affected query.
  • Effect RPC — the RPC server/client model in depth, including streaming and Durable Object backends.
  • Frontend frameworksCloudflare.Website.Vite and Cloudflare.Website.StaticSite for every framework.
  • Shared database across stages — the pr-* branch referencing pattern used in database.ts.