Full-stack TanStack Start + RPC + Drizzle
This guide ties four pieces into one deployable app:
- TanStack Start — the React frontend,
deployed as a streaming Lambda Function URL behind CloudFront via
AWS.Website.TanStackStart. - Effect RPC — a typed backend served by a separate
AWS.Lambda.Function. - Drizzle + Aurora DSQL — reached through the
AWS.DSQL.Connectbinding, which mints an IAM auth token per invocation. - 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.
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 Lambda — /rpc proxy (src/routes/rpc.ts) │ fetch(process.env.BACKEND_URL) ▼Backend RPC Lambda (TodoRpcs) (src/backend/api.ts) │ Drizzle.Postgres over DSQL.Connect ▼Aurora DSQL clusterThe browser posts to a same-origin /rpc route and the frontend Lambda
forwards the body to the backend’s Function URL. Same origin means no CORS to
configure, and the browser never learns the backend’s address.
Install
Section titled “Install”The TanStack Start build integration is a dev dependency; everything else runs in the Lambda:
bun add @effect/atom-react @effect/sql-pg drizzle-orm effect pgbun add -d @alchemy.run/frontend-frameworksnpm install @effect/atom-react @effect/sql-pg drizzle-orm effect pgnpm install -D @alchemy.run/frontend-frameworkspnpm add @effect/atom-react @effect/sql-pg drizzle-orm effect pgpnpm add -D @alchemy.run/frontend-frameworksyarn add @effect/atom-react @effect/sql-pg drizzle-orm effect pgyarn add -D @alchemy.run/frontend-frameworks1. 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.String, text: Schema.String, done: Schema.Boolean, createdAt: Schema.Date,}) {}
export class TodoNotFound extends Schema.TaggedError<TodoNotFound>()( "TodoNotFound", { message: Schema.String, id: Schema.String },) {}
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.String, done: Schema.Boolean }, success: Todo, error: TodoNotFound, }), Rpc.make("deleteTodo", { payload: { id: Schema.String }, success: Schema.String, 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”Aurora DSQL is AWS’s serverless, Postgres wire-compatible database. It needs no VPC, no instance class, and no password — its endpoint is public and gated by IAM.
import * as AWS from "alchemy/AWS";
export const Database = AWS.DSQL.Cluster("Database", {});3. The table
Section titled “3. The table”DSQL drops two Postgres features this schema would otherwise reach for: there
are no sequences (so no serial) and no foreign keys. A uuid primary key
generated in the handler stays well inside the supported surface:
import { defineRelations } from "drizzle-orm";import { boolean, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
export const Todos = pgTable("todos", { id: uuid("id").primaryKey(), text: text("text").notNull(), done: boolean("done").notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull(),});
export const relations = defineRelations({ Todos }, () => ({}));4. The backend RPC Lambda
Section titled “4. The backend RPC Lambda”AWS.DSQL.Connect does both halves of the database capability: at deploy time
it grants dsql:DbConnectAdmin on the cluster to this function’s execution
role, and at runtime it mints a short-lived IAM auth token and hands back a
connection URL. Drizzle.Postgres takes that URL and builds its pool lazily,
per invocation — a ~15-minute token can never outlive its pool.
import * as AWS from "alchemy/AWS";import * as Drizzle from "alchemy/Drizzle/Postgres";import { eq, sql } from "drizzle-orm";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { RpcSerialization, RpcServer } from "effect/unstable/rpc";import { Database } from "./database.ts";import { Todo, TodoNotFound, TodoRpcs } from "./rpc.ts";import { relations, Todos } from "./schema.ts";
export default class Backend extends AWS.Lambda.Function<Backend>()( "Backend", { main: import.meta.url, functionUrl: true, // `pg` is CommonJS — install it intact in the artifact so @effect/sql-pg // loads it with Node's CJS semantics instead of a bundled namespace. build: { install: ["pg"] }, }, Effect.gen(function* () { const cluster = yield* Database; const conn = yield* AWS.DSQL.Connect(cluster, { admin: true }); const db = yield* Drizzle.Postgres( conn.pipe(Effect.map((info) => info.url)), { relations }, );
const handlers = TodoRpcs.toLayer({ listTodos: () => db .select() .from(Todos) .orderBy(Todos.createdAt) .pipe( Effect.map((rows) => rows.map((row) => new Todo(row))), Effect.orDie, ),
createTodo: ({ text }) => db .insert(Todos) .values({ id: crypto.randomUUID(), text, done: false, createdAt: new Date(), }) .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 }), ), ), });
const rpc = yield* RpcServer.toHttpEffect(TodoRpcs).pipe( Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerJson)), );
return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest;
// One-time table bootstrap. DSQL runs DDL as autocommit statements // outside DML transactions, so this is a single `execute`. if (new URL(request.originalUrl).pathname === "/setup") { yield* db.execute(sql` CREATE TABLE IF NOT EXISTS todos ( id uuid PRIMARY KEY, text text NOT NULL, done boolean NOT NULL, created_at timestamptz NOT NULL ) `); return yield* HttpServerResponse.json({ ok: true }); }
return yield* rpc; }).pipe(Effect.orDie), }; }).pipe(Effect.provide(AWS.DSQL.ConnectHttp)),) {}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.- Database failures are unexpected, so
Effect.orDieturns them into defects — that keeps each handler’s typed error channel aligned with its RPC schema (neverfor list/create,TodoNotFoundfor toggle/delete). Note the pipe order:orDiecomes before theflatMap, so theTodoNotFoundraised inside stays a normal, typed failure the client can catch. - The serialization (
RpcSerialization.layerJson) must match the client.
5. Wire it into the Stack
Section titled “5. Wire it into the Stack”The frontend is an AWS.Website.TanStackStart site. The backend’s Function URL
travels to it as a plain environment variable:
import * as Alchemy from "alchemy";import * as AWS from "alchemy/AWS";import * as Effect from "effect/Effect";import Backend from "./src/backend/api.ts";
export default Alchemy.Stack( "AwsTanstackRpcDrizzle", { providers: AWS.providers(), state: AWS.state(), }, Effect.gen(function* () { const backend = yield* Backend;
const site = yield* AWS.Website.TanStackStart("Website", { env: { BACKEND_URL: backend.functionUrl }, });
return { websiteUrl: site.url, backendUrl: backend.functionUrl, }; }),);Yielding Backend deploys the function and everything it declared inside
Init — the DSQL cluster and the execution role with the cluster-scoped
dsql:DbConnectAdmin statement.
6. The /rpc proxy route
Section titled “6. The /rpc proxy route”RpcClient.layerProtocolHttp always POSTs, so one server route handler is
enough. It forwards the body to the backend and returns the response as-is:
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/rpc")({ server: { handlers: { POST: async ({ request }) => fetch(process.env.BACKEND_URL!, { method: "POST", headers: { "content-type": "application/json" }, body: await request.text(), }), }, },});Server routes run on the site’s own Lambda, so they read process.env directly
— the same BACKEND_URL the Stack set under env.
7. The atom RPC client
Section titled “7. 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 9), the list query is invalidated and
refetched automatically — no manual cache busting.
8. Provide a registry
Section titled “8. 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> );}9. The UI
Section titled “9. 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.
10. Deploy
Section titled “10. Deploy”Deploying creates the DSQL cluster, uploads the backend Lambda with its cluster-scoped role, builds the TanStack Start app, and puts the client assets behind CloudFront. If you haven’t configured AWS credentials yet, Alchemy will guide you through it on the first deploy.
bun alchemy deployThen create the table once, using the backendUrl from the deploy output:
curl -X POST "$BACKEND_URL/setup"Open websiteUrl and add a few todos — each checkbox toggle and delete
round-trips through the full stack and the list refreshes itself.
Local dev
Section titled “Local dev”bun alchemy devThe frontend runs TanStack Start’s own Vite dev server with native HMR, and the
backend Lambda runs in a local container behind a working Function URL — see
Local development. DSQL has no local emulation, so
the cluster deploys against real AWS in your personal stage while everything
else stays on your machine. BACKEND_URL is injected into the dev server’s
process environment, so the /rpc route reads the same variable it does in
production.
- One
RpcGroup(TodoRpcs) is the single source of truth — served by the backend, consumed by the browser, typed end to end. - The backend is an
AWS.Lambda.Functionrunning Drizzle over Aurora DSQL viaDSQL.Connect; DB errorsorDieinto defects so typed channels match the RPC schemas. - The frontend is an
AWS.Website.TanStackStartsite whose server route proxies/rpcto the backend’s Function URL. - 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 on Lambda — the RPC server/client model in depth, including a typed standalone client.
- TanStack Start — every prop on the site resource: custom domains, shared routers, build output.
- Drizzle on Postgres — schema modules, generated migrations, and the query API.
- RDS & Aurora — the VPC-based Postgres alternative, with the
Connectbinding and the Data API.