1---
2title: Full-stack TanStack Start + RPC + Drizzle
3description: Build a reactive full-stack app on AWS — a TanStack Start UI on CloudFront and Lambda that drives an Effect RPC Lambda over Drizzle and Aurora DSQL, with browser state wired through Effect 4's native atom RPC.
4sidebar:
5 order: 15
6---
7
8import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
9
10export const deps = `@distilled.cloud/aws @effect/atom-react @effect/sql-pg drizzle-orm effect pg`;
11export const devDeps = `@alchemy.run/frontend-frameworks`;
12
13[TanStack Start](/aws/frontend/tanstack-start) · [Effect RPC](/aws/apis/effect-rpc) · [Drizzle + DSQL](/aws/data/drizzle-dsql)
14
15## The shape
16
17```
18Browser (React)
19 │ useAtomValue / useAtomSet (src/routes/index.tsx)
20 ▼
21AtomRpc client (TodoRpcs) (src/rpc-client.ts)
22 │ HTTP POST /rpc (JSON)
23 ▼
24TanStack Start Lambda — /rpc proxy (src/routes/rpc.ts)
25 │ fetch(process.env.BACKEND_URL)
26 ▼
27Backend RPC Lambda (TodoRpcs) (src/backend/api.ts)
28 │ Drizzle.Postgres over DSQL.Connect
29 ▼
30Aurora DSQL cluster
31```
32
33The browser calls a same-origin `/rpc` route; the frontend Lambda forwards requests to the backend.
34
35## Install
36
37<Tabs syncKey="pkgManager">
38 <TabItem label="bun" icon="bun">
39 <Code code={`bun add ${deps}\nbun add -d ${devDeps}`} lang="sh" />
40 </TabItem>
41 <TabItem label="npm" icon="npm">
42 <Code code={`npm install ${deps}\nnpm install -D ${devDeps}`} lang="sh" />
43 </TabItem>
44 <TabItem label="pnpm" icon="pnpm">
45 <Code code={`pnpm add ${deps}\npnpm add -D ${devDeps}`} lang="sh" />
46 </TabItem>
47 <TabItem label="yarn" icon="seti:yarn">
48 <Code code={`yarn add ${deps}\nyarn add -D ${devDeps}`} lang="sh" />
49 </TabItem>
50</Tabs>
51
52## 1. The shared RPC contract
53
54Share the RPC contract between the backend and browser:
55
56```typescript
57// src/backend/rpc.ts
58import * as Schema from "effect/Schema";
59import { Rpc, RpcGroup } from "effect/rpc";
60
61export class Todo extends Schema.Class<Todo>("Todo")({
62 id: Schema.String,
63 text: Schema.String,
64 done: Schema.Boolean,
65 createdAt: Schema.Date,
66}) {}
67
68export class TodoNotFound extends Schema.TaggedError<TodoNotFound>()(
69 "TodoNotFound",
70 { message: Schema.String, id: Schema.String },
71) {}
72
73export class TodoRpcs extends RpcGroup.make(
74 Rpc.make("listTodos", { success: Schema.Array(Todo) }),
75 Rpc.make("createTodo", { payload: { text: Schema.String }, success: Todo }),
76 Rpc.make("toggleTodo", {
77 payload: { id: Schema.String, done: Schema.Boolean },
78 success: Todo,
79 error: TodoNotFound,
80 }),
81 Rpc.make("deleteTodo", {
82 payload: { id: Schema.String },
83 success: Schema.String,
84 error: TodoNotFound,
85 }),
86) {}
87```
88
89[RPC contracts and errors](/aws/apis/effect-rpc)
90
91## 2. The database
92
93Copy `database.ts`, `bootstrap.ts`, and `client.ts` from the [DSQL example](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-dsql-drizzle/src) into `src/backend/`. [Database setup](/aws/data/drizzle-dsql).
94
95## 3. The table
96
97Copy the example's [`schema.ts`](https://github.com/alchemy-run/alchemy/blob/main/examples/aws-dsql-drizzle/src/schema.ts) into `src/backend/`. It targets `app.todos` with application-generated UUIDs, matching the bootstrap and role grants. Check [DSQL compatibility](/aws/data/drizzle-dsql#define-the-table) before extending it.
98
99:::note
100`AWS.DSQL.Cluster` has no `migrations` prop. The Action in step 5 bootstraps the database without an HTTP `/setup` route; it is not a versioned migrator. Whenever the schema changes, explicitly generate migration files using the [DSQL migration workflow](/aws/data/drizzle-dsql#evolve-the-schema), with schema path `./src/backend/schema.ts`. Stage the schema, config, SQL, and snapshots, review with `git diff --cached`, and commit before release. The migration step consumes committed SQL, not SQL generated during deployment.
101:::
102
103## 4. The backend RPC Lambda
104
105`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.
106
107```typescript
108// src/backend/api.ts
109import * as AWS from "alchemy/AWS";
110import { eq } from "drizzle-orm";
111import * as Effect from "effect/Effect";
112import * as Layer from "effect/Layer";
113import { RpcSerialization, RpcServer } from "effect/rpc";
114import { connectDatabase } from "./client.ts";
115import { Todo, TodoNotFound, TodoRpcs } from "./rpc.ts";
116import { Todos } from "./schema.ts";
117
118export default class Backend extends AWS.Lambda.Function<Backend>()(
119 "Backend",
120 {
121 main: import.meta.url,
122 functionUrl: true,
123 build: { install: ["pg"] },
124 },
125 Effect.gen(function* () {
126 const db = yield* connectDatabase;
127
128 const handlers = TodoRpcs.toLayer({
129 listTodos: () =>
130 db
131 .select()
132 .from(Todos)
133 .orderBy(Todos.createdAt)
134 .pipe(
135 Effect.map((rows) => rows.map((row) => new Todo(row))),
136 Effect.orDie,
137 ),
138
139 createTodo: ({ text }) =>
140 db
141 .insert(Todos)
142 .values({
143 id: crypto.randomUUID(),
144 text,
145 done: false,
146 createdAt: new Date(),
147 })
148 .returning()
149 .pipe(
150 Effect.map(([row]) => new Todo(row)),
151 Effect.orDie,
152 ),
153
154 toggleTodo: ({ id, done }) =>
155 db
156 .update(Todos)
157 .set({ done })
158 .where(eq(Todos.id, id))
159 .returning()
160 .pipe(
161 Effect.orDie, // DB errors -> defects; TodoNotFound below stays a failure
162 Effect.flatMap(([row]) =>
163 row
164 ? Effect.succeed(new Todo(row))
165 : new TodoNotFound({ message: `Todo ${id} not found`, id }),
166 ),
167 ),
168
169 deleteTodo: ({ id }) =>
170 db
171 .delete(Todos)
172 .where(eq(Todos.id, id))
173 .returning()
174 .pipe(
175 Effect.orDie,
176 Effect.flatMap(([row]) =>
177 row
178 ? Effect.succeed(row.id)
179 : new TodoNotFound({ message: `Todo ${id} not found`, id }),
180 ),
181 ),
182 });
183
184 const rpc = yield* RpcServer.toHttpEffect(TodoRpcs).pipe(
185 Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerJson)),
186 );
187
188 return { fetch: rpc.pipe(Effect.orDie) };
189 }).pipe(Effect.provide(AWS.DSQL.ConnectHttp)),
190) {}
191```
192
193`Effect.orDie` handles database failures before `flatMap`, preserving the typed `TodoNotFound` failure. Client and server must both use `RpcSerialization.layerJson`.
194
195## 5. Wire it into the Stack
196
197```typescript
198// alchemy.run.ts
199import * as Alchemy from "alchemy";
200import * as AWS from "alchemy/AWS";
201import * as Effect from "effect/Effect";
202import Backend from "./src/backend/api.ts";
203import { BootstrapDatabase } from "./src/backend/bootstrap.ts";
204import { Database } from "./src/backend/database.ts";
205
206export default Alchemy.Stack(
207 "AwsTanstackRpcDrizzle",
208 {
209 providers: AWS.providers(),
210 state: AWS.state(),
211 },
212 Effect.gen(function* () {
213 const backend = yield* Backend.pipe(Alchemy.remote());
214 const cluster = yield* Database;
215 const schemaVersion = yield* BootstrapDatabase({
216 endpoint: cluster.endpoint,
217 roleArn: backend.roleArn,
218 version: "1",
219 });
220
221 const site = yield* AWS.Website.TanStackStart("Website", {
222 env: { BACKEND_URL: backend.functionUrl },
223 });
224
225 return {
226 websiteUrl: site.url,
227 backendUrl: backend.functionUrl,
228 schemaVersion,
229 };
230 }),
231);
232```
233
234`BootstrapDatabase` initializes the schema. `Alchemy.remote()` keeps the backend on AWS during local development.
235
236:::caution
237The backend Function URL is public. Add authentication before exposing private data; `AWS_IAM` requires SigV4-signed proxy requests.
238:::
239
240## 6. The `/rpc` proxy route
241
242Forward POST requests to the backend:
243
244```typescript
245// src/routes/rpc.ts
246import { createFileRoute } from "@tanstack/react-router";
247
248export const Route = createFileRoute("/rpc")({
249 server: {
250 handlers: {
251 POST: async ({ request }) =>
252 fetch(process.env.BACKEND_URL!, {
253 method: "POST",
254 headers: { "content-type": "application/json" },
255 body: await request.text(),
256 }),
257 },
258 },
259});
260```
261
262## 7. The atom RPC client
263
264Create the browser client with Effect 4’s `AtomRpc`:
265
266```typescript
267// src/rpc-client.ts
268import * as Layer from "effect/Layer";
269import * as FetchHttpClient from "effect/http/FetchHttpClient";
270import * as AtomRpc from "effect/reactivity/AtomRpc";
271import { RpcClient, RpcSerialization } from "effect/rpc";
272import { TodoRpcs } from "./backend/rpc.ts";
273
274export class TodoClient extends AtomRpc.Service<TodoClient>()("TodoClient", {
275 group: TodoRpcs,
276 protocol: RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
277 Layer.provide(FetchHttpClient.layer),
278 Layer.provide(RpcSerialization.layerJson),
279 ),
280}) {}
281
282// Created once at module scope so their identity is stable across renders.
283export const listTodosAtom = TodoClient.query("listTodos", undefined, {
284 reactivityKeys: ["todos"],
285});
286export const createTodoAtom = TodoClient.mutation("createTodo");
287export const toggleTodoAtom = TodoClient.mutation("toggleTodo");
288export const deleteTodoAtom = TodoClient.mutation("deleteTodo");
289```
290
291Mutations with `reactivityKeys: ["todos"]` invalidate and refetch the list.
292
293## 8. Provide a registry
294
295Wrap the application in `RegistryProvider`:
296
297```tsx
298// src/routes/__root.tsx
299import { RegistryProvider } from "@effect/atom-react";
300import { Outlet, createRootRoute } from "@tanstack/react-router";
301
302export const Route = createRootRoute({ component: RootComponent });
303
304function RootComponent() {
305 return (
306 // One AtomRegistry for the whole app — every query/mutation atom
307 // resolves against it.
308 <RegistryProvider>
309 <Outlet />
310 </RegistryProvider>
311 );
312}
313```
314
315## 9. The UI
316
317Read the query with `useAtomValue`; call mutations with `useAtomSet`:
318
319```tsx
320// src/routes/index.tsx
321import { useAtomSet, useAtomValue } from "@effect/atom-react";
322import * as AsyncResult from "effect/reactivity/AsyncResult";
323import { useState } from "react";
324import {
325 createTodoAtom,
326 deleteTodoAtom,
327 listTodosAtom,
328 toggleTodoAtom,
329} from "../rpc-client.ts";
330
331function TodoForm() {
332 const createTodo = useAtomSet(createTodoAtom);
333 const [text, setText] = useState("");
334
335 return (
336 <form
337 onSubmit={(e) => {
338 e.preventDefault();
339 const value = text.trim();
340 if (!value) return;
341 createTodo({ payload: { text: value }, reactivityKeys: ["todos"] });
342 setText("");
343 }}
344 >
345 <input value={text} onChange={(e) => setText(e.target.value)} />
346 <button type="submit">Add</button>
347 </form>
348 );
349}
350
351function TodoList() {
352 const atom = useAtomValue(listTodosAtom);
353 const toggleTodo = useAtomSet(toggleTodoAtom);
354 const deleteTodo = useAtomSet(deleteTodoAtom);
355
356 const todos = AsyncResult.getOrElse(atom, () => []);
357
358 if (AsyncResult.isWaiting(atom) && !todos.length) {
359 return <p>Loading todos…</p>;
360 }
361
362 return (
363 <ul>
364 {todos.map((todo) => (
365 <li key={todo.id}>
366 <input
367 type="checkbox"
368 checked={todo.done}
369 onChange={() =>
370 toggleTodo({
371 payload: { id: todo.id, done: !todo.done },
372 reactivityKeys: ["todos"],
373 })
374 }
375 />
376 <span>{todo.text}</span>
377 <button
378 type="button"
379 onClick={() =>
380 deleteTodo({ payload: { id: todo.id }, reactivityKeys: ["todos"] })
381 }
382 >
383 Delete
384 </button>
385 </li>
386 ))}
387 </ul>
388 );
389}
390```
391
392## 10. Deploy
393
394[AWS setup](/aws/setup)
395
396```sh
397bun alchemy deploy
398```
399
400Open `websiteUrl` to create, toggle, and delete todos.
401
402## Local dev
403
404```sh
405bun alchemy dev
406```
407
408The frontend runs locally; the backend Lambda and DSQL remain on AWS and incur charges.
409
410## Recap
411
412`TodoRpcs` defines the API; the frontend proxies `/rpc`; mutation keys refresh the list.
413
414## Where to go next
415
416[Effect RPC](/aws/apis/effect-rpc) · [TanStack Start](/aws/frontend/tanstack-start) · [Drizzle + DSQL](/aws/data/drizzle-dsql) · [Drizzle queries](/sql/drizzle/postgres) · [RDS & Aurora](/aws/data/rds)