Capstone — Build a Fullstack Todo App
Tie the patterns from Chapters 1 ~ 33 into one working fullstack service: RSC, Server Actions, DB, auth, and tests.
With Chapter 33, Part 5 (operations, testing, deploy) wrapped up. This chapter is the single chapter of Part 6 (capstone), and we will weave every tool you picked up across Chapters 1 ~ 33 into one fullstack Todo app under a single flow.
The point of the capstone is simple. When you learn each tool in isolation, you can end up at the “I know each one, but I cannot see how they fit together inside one service” stage. This chapter fills that gap. We will not write out every line of code from scratch — instead, this is a guided tour of which chapter of the book each step applies, and how. You get the most out of it by reading it alongside one working repository.
The contrast with the react-todo-app series (5 parts) and its simple client-only Todo is clear. The series is an introductory take that uses useState and localStorage to handle a single user’s task list. This chapter takes the same domain (task management) and moves it through a full fullstack cycle: DB persistence, per-authenticated-user separation, RSC + Server Actions, tests, deploy, and observability.
What we are building #
A fullstack Todo app — a per-authenticated-user task management service. The feature list:
- Signup / login (Auth.js v5 — Chapter 32)
- Todo CRUD (title / description / due date / completed)
- Tag filter + search (URL searchParams synced)
- Optimistic update on toggle (
useOptimistic— Chapter 28) - Due-soon notification badge
- Dark mode toggle (theme via Cookie, SSR friendly)
This one app pulls in nearly every tool the book has covered. We kept the scope intentionally small. The domain itself is obvious at a glance, so your attention can land on how the tools mesh.
Tech stack — same standards as the book #
| Area | Choice | Book chapter |
|---|---|---|
| Framework | Next.js 15 (App Router) | Chapter 23 |
| Language | TypeScript 5 | Chapter 16 |
| Component model | RSC + ‘use client’ | Chapter 24 |
| Data fetching | Direct fetch inside RSC | Chapter 25 |
| Mutations | Server Actions | Chapter 27 |
| Optimistic updates | useOptimistic | Chapter 28 |
| DB | SQLite + Drizzle ORM | Chapter 21 + Chapter 25 |
| Validation | zod | Chapter 21 |
| Auth | Auth.js v5 | Chapter 32 |
| Unit tests | Vitest + Testing Library | Chapter 29 |
| E2E | Playwright | Chapter 30 |
| Performance | next/font + Lighthouse CI + Web Vitals | Chapter 31 |
| Hosting | Vercel | Chapter 33 |
| Observability | Sentry + PostHog | Chapter 33 |
The choices come straight from Parts 4 ~ 5 of the book. The capstone introduces no new tools.
The 13-step flow #
The whole project runs through these 13 steps. Each step notes which chapter’s patterns it applies.
1. Project setup (Chapter 2 + Chapter 16)
2. DB model and schema (Chapter 21 + Chapter 25)
3. Auth (Chapter 32)
4. Layout and routing (Chapter 23)
5. Todo list — RSC (Chapter 24 + Chapter 25)
6. Add Todo — Server Action (Chapter 27)
7. Toggle complete — Optimistic UI (Chapter 28)
8. Search and tag filter (Chapter 10 + Chapter 21)
9. Dark mode (Chapter 12)
10. Component tests (Chapter 29)
11. E2E tests (Chapter 30)
12. Performance check (Chapter 31)
13. Deploy and observability (Chapter 33)Each step is intentionally small. A single step is roughly one PR’s worth of work.
1. Project setup #
We will start a new project with Next.js 15 + TypeScript.
pnpm create next-app@latest fullstack-todo --typescript --eslint --app --tailwind
cd fullstack-todoThe spirit of Chapter 2 (Vite setup) is the same: start with the minimum defaults unless the tool forces otherwise. Confirm strict: true and noUncheckedIndexedAccess: true in tsconfig.json as covered in Chapter 16 (TypeScript setup).
The folder structure starts roughly like this.
src/
├── app/ # App Router root
│ ├── (auth)/ # auth route group
│ ├── (app)/ # post-login route group
│ └── layout.tsx
├── components/ # reusable components
├── db/ # Drizzle schema and client
├── lib/ # Server Actions, zod schemas, utilities
└── auth.ts # Auth.js configThe (auth) and (app) route groups split before-auth and after-auth. The same pattern Chapter 13 (routing) covered.
2. DB model and schema #
We start with SQLite + Drizzle ORM. SQLite is a single file, so it has low friction for learning, and Drizzle is TypeScript first — which fits the typing habits from Chapters 16 ~ 21 naturally.
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { relations } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
hashedPassword: text('hashed_password'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
export const todos = sqliteTable('todos', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
title: text('title').notNull(),
description: text('description'),
dueAt: integer('due_at', { mode: 'timestamp' }),
completed: integer('completed', { mode: 'boolean' }).notNull().default(false),
tags: text('tags', { mode: 'json' }).$type<string[]>().default([]),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
export const todosRelations = relations(todos, ({ one }) => ({
user: one(users, { fields: [todos.userId], references: [users.id] }),
}));
export type Todo = typeof todos.$inferSelect;
export type NewTodo = typeof todos.$inferInsert;$inferSelect / $inferInsert is the key. The types fall straight out of the DB schema, so you do not hand-write domain types separately. The “types defined in one place only” principle from Chapter 17 (typing props and children) carries all the way down to the DB.
The zod schema shows up at the Server Action’s input validation step.
import { z } from 'zod';
export const todoCreateSchema = z.object({
title: z.string().min(1, 'Title cannot be empty').max(200),
description: z.string().max(2000).optional(),
dueAt: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
});
export type TodoCreateInput = z.infer<typeof todoCreateSchema>;The pattern from Chapter 21 (fetch and API typing). It expresses the flow form input → zod → validated object → DB in one line.
3. Auth #
We will use Auth.js v5 to accept GitHub OAuth and credentials at the same time, on two tracks.
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/db/client';
import { verifyPassword } from '@/lib/password';
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
providers: [
GitHub,
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
authorize: async (credentials) => {
const user = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, credentials.email as string),
});
if (!user?.hashedPassword) return null;
const ok = await verifyPassword(
credentials.password as string,
user.hashedPassword,
);
return ok ? { id: user.id, email: user.email } : null;
},
}),
],
session: { strategy: 'jwt' },
});The exact pattern from Chapter 32 (auth and sessions). You retrieve the session in one line from anywhere.
import { auth } from '@/auth';
const session = await auth();
if (!session?.user) {
redirect('/login');
}That one line works the same way in an RSC, a Route Handler, or a Server Action.
4. Layout and routing #
We use App Router’s route groups to split before-auth and after-auth.
app/
├── (auth)/
│ ├── login/page.tsx
│ └── signup/page.tsx
├── (app)/
│ ├── layout.tsx # sidebar + user menu
│ ├── page.tsx # Todo list (default)
│ └── todos/
│ ├── new/page.tsx
│ └── [id]/page.tsx
└── layout.tsx # root layout (theme / fonts)Inside (app)/layout.tsx, we check the session and send unauthenticated users to /login.
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
import { Sidebar } from '@/components/sidebar';
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) redirect('/login');
return (
<div className="grid grid-cols-[240px_1fr] min-h-screen">
<Sidebar user={session.user} />
<main className="p-6">{children}</main>
</div>
);
}Chapter 23 (App Router)’s nested layout model is unchanged. This layout runs as a server component, so the session check finishes in one place and no sentinel data leaks into the client.
5. Todo list — RSC #
The Todo list queries the DB directly inside a server component. No fetch, no API route, no client state in between.
import { auth } from '@/auth';
import { db } from '@/db/client';
import { todos } from '@/db/schema';
import { eq, desc } from 'drizzle-orm';
import { TodoList } from '@/components/todo-list';
export default async function TodosPage() {
const session = await auth();
const items = await db
.select()
.from(todos)
.where(eq(todos.userId, session!.user!.id))
.orderBy(desc(todos.createdAt));
return <TodoList items={items} />;
}The picture from Chapter 24 (Server vs Client Components) turned into actual code. The page component runs on the server, and only the interactive parts of the list UI drop down to the client.
The list component only renders.
import type { Todo } from '@/db/schema';
import { TodoItem } from './todo-item';
export function TodoList({ items }: { items: Todo[] }) {
if (items.length === 0) {
return <p className="text-muted">No tasks yet. Add one.</p>;
}
return (
<ul className="space-y-2">
{items.map((t) => (
<TodoItem key={t.id} todo={t} />
))}
</ul>
);
}This component does not carry 'use client'. That is RSC’s reasonable default. The interactive piece — the toggle button alone — drops to the client inside TodoItem.
6. Add Todo — Server Action #
The add form ends in one Server Action function. The exact pattern from Chapter 27 (Server Actions and forms).
'use server';
import { auth } from '@/auth';
import { db } from '@/db/client';
import { todos } from '@/db/schema';
import { todoCreateSchema } from './schemas';
import { revalidatePath } from 'next/cache';
import { nanoid } from 'nanoid';
export async function createTodo(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
const parsed = todoCreateSchema.safeParse({
title: formData.get('title'),
description: formData.get('description'),
dueAt: formData.get('dueAt') || undefined,
tags: formData.getAll('tags'),
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
await db.insert(todos).values({
id: nanoid(),
userId: session.user.id,
...parsed.data,
createdAt: new Date(),
});
revalidatePath('/');
}The form component connects in one line, with <form action={createTodo}>.
import { createTodo } from '@/lib/actions';
export default function NewTodoPage() {
return (
<form action={createTodo} className="space-y-3">
<input name="title" required className="input" placeholder="Title" />
<textarea name="description" className="input" placeholder="Description (optional)" />
<input name="dueAt" type="date" className="input" />
<button type="submit" className="btn">Add</button>
</form>
);
}The FormData-narrowing pattern from Chapter 19 (typing events and forms), combined with zod, becomes type-safe input validation. The API route, the fetch call, and the client state all drop out.
7. Toggle complete — Optimistic UI #
The completion checkbox flips the UI immediately, without waiting for the response, using useOptimistic. The hook from Chapter 28 (React 19 features).
'use client';
import { useOptimistic, useTransition } from 'react';
import type { Todo } from '@/db/schema';
import { toggleTodo } from '@/lib/actions';
export function TodoItem({ todo }: { todo: Todo }) {
const [optimistic, setOptimistic] = useOptimistic(
todo,
(state, next: boolean) => ({ ...state, completed: next }),
);
const [pending, startTransition] = useTransition();
return (
<li className="flex items-center gap-2">
<input
type="checkbox"
checked={optimistic.completed}
disabled={pending}
onChange={(e) => {
const next = e.target.checked;
startTransition(async () => {
setOptimistic(next);
await toggleTodo(todo.id, next);
});
}}
/>
<span className={optimistic.completed ? 'line-through text-muted' : ''}>
{optimistic.title}
</span>
</li>
);
}useOptimistic flips the UI without waiting for the server response. If the Server Action fails and a revalidate fires, it falls back to the original state automatically. You do not need to carry separate client state.
8. Search and tag filter #
Search query and tag filter live in the URL searchParams, not in client state. That is refresh-, share-, and back-button-friendly.
export default async function TodosPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; tag?: string }>;
}) {
const { q, tag } = await searchParams;
const session = await auth();
const items = await db.query.todos.findMany({
where: (t, { and, eq, like, sql }) => and(
eq(t.userId, session!.user!.id),
q ? like(t.title, `%${q}%`) : undefined,
tag ? sql`json_extract(${t.tags}, '$') LIKE ${'%' + tag + '%'}` : undefined,
),
orderBy: (t, { desc }) => desc(t.createdAt),
});
return <TodoList items={items} />;
}The search input is a client component, but it only carries the small responsibility of writing the input value back to the URL.
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
export function TodoSearch() {
const router = useRouter();
const params = useSearchParams();
const [, startTransition] = useTransition();
return (
<input
defaultValue={params.get('q') ?? ''}
placeholder="Search"
onChange={(e) => {
const next = new URLSearchParams(params);
if (e.target.value) next.set('q', e.target.value);
else next.delete('q');
startTransition(() => router.replace(`?${next}`));
}}
/>
);
}Same spirit as Chapter 10 (conditional rendering). Pick one source of truth and let the UI only display it. Here, the URL is the source of truth.
9. Dark mode #
The theme persists in a Cookie. That is SSR friendly, and the first paint renders in the right theme, so there is no flicker.
'use server';
import { cookies } from 'next/headers';
export async function setTheme(theme: 'light' | 'dark') {
(await cookies()).set('theme', theme, {
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
});
}import { cookies } from 'next/headers';
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const theme = (await cookies()).get('theme')?.value ?? 'light';
return (
<html lang="en" data-theme={theme} className={theme}>
<body>{children}</body>
</html>
);
}A variation on Chapter 12 (conditional rendering)’s “state lives in one place.” Because it is in a Cookie instead of localStorage, the server knows the state from the very first response.
10. Component tests #
We start with Vitest + Testing Library on three or four core components. The pattern from Chapter 29 (Vitest).
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { TodoItem } from './todo-item';
vi.mock('@/lib/actions', () => ({
toggleTodo: vi.fn().mockResolvedValue(undefined),
}));
describe('TodoItem', () => {
it('flips UI immediately on checkbox click (optimistic)', async () => {
const user = userEvent.setup();
render(
<TodoItem todo={{
id: '1', userId: 'u', title: 'Buy a book', description: null,
dueAt: null, completed: false, tags: [], createdAt: new Date(),
}} />,
);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
expect(screen.getByText('Buy a book')).toHaveClass('line-through');
});
});Chapter 29’s slogan “test what the user sees” carries over unchanged. You confirm what the user clicks and how the screen changes, not the internal state.
A natural list of the first four tests to write:
TodoItem— optimistic toggleTodoList— empty list messageTodoSearch— that input updates the URLlib/schemas— two or three edge cases for zod input validation
11. E2E tests #
We bundle “signup → login → add Todo → complete” into one scenario with Playwright. The pattern from Chapter 30 (Playwright).
import { test, expect } from '@playwright/test';
test('from signup to Todo completion', async ({ page }) => {
const email = `user-${Date.now()}@example.com`;
await page.goto('/signup');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill('correct-horse-battery-staple');
await page.getByRole('button', { name: 'Sign up' }).click();
await page.waitForURL('/');
await page.getByRole('link', { name: 'New Todo' }).click();
await page.getByLabel('Title').fill('First task');
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText('First task')).toBeVisible();
await page.getByRole('checkbox').click();
await expect(page.getByText('First task')).toHaveClass(/line-through/);
});This one test verifies Steps 1 ~ 9 of this chapter in one shot. Running it on every PR preview deploy in CI catches bugs that only appear in the production build (the pattern from Chapter 33).
12. Performance check #
The tools from Chapter 31 (Web Vitals) get a check early once, and once more just before launch.
pnpm dlx @lhci/cli@latest autorun --upload.target=temporary-public-storageThis app’s first paint comes out well because RSC cuts a single response that already includes the DB query — LCP naturally lands well. INP responds instantly on toggle thanks to useOptimistic. Capture the lab data from Chapter 31 once at launch, and you have a baseline to compare regressions against later.
The real-user distribution in production comes from PostHog (Chapter 33). The gap between lab and real-user environments is not small.
13. Deploy and observability #
We ship it to Vercel in one go. Following Chapter 33’s procedure as is.
1. Push GitHub repo
2. Vercel: New Project → pick repo
3. Enter env vars:
- DATABASE_URL (one of Turso / Cloudflare D1 / Vercel Postgres)
- AUTH_SECRET
- AUTH_GITHUB_ID / AUTH_GITHUB_SECRET
- NEXT_PUBLIC_POSTHOG_KEY / NEXT_PUBLIC_POSTHOG_HOST
- NEXT_PUBLIC_SENTRY_DSN
4. DeploySQLite is fine for local learning, but in production a single file becomes a limit. The cleanest transition for this capstone is Turso (libSQL). It is SQLite-compatible, so you can move with hardly any code changes.
Sentry and PostHog each set up with a one-line wizard. The first thing to check in the first 24 hours: whether new errors flow in, and whether the autocapture funnel renders.
The whole book in one table #
A one-page summary of which chapter each step of the capstone applies.
| Step | Application in this chapter | Source chapter |
|---|---|---|
| Project setup | Next.js + TS strict | Chapter 2 / Chapter 16 |
| DB schema | Domain types from Drizzle $inferSelect | Chapter 17 |
| zod validation | Form input → server validation | Chapter 21 |
| Auth | Auth.js v5 GitHub + credentials | Chapter 32 |
| Layout | App Router route groups | Chapter 23 |
| Todo list | Direct DB query inside RSC | Chapter 24 / Chapter 25 |
| Add Todo | Server Action + revalidatePath | Chapter 27 |
| Toggle | useOptimistic + useTransition | Chapter 28 |
| Search | URL searchParams = source of truth | Chapter 10 / Chapter 13 |
| Dark mode | Cookie-based, SSR friendly | Chapter 12 |
| Unit tests | Vitest + Testing Library | Chapter 29 |
| E2E | Playwright on preview deploy | Chapter 30 |
| Performance | Lighthouse + Web Vitals | Chapter 31 |
| Deploy | Vercel + Turso | Chapter 33 |
| Observability | Sentry + PostHog | Chapter 33 |
There is no new pattern introduced in this chapter. Every tool was already covered once in this book. That is exactly where the capstone’s value lies. If you followed the book, there should be nothing new to learn in this chapter — that is the expected state.
Comparison with the react-todo-app series #
| Item | react-todo-app series | This chapter |
|---|---|---|
| Users | Single | Per-authenticated-user separation |
| Storage | localStorage | DB (SQLite / Turso) |
| State | useState | RSC + Server Action |
| Routing | None (single page) | App Router |
| Types | JS / light TS | DB schema → domain types |
| Mutations | Client only | Server Action |
| Dark mode | localStorage (flicker) | Cookie (SSR friendly) |
| Tests | None | Vitest + Playwright |
| Deploy | Static hosting | Vercel + observability |
Readers who finished the series and move to this chapter can see at a glance how the same domain transforms into a fullstack version. The series stays up on the site for free, so you can compare without any cost.
Hands-on — one full cycle #
This is the last hands-on of the book. Take the 13 steps in this chapter and run them through end-to-end yourself. You do not need to finish in one sitting. One PR per step, stacked over 1 ~ 2 weeks, is fine.
- Steps 1 ~ 3 — project / DB / auth setup. See signup + login working locally.
- Steps 4 ~ 7 — layout and Todo CRUD + optimistic toggle. Try it as a single user and polish the UX.
- Steps 8 ~ 9 — search / tags / dark mode.
- Steps 10 ~ 11 — four Vitest tests plus one Playwright scenario. Hook up CI with GitHub Actions.
- Steps 12 ~ 13 — first Lighthouse measurement → Vercel deploy → Sentry / PostHog setup.
Once you finish, every tool in this book has run through your own hands at least once. That is the goal this book promised.
Exercises #
- Server vs client division. For each of the following components in this capstone, say whether it is server or client and why. (a)
TodoList, (b)TodoItem, (c)TodoSearch, (d)RootLayout, (e) the new-Todo form page. The key is to identify the smallest unit that has to drop to the client. - URL as the source of truth. In this chapter, search query and tags live in URL searchParams instead of client state. How would you handle these items? (a) “show completed only” toggle, (b) pagination (current page number), (c) sort (by creation date / due date), (d) dark mode. Distinguish which are URL-friendly and which are more naturally a Cookie or server state.
- Test priorities. This chapter suggested four tests to start with. If you only had half the time, which two would you write first? Write one line on why you picked those two. (Hint: start with the regressions most visible to users.)
In one line: the capstone is not a step for learning new tools but a step for confirming that all the tools in this book mesh inside one fullstack Todo app, and RSC + Server Action + zod + Drizzle + Auth.js + useOptimistic + Vitest + Playwright + Vercel + Sentry / PostHog connect naturally as a 13-step PR-sized flow. Having nothing new to learn is the expected state of this chapter, and reaching that state is what it means to be at the goal this book promised.
Next chapter #
This chapter wraps up Chapters 1 ~ 34 of the main text. The next one, Appendix A — Migrating Old React Code, bundles in one place the one area the main text did not cover: a procedure for moving Class component / Pages Router / Redux-only / fetch-on-mount era code into this book’s modern style. The reason it is an appendix rather than a main chapter is clear. This book sets out to teach modern only from page one, so the mapping to old code is separated out as an appendix to keep the main text’s flow intact. For readers approaching this book with an older codebase in hand, Appendix A is the chapter to open first.