🏆 Chapter 26: Capstone Project
Building one real application that uses every concept in this book, in the order you'd actually build it.
📖 What we're building
Notedeck — a small team knowledge base. Members write notes, tag them, and publish selected ones to a public URL.
It's deliberately chosen because it forces you to use everything:
| Feature | Chapters exercised |
|---|---|
| Public marketing pages | 3, 6, 14, 16 |
| Auth with sessions | 12, 17, 18 |
| Dashboard CRUD | 10, 11, 12, 15 |
| Optimistic UI | 12 |
| Public note pages with ISR | 5, 14, 15 |
| Search with URL state | 4, 5, 8 |
| Tag modal over the list | 7 |
| Per-note OG images | 19 |
| Route handler for webhooks | 13 |
| Streaming dashboard panels | 8, 16 |
| Tests | 23 |
| Deployment | 24 |
Work through it in order. Each milestone is shippable on its own.
🧱 Milestone 0 — Setup
npx create-next-app@latest notedeck --typescript --tailwind --app --no-src-dir --import-alias "@/*"
cd notedeck
npm install @prisma/client zod jose bcryptjs clsx tailwind-merge
npm install -D prisma @types/bcryptjs vitest @vitejs/plugin-react jsdom \
@testing-library/react @testing-library/jest-dom @playwright/test
npx prisma init --datasource-provider postgresql
npx @next/codemod@canary agents-md
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true, // Chapter 16
typedRoutes: true, // Chapter 2
images: {
remotePatterns: [{ protocol: 'https', hostname: 'avatars.githubusercontent.com' }],
},
}
export default nextConfig
Starting with cacheComponents: true from day one is much easier than retrofitting it. You learn the discipline as you write rather than fixing 40 build errors later.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
passwordHash String
role Role @default(MEMBER)
createdAt DateTime @default(now())
notes Note[]
}
enum Role {
MEMBER
ADMIN
}
model Note {
id String @id @default(cuid())
slug String @unique
title String
body String @db.Text
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
tags Tag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId, updatedAt])
@@index([published, updatedAt])
}
model Tag {
id String @id @default(cuid())
name String @unique
notes Note[]
}
// lib/db.ts
import 'server-only'
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
export const db = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
The globalThis trick prevents connection exhaustion from hot-reload creating a new client on every edit.
🗂️ Milestone 1 — Structure
Set up route groups before writing pages (Chapter 6):
app/
├── layout.tsx root: <html>, fonts, providers
├── globals.css
├── not-found.tsx
├── error.tsx
├── (marketing)/
│ ├── layout.tsx public header + footer
│ ├── page.tsx /
│ └── pricing/page.tsx /pricing
├── (auth)/
│ ├── layout.tsx centred card, no nav
│ ├── login/page.tsx /login
│ └── register/page.tsx /register
├── (app)/
│ ├── layout.tsx auth gate + sidebar
│ ├── dashboard/
│ │ ├── page.tsx /dashboard
│ │ ├── loading.tsx
│ │ ├── error.tsx
│ │ └── @activity/ parallel slot
│ │ ├── page.tsx
│ │ ├── loading.tsx
│ │ └── default.tsx ← required (Chapter 7)
│ └── notes/
│ ├── page.tsx /notes
│ ├── new/page.tsx /notes/new
│ └── [id]/edit/page.tsx /notes/:id/edit
├── n/
│ └── [slug]/ public note pages
│ ├── page.tsx /n/:slug
│ ├── opengraph-image.tsx
│ └── not-found.tsx
├── api/
│ └── health/route.ts
├── sitemap.ts
├── robots.ts
└── proxy.ts
// app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-sans' })
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'),
title: { default: 'Notedeck', template: '%s | Notedeck' },
description: 'A shared knowledge base for small teams.',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body className="font-sans antialiased">{children}</body>
</html>
)
}
Checkpoint: / and /pricing render with the marketing shell, /login with the auth shell. Chapters 3, 6, 19, 20.
🔐 Milestone 2 — Authentication
Build the session layer and the DAL first. Everything else depends on it. (Chapter 18)
// lib/session.ts
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
const key = new TextEncoder().encode(process.env.SESSION_SECRET)
export type SessionPayload = {
userId: string
role: 'MEMBER' | 'ADMIN'
expiresAt: number
}
export async function encrypt(payload: SessionPayload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(key)
}
export async function decrypt(token = '') {
try {
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] })
return payload as SessionPayload
} catch {
return null
}
}
export async function createSession(userId: string, role: 'MEMBER' | 'ADMIN') {
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000
;(await cookies()).set('session', await encrypt({ userId, role, expiresAt }), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
expires: new Date(expiresAt),
path: '/',
})
}
export async function deleteSession() {
;(await cookies()).delete('session')
}
// lib/dal.ts
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { decrypt } from '@/lib/session'
import { db } from '@/lib/db'
export const verifySession = cache(async () => {
const token = (await cookies()).get('session')?.value
const session = await decrypt(token)
if (!session?.userId || session.expiresAt < Date.now()) return null
return { userId: session.userId, role: session.role }
})
export const requireSession = cache(async () => {
const session = await verifySession()
if (!session) redirect('/login')
return session
})
export const getCurrentUser = cache(async () => {
const session = await verifySession()
if (!session) return null
return db.user.findUnique({
where: { id: session.userId },
select: { id: true, name: true, email: true, role: true },
})
})
/** Authorization lives IN the query. */
export const listMyNotes = cache(async (query?: string) => {
const session = await verifySession()
if (!session) return []
return db.note.findMany({
where: {
authorId: session.userId,
...(query
? { OR: [{ title: { contains: query, mode: 'insensitive' } },
{ body: { contains: query, mode: 'insensitive' } }] }
: {}),
},
orderBy: { updatedAt: 'desc' },
include: { tags: true },
})
})
export const getMyNote = cache(async (id: string) => {
const session = await verifySession()
if (!session) return null
return db.note.findFirst({
where: { id, authorId: session.userId }, // ← ownership in the WHERE
include: { tags: true },
})
})
// app/actions/auth.ts
'use server'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import { db } from '@/lib/db'
import { createSession, deleteSession } from '@/lib/session'
import { rateLimit } from '@/lib/rate-limit'
const loginSchema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(1, 'Password is required'),
})
export type AuthState = { errors?: Record<string, string[]>; message?: string }
export async function login(prev: AuthState, formData: FormData): Promise<AuthState> {
const ip = (await headers()).get('x-forwarded-for') ?? 'unknown'
if (!(await rateLimit(`login:${ip}`, { max: 5, window: 900 })).success) {
return { message: 'Too many attempts. Try again in 15 minutes.' }
}
const parsed = loginSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }
const user = await db.user.findUnique({
where: { email: parsed.data.email },
select: { id: true, role: true, passwordHash: true },
})
const ok = user && (await bcrypt.compare(parsed.data.password, user.passwordHash))
if (!ok) return { message: 'Invalid email or password' } // same message either way
await createSession(user.id, user.role)
redirect('/dashboard') // throws — must be last
}
export async function logout() {
await deleteSession()
redirect('/login')
}
// app/(app)/layout.tsx — optimistic gate + shell
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/dal'
import { Sidebar } from '@/components/Sidebar'
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const user = await getCurrentUser()
if (!user) redirect('/login')
return (
<div className="flex min-h-screen">
<Sidebar user={user} />
<main className="flex-1 p-8">{children}</main>
</div>
)
}
// proxy.ts — cheap first line (Chapter 17)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const PUBLIC = ['/login', '/register']
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
if (PUBLIC.includes(pathname)) return NextResponse.next()
if (!request.cookies.has('session')) {
const url = new URL('/login', request.url)
url.searchParams.set('from', pathname)
return NextResponse.redirect(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/notes/:path*'],
}
Checkpoint: register, log in, get redirected to /dashboard, log out. Try hitting /dashboard logged out and confirm the redirect carries ?from=.
Then attack it: call the login action with curl, try 20 rapid attempts, and confirm the rate limit fires.
📝 Milestone 3 — Notes CRUD
// app/actions/notes.ts
'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/dal'
import { slugify } from '@/lib/slug'
const noteSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters').max(200),
body: z.string().min(1, 'Body cannot be empty').max(50_000),
published: z.coerce.boolean().default(false),
})
export type NoteState = { errors?: Record<string, string[]>; message?: string }
export async function createNote(prev: NoteState, formData: FormData): Promise<NoteState> {
const session = await verifySession()
if (!session) return { message: 'Not signed in' }
const parsed = noteSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }
const note = await db.note.create({
data: {
...parsed.data,
slug: await slugify(parsed.data.title),
authorId: session.userId, // ← from the session, never the form
},
})
updateTag(`notes-${session.userId}`) // read-your-writes (Chapter 15)
if (note.published) updateTag('published-notes')
redirect(`/notes/${note.id}/edit`)
}
export async function updateNote(
id: string,
prev: NoteState,
formData: FormData
): Promise<NoteState> {
const session = await verifySession()
if (!session) return { message: 'Not signed in' }
// Ownership check before mutating
const existing = await db.note.findFirst({
where: { id, authorId: session.userId },
select: { id: true, slug: true },
})
if (!existing) return { message: 'Not found' }
const parsed = noteSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }
await db.note.update({ where: { id }, data: parsed.data })
updateTag(`notes-${session.userId}`)
updateTag(`note-${existing.slug}`)
updateTag('published-notes')
return { message: 'Saved' }
}
export async function deleteNote(id: string) {
const session = await verifySession()
if (!session) return { error: 'Not signed in' }
const note = await db.note.findFirst({
where: { id, authorId: session.userId },
select: { id: true, slug: true },
})
if (!note) return { error: 'Not found' }
await db.note.delete({ where: { id } })
updateTag(`notes-${session.userId}`)
updateTag(`note-${note.slug}`)
return { success: true }
}
// app/(app)/notes/NoteList.tsx — optimistic delete (Chapter 12)
'use client'
import { useOptimistic, useTransition } from 'react'
import { deleteNote } from '@/app/actions/notes'
type Note = { id: string; title: string; updatedAt: Date }
export function NoteList({ notes }: { notes: Note[] }) {
const [, startTransition] = useTransition()
const [optimistic, removeOptimistic] = useOptimistic(
notes,
(state: Note[], removedId: string) => state.filter((n) => n.id !== removedId)
)
return (
<ul className="divide-y">
{optimistic.map((note) => (
<li key={note.id} className="flex items-center justify-between py-3">
<a href={`/notes/${note.id}/edit`} className="hover:underline">
{note.title}
</a>
<button
className="text-sm text-red-600"
onClick={() =>
startTransition(async () => {
removeOptimistic(note.id) // vanishes instantly
await deleteNote(note.id) // server catches up
})
}
>
Delete
</button>
</li>
))}
</ul>
)
}
Checkpoint: create, edit, and delete notes. Deletion should feel instant. Submit an invalid title and confirm the error appears without losing the body text.
🔎 Milestone 4 — Search via URL state
Search belongs in the URL, not in client state (Chapter 5).
// app/(app)/notes/page.tsx
import { Suspense } from 'react'
import { listMyNotes } from '@/lib/dal'
import { SearchBox } from './SearchBox'
import { NoteList } from './NoteList'
import { ListSkeleton } from '@/components/skeletons'
export default function Page(props: PageProps<'/notes'>) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Notes</h1>
<SearchBox />
{/* searchParams read INSIDE the boundary → the shell still prerenders */}
<Suspense fallback={<ListSkeleton />}>
<Results searchParams={props.searchParams} />
</Suspense>
</div>
)
}
async function Results({
searchParams,
}: Pick<PageProps<'/notes'>, 'searchParams'>) {
const { q } = await searchParams
const notes = await listMyNotes(typeof q === 'string' ? q : undefined)
if (notes.length === 0) {
return <p className="text-slate-500">No notes found.</p>
}
return <NoteList notes={notes} />
}
// app/(app)/notes/SearchBox.tsx
'use client'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { useDeferredValue, useEffect, useState } from 'react'
export function SearchBox() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [value, setValue] = useState(searchParams.get('q') ?? '')
const deferred = useDeferredValue(value)
useEffect(() => {
const params = new URLSearchParams(searchParams.toString())
if (deferred) params.set('q', deferred)
else params.delete('q')
router.replace(`${pathname}?${params}`) // replace — don't flood history
}, [deferred, pathname, router, searchParams])
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Search notes…"
className="w-full rounded border px-3 py-2"
/>
)
}
Reading searchParams inside the Suspense boundary is the Chapter 16 lesson applied — the heading, search box, and skeleton all prerender; only the results stream.
Checkpoint: type in the box, watch the URL update, and confirm that copying the URL into a new tab reproduces the search.
🌍 Milestone 5 — Public note pages
Where caching and ISR earn their keep (Chapters 14, 15, 16).
// lib/public.ts
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'
export async function getPublicNote(slug: string) {
'use cache'
cacheLife('max') // never expires by time...
cacheTag(`note-${slug}`) // ...invalidated on write instead
return db.note.findFirst({
where: { slug, published: true },
select: {
slug: true,
title: true,
body: true,
updatedAt: true,
author: { select: { name: true } },
tags: { select: { name: true } },
},
})
}
export async function listPublicNotes(limit = 100) {
'use cache'
cacheLife('max')
cacheTag('published-notes')
return db.note.findMany({
where: { published: true },
orderBy: { updatedAt: 'desc' },
take: limit,
select: { slug: true, title: true, updatedAt: true },
})
}
// app/n/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPublicNote, listPublicNotes } from '@/lib/public'
// Prerender the recent ones; the tail renders on demand and caches (Chapter 5)
export async function generateStaticParams() {
const notes = await listPublicNotes(100)
return notes.map((n) => ({ slug: n.slug }))
}
export async function generateMetadata(
props: PageProps<'/n/[slug]'>
): Promise<Metadata> {
const { slug } = await props.params
const note = await getPublicNote(slug)
if (!note) return { title: 'Not found' }
return {
title: note.title,
description: note.body.slice(0, 155),
alternates: { canonical: `/n/${slug}` },
openGraph: {
type: 'article',
title: note.title,
description: note.body.slice(0, 155),
publishedTime: note.updatedAt.toISOString(),
authors: [note.author.name],
},
}
}
export default async function Page(props: PageProps<'/n/[slug]'>) {
const { slug } = await props.params
const note = await getPublicNote(slug)
if (!note) notFound()
return (
<article className="mx-auto max-w-2xl px-6 py-12">
<h1 className="text-3xl font-bold">{note.title}</h1>
<p className="mt-2 text-sm text-slate-500">
By {note.author.name} · Updated{' '}
{note.updatedAt.toLocaleDateString('en-US', { dateStyle: 'medium' })}
</p>
<div className="prose mt-8 whitespace-pre-wrap">{note.body}</div>
</article>
)
}
// app/n/[slug]/opengraph-image.tsx (Chapter 19)
import { ImageResponse } from 'next/og'
import { getPublicNote } from '@/lib/public'
export const alt = 'Notedeck note'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image(props: { params: Promise<{ slug: string }> }) {
const { slug } = await props.params // ← Promise in Next.js 16
const note = await getPublicNote(slug)
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
background: '#0f172a',
padding: 72,
}}
>
<div style={{ display: 'flex', color: '#60a5fa', fontSize: 30 }}>
Notedeck
</div>
<div
style={{
display: 'flex',
color: 'white',
fontSize: 64,
fontWeight: 700,
lineHeight: 1.15,
}}
>
{note?.title ?? 'Note not found'}
</div>
<div style={{ display: 'flex', color: '#94a3b8', fontSize: 28 }}>
{note?.author.name ?? ''}
</div>
</div>
),
size
)
}
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { listPublicNotes } from '@/lib/public'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
const notes = await listPublicNotes(5000)
return [
{ url: base, lastModified: new Date(), priority: 1 },
{ url: `${base}/pricing`, lastModified: new Date(), priority: 0.8 },
...notes.map((n) => ({
url: `${base}/n/${n.slug}`,
lastModified: n.updatedAt,
priority: 0.6,
})),
]
}
Checkpoint: publish a note, open /n/<slug>, and paste the URL into Slack. The card should render. Then edit the note and confirm the public page updates — that's updateTag from Milestone 3 doing its job.
📊 Milestone 6 — A streaming dashboard
Three panels at different speeds (Chapters 7, 8, 16).
// app/(app)/dashboard/layout.tsx
export default function DashboardLayout({
children,
activity,
}: {
children: React.ReactNode
activity: React.ReactNode
}) {
return (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">{children}</div>
<aside>{activity}</aside>
</div>
)
}
// app/(app)/dashboard/page.tsx
import { Suspense } from 'react'
import { getCurrentUser } from '@/lib/dal'
import { CardSkeleton, ChartSkeleton } from '@/components/skeletons'
export default async function Page() {
const user = await getCurrentUser() // fast — block on this
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Welcome back, {user!.name}</h1>
<Suspense fallback={<CardSkeleton />}>
<StatsPanel />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<UsageChart />
</Suspense>
</div>
)
}
async function StatsPanel() {
const [notes, published] = await Promise.all([ // parallel, not sequential
countMyNotes(),
countMyPublished(),
])
return <Stats total={notes} published={published} />
}
async function UsageChart() {
const data = await getUsageSeries() // slow
return <Chart data={data} />
}
// app/(app)/dashboard/@activity/default.tsx ← required in Next.js 16
export default function Default() {
return null
}
Checkpoint: throttle the network in DevTools and reload. The heading should appear immediately, then stats, then the chart. Each panel arrives independently.
🧪 Milestone 7 — Tests
Write the security tests first (Chapter 23):
// app/actions/notes.test.ts
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createNote, deleteNote } from './notes'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/dal'
vi.mock('@/lib/db', () => ({
db: { note: { create: vi.fn(), findFirst: vi.fn(), delete: vi.fn() } },
}))
vi.mock('@/lib/dal', () => ({ verifySession: vi.fn() }))
vi.mock('next/cache', () => ({ updateTag: vi.fn() }))
vi.mock('next/navigation', () => ({ redirect: vi.fn() }))
const form = (f: Record<string, string>) => {
const fd = new FormData()
Object.entries(f).forEach(([k, v]) => fd.set(k, v))
return fd
}
describe('createNote', () => {
beforeEach(() => vi.clearAllMocks())
it('rejects unauthenticated requests', async () => {
vi.mocked(verifySession).mockResolvedValue(null)
const result = await createNote({}, form({ title: 'Hello', body: 'x' }))
expect(result.message).toBe('Not signed in')
expect(db.note.create).not.toHaveBeenCalled()
})
it('ignores an authorId supplied in the form', async () => {
vi.mocked(verifySession).mockResolvedValue({ userId: 'me', role: 'MEMBER' })
await createNote({}, form({ title: 'Hello', body: 'x', authorId: 'someone-else' }))
expect(db.note.create).toHaveBeenCalledWith({
data: expect.objectContaining({ authorId: 'me' }),
})
})
})
describe('deleteNote', () => {
it("refuses to delete another user's note", async () => {
vi.mocked(verifySession).mockResolvedValue({ userId: 'me', role: 'MEMBER' })
vi.mocked(db.note.findFirst).mockResolvedValue(null) // scoped query finds nothing
const result = await deleteNote('someone-elses-note')
expect(result.error).toBe('Not found')
expect(db.note.delete).not.toHaveBeenCalled()
})
})
// e2e/notes.spec.ts
import { test, expect } from '@playwright/test'
test('publishing a note makes it public', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('test@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Sign in' }).click()
await page.goto('/notes/new')
await page.getByLabel('Title').fill('My public note')
await page.getByLabel('Body').fill('Contents of the note.')
await page.getByLabel('Published').check()
await page.getByRole('button', { name: 'Save' }).click()
await page.goto('/n/my-public-note')
await expect(page.getByRole('heading', { name: 'My public note' })).toBeVisible()
})
test.describe('without JavaScript', () => {
test.use({ javaScriptEnabled: false })
test('login still works', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('test@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page).toHaveURL('/dashboard')
})
})
That last test is the one to be proud of. It proves your Server Actions are genuinely progressively enhanced.
🚢 Milestone 8 — Ship it
// next.config.ts
const nextConfig: NextConfig = {
output: 'standalone',
cacheComponents: true,
typedRoutes: true,
generateBuildId: async () => process.env.GIT_SHA ?? 'dev',
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
],
},
]
},
}
// app/api/health/route.ts
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
export async function GET() {
try {
await db.$queryRaw`SELECT 1`
return Response.json({ status: 'ok' })
} catch {
return Response.json({ status: 'degraded' }, { status: 503 })
}
}
Then work the Chapter 24 checklist: Dockerfile, HOSTNAME=0.0.0.0, copy public/ and .next/static/, migrations before cutover, and proxy_buffering off if you're behind nginx.
✅ Final review
Go through your finished app and check each of these. Every item maps to something in this book.
Rendering
□ Pages are Server Components by default
□ 'use client' only on interactive leaves
□ Every uncached read is inside <Suspense>
□ No await of params/cookies at the top of a layout
□ Skeletons match the real content's dimensions
Data
□ No sequential awaits that could be Promise.all
□ Every non-fetch data function is wrapped in cache()
□ Explicit select — no whole records to Client Components
□ Shared, stable data is behind 'use cache' + cacheLife
□ Mutations call updateTag / revalidateTag correctly
Security
□ Every Server Action verifies the session
□ Every Route Handler verifies the session
□ Ownership is enforced in the WHERE clause
□ Target IDs come from the session, never the form
□ Input validated with a schema (no object spreads)
□ server-only on db, session, and DAL modules
□ Login rate-limited
□ Session cookie: httpOnly, secure, sameSite
UX
□ loading.tsx or Suspense on every slow route
□ error.tsx per major section, using retry()
□ not-found.tsx for missing content
□ Optimistic UI on likely-to-succeed mutations
□ Forms keep user input when validation fails
□ Forms work with JavaScript disabled
SEO
□ metadataBase set
□ Unique title and description per page
□ Canonical URLs
□ OG image per public page
□ sitemap.ts and robots.ts
Performance
□ next/image with sizes; one priority per page
□ next/font, no <link> to Google
□ Bundle analyzed; nothing unexpected client-side
□ Measured with Lighthouse against a production build
Ship
□ output: 'standalone'
□ Security headers
□ /api/health
□ instrumentation.ts wired to error tracking
□ Tests pass against next build && next start
🚀 Where to go next
Extensions that each teach something new:
- Tag modal with intercepting routes (Chapter 7) — clicking a tag opens a filtered overlay at
/tags/[name], shareable, closing on back. - Real-time collaboration — a Route Handler streaming Server-Sent Events (Chapter 13) with optimistic local edits.
- A CMS webhook (Chapters 13, 15) — an authenticated
/api/revalidatethat invalidates tags, with HMAC verification. - Internationalization (Chapters 5, 6) — an
[locale]root segment withnext/root-paramsand locale detection inproxy.ts. - Multi-tenancy (Chapter 17) — subdomain rewrites, with tenant scoping enforced in the DAL.
- File uploads (Chapter 12) — avatars and attachments with server-side type and size validation.
- Admin panel (Chapters 9, 18) — role-gated with
forbidden()and aforbidden.tsx.
🎯 Key Takeaways
- Build the session layer and DAL first. Every feature depends on knowing who's asking and what they may touch — retrofitting that is far harder than starting with it.
- Enable
cacheComponentson day one. You'll learn the "cache it, stream it, or move it deeper" discipline as you write instead of fixing 40 build errors later. updateTagon write,cacheLife('max')on read. Nothing regenerates unless content actually changed, and the person who changed it sees it immediately.- The security tests are the ones worth writing. "Unauthenticated is rejected" and "user A can't touch user B's data" catch the bugs that become incidents.
- A form that works with JavaScript disabled is the clearest signal you've used the App Router as designed — real
<form action>, real Server Actions, progressive enhancement for free.
Next Chapter: React Performance Profiling →
You now know the App Router as it exists in Next.js 16.3 — not the version in most tutorials. Chapter 27 is the appendix: once the app is built and shipped, how you measure what it actually does at runtime.
Back to: Table of Contents →