Dev Logs
/Next.js/ Chapter 26: Capstone Project
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
    • What we're building
    • Milestone 0 — Setup
    • Milestone 1 — Structure
    • Milestone 2 — Authentication
    • Milestone 3 — Notes CRUD
    • Milestone 4 — Search via URL state
    • Milestone 5 — Public note pages
    • Milestone 6 — A streaming dashboard
    • Milestone 7 — Tests
    • Milestone 8 — Ship it
    • Final review
    • Where to go next
    • Key Takeaways
  • 27Chapter 27: React Performance Profiling
All chapters

🏆 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:

FeatureChapters exercised
Public marketing pages3, 6, 14, 16
Auth with sessions12, 17, 18
Dashboard CRUD10, 11, 12, 15
Optimistic UI12
Public note pages with ISR5, 14, 15
Search with URL state4, 5, 8
Tag modal over the list7
Per-note OG images19
Route handler for webhooks13
Streaming dashboard panels8, 16
Tests23
Deployment24

Work through it in order. Each milestone is shippable on its own.

🧱 Milestone 0 — Setup

bash
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
ts
// 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
// 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[]
}
ts
// 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
tsx
// 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)

ts
// 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')
}
ts
// 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 },
  })
})
ts
// 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')
}
tsx
// 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>
  )
}
ts
// 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

ts
// 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 }
}
tsx
// 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).

tsx
// 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} />
}
tsx
// 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).

ts
// 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 },
  })
}
tsx
// 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>
  )
}
tsx
// 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
  )
}
ts
// 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).

tsx
// 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>
  )
}
tsx
// 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} />
}
tsx
// 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):

ts
// 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()
  })
})
ts
// 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

ts
// 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' },
        ],
      },
    ]
  },
}
ts
// 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:

  1. Tag modal with intercepting routes (Chapter 7) — clicking a tag opens a filtered overlay at /tags/[name], shareable, closing on back.
  2. Real-time collaboration — a Route Handler streaming Server-Sent Events (Chapter 13) with optimistic local edits.
  3. A CMS webhook (Chapters 13, 15) — an authenticated /api/revalidate that invalidates tags, with HMAC verification.
  4. Internationalization (Chapters 5, 6) — an [locale] root segment with next/root-params and locale detection in proxy.ts.
  5. Multi-tenancy (Chapter 17) — subdomain rewrites, with tenant scoping enforced in the DAL.
  6. File uploads (Chapter 12) — avatars and attachments with server-side type and size validation.
  7. Admin panel (Chapters 9, 18) — role-gated with forbidden() and a forbidden.tsx.

🎯 Key Takeaways

  1. 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.
  2. Enable cacheComponents on 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.
  3. updateTag on write, cacheLife('max') on read. Nothing regenerates unless content actually changed, and the person who changed it sees it immediately.
  4. 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.
  5. 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 →


PreviousChapter 25: Upgrading to Next.js 16NextChapter 27: React Performance Profiling

Open source, free forever. Built by iammhador.

Contribute on GitHub