Full-stack TanStack Start + RPC + Drizzle
TanStack Start · Effect RPC · Drizzle + DSQL
The shape
Section titled “The shape”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 calls a same-origin /rpc route; the frontend Lambda forwards requests to the backend.
Install
Section titled “Install”bun add @distilled.cloud/aws @effect/atom-react @effect/sql-pg drizzle-orm effect pgbun add -d @alchemy.run/frontend-frameworksnpm install @distilled.cloud/aws @effect/atom-react @effect/sql-pg drizzle-orm effect pgnpm install -D @alchemy.run/frontend-frameworkspnpm add @distilled.cloud/aws @effect/atom-react @effect/sql-pg drizzle-orm effect pgpnpm add -D @alchemy.run/frontend-frameworksyarn add @distilled.cloud/aws @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”Share the RPC contract between the backend and browser:
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, }),) {}2. The database
Section titled “2. The database”Copy database.ts, bootstrap.ts, and client.ts from the DSQL example into src/backend/. Database setup.
3. The table
Section titled “3. The table”Copy the example’s schema.ts into src/backend/. It targets app.todos with application-generated UUIDs, matching the bootstrap and role grants. Check DSQL compatibility before extending it.
4. The backend RPC Lambda
Section titled “4. The backend RPC Lambda”connectDatabase uses the non-admin app_user role with verified TLS. DSQL.Connect grants cluster-scoped dsql:DbConnect and signs IAM tokens locally; Drizzle opens the connection lazily and closes its pool with the invocation scope.
import * as AWS from "alchemy/AWS";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 { connectDatabase } from "./client.ts";import { Todo, TodoNotFound, TodoRpcs } from "./rpc.ts";import { Todos } from "./schema.ts";
export default class Backend extends AWS.Lambda.Function<Backend>()( "Backend", { main: import.meta.url, functionUrl: true, build: { install: ["pg"] }, }, Effect.gen(function* () { const db = yield* connectDatabase;
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: rpc.pipe(Effect.orDie) }; }).pipe(Effect.provide(AWS.DSQL.ConnectHttp)),) {}Effect.orDie handles database failures before flatMap, preserving the typed TodoNotFound failure. Client and server must both use RpcSerialization.layerJson.
5. Wire it into the Stack
Section titled “5. Wire it into the Stack”import * as Alchemy from "alchemy";import * as AWS from "alchemy/AWS";import * as Effect from "effect/Effect";import Backend from "./src/backend/api.ts";import { BootstrapDatabase } from "./src/backend/bootstrap.ts";import { Database } from "./src/backend/database.ts";
export default Alchemy.Stack( "AwsTanstackRpcDrizzle", { providers: AWS.providers(), state: AWS.state(), }, Effect.gen(function* () { const backend = yield* Backend.pipe(Alchemy.remote()); const cluster = yield* Database; const schemaVersion = yield* BootstrapDatabase({ endpoint: cluster.endpoint, roleArn: backend.roleArn, version: "1", });
const site = yield* AWS.Website.TanStackStart("Website", { env: { BACKEND_URL: backend.functionUrl }, });
return { websiteUrl: site.url, backendUrl: backend.functionUrl, schemaVersion, }; }),);BootstrapDatabase initializes the schema. Alchemy.remote() keeps the backend on AWS during local development.
6. The /rpc proxy route
Section titled “6. The /rpc proxy route”Forward POST requests to the backend:
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(), }), }, },});7. The atom RPC client
Section titled “7. The atom RPC client”Create the browser client with Effect 4’s AtomRpc:
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");Mutations with reactivityKeys: ["todos"] invalidate and refetch the list.
8. Provide a registry
Section titled “8. Provide a registry”Wrap the application in RegistryProvider:
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”Read the query with useAtomValue; call mutations with useAtomSet:
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> );}10. Deploy
Section titled “10. Deploy”bun alchemy deployOpen websiteUrl to create, toggle, and delete todos.
Local dev
Section titled “Local dev”bun alchemy devThe frontend runs locally; the backend Lambda and DSQL remain on AWS and incur charges.
TodoRpcs defines the API; the frontend proxies /rpc; mutation keys refresh the list.
Where to go next
Section titled “Where to go next”Effect RPC · TanStack Start · Drizzle + DSQL · Drizzle queries · RDS & Aurora