Skip to content

Full-stack TanStack Start + RPC + Drizzle

TanStack Start · Effect RPC · Drizzle + DSQL

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 cluster

The browser calls a same-origin /rpc route; the frontend Lambda forwards requests to the backend.

Terminal window
bun add @distilled.cloud/aws @effect/atom-react @effect/sql-pg drizzle-orm effect pg
bun add -d @alchemy.run/frontend-frameworks

Share the RPC contract between the backend and browser:

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.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,
}),
) {}

RPC contracts and errors

Copy database.ts, bootstrap.ts, and client.ts from the DSQL example into src/backend/. Database setup.

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.

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.

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

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

Forward POST requests to the backend:

src/routes/rpc.ts
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(),
}),
},
},
});

Create the browser client with Effect 4’s AtomRpc:

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");

Mutations with reactivityKeys: ["todos"] invalidate and refetch the list.

Wrap the application in RegistryProvider:

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>
);
}

Read the query with useAtomValue; call mutations with useAtomSet:

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>
);
}

AWS setup

Terminal window
bun alchemy deploy

Open websiteUrl to create, toggle, and delete todos.

Terminal window
bun alchemy dev

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

Effect RPC · TanStack Start · Drizzle + DSQL · Drizzle queries · RDS & Aurora