51 min read

The Next.js First Principles Book

From Ground Zero to Production Systems — A Causal, Derivation-First Guide

Every concept in this book is derived, not memorised. Every code example is explained line by line. Every decision comes with: why this, why not that, when it breaks. Read this once. Understand Next.js permanently.


How This Book Works

Every chapter follows the same structure:

  1. The problem — what was painful before this feature existed
  2. The mechanism — what Next.js actually does (not what the docs say it does — what it actually does)
  3. The code — real, complete examples explained line by line
  4. The rules — what you can and cannot do, and why
  5. The failure modes — how this breaks in production
  6. The mental model — one sentence you keep forever

Do not skip chapters. Every chapter is a floor the next one stands on.


Part 0: Why Next.js Exists

Chapter 0: The Problem With React Alone

React is a UI library. It solves one problem precisely: making the UI a function of state. Given state, render this UI. State changes, UI updates. That is the full scope of React's responsibility.

React has no opinion about:

  • How your app is served to users (server? static files? CDN?)
  • How users navigate between pages (routing)
  • How you fetch data and when
  • How you handle SEO (React renders empty HTML until JS runs)
  • How your app performs (no image optimisation, no font loading strategy)
  • How you split your bundle so users don't download unused code
  • How you handle authentication across routes

This is not React's failure. It is scope discipline. React does its job excellently. But building a production web application requires all of these things.

The pre-Next.js world: A React app built with Create React App (CRA) works like this:

  1. Browser requests yourdomain.com
  2. Server returns index.html — a nearly empty file with a <div id="root"></div> and a <script> tag
  3. Browser downloads, parses, and executes your entire JS bundle
  4. React mounts into #root, fetches data, renders the UI
  5. Only now does the user see anything

The problems with this:

SEO is broken. Google (mostly) and other crawlers (often) do not execute JavaScript. They see an empty <div id="root">. Your page has no crawlable content. You are invisible to search engines.

First paint is slow. The user sees nothing until: network download + JS parse + JS execute + React mount + data fetch + React render. On slow networks or low-end devices, this is 3-8 seconds of blank screen.

No server. CRA produces static files. You have no server-side logic, no server-side auth, no ability to access databases or secrets.

No routing. You have to install React Router, configure it, handle edge cases.

No code splitting by default. Everything ships in one bundle. A user visiting /landing downloads the code for /dashboard, /settings, /admin, and every other page.

What Next.js is: a production runtime for React applications. It takes React and adds everything that production requires — a server, routing, rendering strategies, data fetching patterns, performance tooling, and deployment primitives. It makes the right choices for you by default. You can override any of them when you need to.

The mental model: React is the engine. Next.js is the car. You could bolt an engine to a chassis yourself (React + Express + React Router + etc). Or you could drive the car (Next.js). The engine works the same in both cases.


Chapter 1: How Next.js Works — The Actual Mechanism

Before writing a line of code, understand the machine you are operating.

The Build Step

When you run next build, Next.js does several things:

  1. Compiles your TypeScript/JSX — using either Webpack (stable) or Turbopack (fast, newer). Your .tsx files become JavaScript the browser can run.

  2. Analyses your routes — scans the app/ directory, builds a routing map from the folder structure.

  3. Determines rendering strategy per route — for each route, Next.js decides: can this be rendered at build time (static)? Must it be rendered per-request (dynamic)? The decision is based on what your code does (more on this in Chapter 5).

  4. Generates static HTML for static routes — Next.js pre-renders every statically determinable page into .html files. When a user requests these pages, the server returns pre-built HTML instantly. No computation at request time.

  5. Produces a .next/ directory — compiled JS, pre-rendered HTML, route manifests, and metadata.

The Server

When you run next start (or deploy to Vercel), a Node.js server runs. This server:

  • Receives HTTP requests
  • For static routes: returns the pre-built HTML from disk. Zero computation. Near-instant.
  • For dynamic routes: runs your server-side code, generates HTML, returns it.
  • Serves your client-side JS bundle so the browser can hydrate (see below).

Hydration — The Critical Concept

Here is the thing that confuses most engineers.

Next.js sends pre-rendered HTML to the browser. The user sees content immediately — even before any JS executes. This is the performance win.

But the HTML is static — it has no event listeners, no interactivity. Buttons don't work yet. useState doesn't exist yet.

Hydration: after the HTML is painted, React's JS bundle loads and React "takes over" the existing HTML. It attaches event listeners to the already-rendered DOM nodes. It initialises state. The page becomes interactive.

The key insight: the HTML painted before hydration and the HTML React would render must match exactly. If they don't, React throws a hydration error — it detects a mismatch between what the server rendered and what it would render on the client, and it re-renders from scratch, destroying the performance benefit.

This is why you cannot use window, localStorage, document, or browser-only APIs directly in a component that also runs on the server. The server renders the component and has no window. The client renders it and does. They produce different HTML. Hydration error.

Dev vs Production

next dev: every request rebuilds the relevant modules. Slow, but reflects your latest changes instantly. No static optimisation. Hot Module Replacement (HMR) — change a file, the browser updates without full reload.

next build && next start: build once, serve many. Static routes are pre-built. Dynamic routes run on the server at request time. The production performance profile.

Mental model: In development, Next.js is a build tool. In production, Next.js is a server and a static file host. The same code runs in both modes, but the execution model differs significantly.


Chapter 2: Project Setup — Every File Explained

bash
npx create-next-app@latest my-app \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"

Every flag explained:

  • --typescript: TypeScript is not optional for serious projects. Strict types prevent an entire class of runtime errors and enable the auto-complete that makes large codebases navigable.
  • --tailwind: utility-first CSS. Not required, but the ecosystem default.
  • --eslint: lint rules configured for Next.js-specific patterns.
  • --app: use the App Router, not the legacy Pages Router. This book covers App Router only.
  • --src-dir: puts your code in src/. Keeps root clean.
  • --import-alias "@/*": allows import { Button } from '@/components/Button' instead of '../../../components/Button'. Use this everywhere.

What Gets Created

code
my-app/
├── src/
│   └── app/
│       ├── layout.tsx        ← Root layout (wraps every page)
│       ├── page.tsx          ← The / route
│       └── globals.css       ← Global styles
├── public/                   ← Static assets (images, fonts, icons)
├── next.config.ts            ← Next.js configuration
├── tsconfig.json             ← TypeScript configuration
├── tailwind.config.ts        ← Tailwind configuration
└── package.json

next.config.ts — The Control Panel

typescript
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  // Allow images from external domains
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
      },
    ],
  },

  // Experimental features (use only what you need)
  experimental: {
    // ppr: true,  // Partial Prerendering (cutting edge, not stable yet)
  },
}

export default nextConfig

Why does images.remotePatterns exist? Next.js's Image component optimises images on the fly — resizing, converting to WebP/AVIF, adding cache headers. If it fetched images from any external URL without restriction, an attacker could point it at an infinite number of external servers, exhausting your server's bandwidth and CPU. The allowlist forces you to declare which external domains you trust.

tsconfig.json — Why These Settings Matter

json
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Key settings:

  • "strict": true — enables all strict type checks. Never turn this off. It catches undefined access, missing properties, wrong argument types.
  • "jsx": "preserve" — tells TypeScript not to transform JSX; let Next.js/Babel handle it.
  • "moduleResolution": "bundler" — uses the bundler's module resolution algorithm, which matches how Next.js actually resolves imports.
  • "paths" — the @/* alias. Works only because you passed --import-alias at setup.

Part 1: Routing — The App Router

Chapter 3: File System Routing from First Principles

Why File System Routing?

In a traditional React Router setup, you write a config:

jsx
<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/users" element={<UserList />} />
  <Route path="/users/:id" element={<UserDetail />} />
  <Route path="/users/:id/posts" element={<UserPosts />} />
</Routes>

As apps grow, this config grows. It is a second source of truth that must be kept in sync with your actual files. Rename a component? Update the route config. Add a new page? Add a route config entry.

File system routing eliminates this. The folder structure is the route config. A file at a certain path in app/ defines a route at the corresponding URL path. The file system is the single source of truth.

The Core Convention

In the app/ directory:

  • A folder defines a URL segment
  • A page.tsx file inside a folder makes that segment publicly accessible
  • A layout.tsx file inside a folder wraps all child routes in that segment
code
app/
├── page.tsx              → /
├── about/
│   └── page.tsx          → /about
├── users/
│   ├── page.tsx          → /users
│   └── [id]/
│       └── page.tsx      → /users/:id
└── dashboard/
    ├── layout.tsx        → wraps all /dashboard/* routes
    ├── page.tsx          → /dashboard
    ├── settings/
    │   └── page.tsx      → /dashboard/settings
    └── analytics/
        └── page.tsx      → /dashboard/analytics

Why does page.tsx need to exist? Not every folder is a public route. You might have a folder for shared components, utilities, or hooks inside the app/ directory. Folders without a page.tsx are not accessible via URL. The page.tsx file is the explicit opt-in that says "this folder is a route."

The Special Files

The App Router recognises specific filenames. Each solves a specific problem:

FilePurposeWhy it exists
page.tsxRoute's unique contentDefines what's shown at this URL
layout.tsxPersistent wrapperShared UI that survives navigation (sidebar, header)
loading.tsxStreaming skeletonInstant fallback while page data loads
error.tsxError boundaryCatches errors in this route segment
not-found.tsx404 UICustom not-found for this segment
route.tsAPI endpointHTTP handler (GET, POST, etc.)
middleware.tsRequest interceptorRuns before every request in scope
template.tsxRe-mounting layoutLike layout but unmounts on navigation

Every one of these is optional. The only required file for a route to exist is page.tsx.


Chapter 4: page.tsx and layout.tsx — The Two Files You Write Most

page.tsx

typescript
// app/users/page.tsx

// This is a Server Component by default (explained in Chapter 6)
// It runs on the server, has access to databases, and ships zero JS to the client

export default function UsersPage() {
  return (
    <main>
      <h1>Users</h1>
    </main>
  )
}

// You can export metadata from any page
export const metadata = {
  title: 'Users',
  description: 'All users in the system',
}

A page receives two props automatically from Next.js:

typescript
// app/users/[id]/page.tsx

interface Props {
  params: Promise<{ id: string }>      // URL params (/users/123 → id = '123')
  searchParams: Promise<{ q?: string }> // Query string (?q=hello → q = 'hello')
}

export default async function UserPage({ params, searchParams }: Props) {
  const { id } = await params
  const { q } = await searchParams

  return <div>User {id}, query: {q}</div>
}

Why are params and searchParams Promises in Next.js 15? In earlier versions they were plain objects. Next.js 15 changed them to Promises to enable better parallelism — the page component can now start rendering before params are resolved, and awaiting them at the point of use gives the runtime flexibility. Always await them.

layout.tsx

typescript
// app/layout.tsx  ← The ROOT layout. Every page in the app is wrapped by this.

import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

// Metadata here applies to all pages unless overridden
export const metadata: Metadata = {
  title: {
    template: '%s | MyApp',  // '%s' is replaced by page-level title
    default: 'MyApp',
  },
  description: 'MyApp description',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        {children}  {/* ← Every page renders here */}
      </body>
    </html>
  )
}

The root layout is mandatory. It must contain <html> and <body> tags. No other component in your app should have these — only the root layout.

Why does layout exist separately from page? The critical behaviour: layouts do not re-render on navigation. When a user navigates from /dashboard to /dashboard/settings, the dashboard/layout.tsx does not unmount and remount. It stays alive. Only the page content changes.

This is why persistent UI (sidebars, navigation, headers, session state) lives in layouts. Putting them in page.tsx would cause them to re-render (and potentially re-fetch data, re-play animations, lose scroll position) on every navigation.

Nested layouts:

typescript
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="flex">
      <aside className="w-64 bg-gray-900">
        {/* Sidebar — stays mounted across all /dashboard/* navigations */}
        <nav>
          <a href="/dashboard">Overview</a>
          <a href="/dashboard/settings">Settings</a>
        </nav>
      </aside>
      <main className="flex-1">
        {children}  {/* The page renders here */}
      </main>
    </div>
  )
}

The layout stack for /dashboard/settings looks like:

code
RootLayout (app/layout.tsx)
  └── DashboardLayout (app/dashboard/layout.tsx)
        └── SettingsPage (app/dashboard/settings/page.tsx)

template.tsx vs layout.tsx: template.tsx has the same API as layout.tsx — same props, same structure. The difference: layouts persist across navigations. Templates remount. Each navigation creates a new template instance. Use templates when you want enter/exit animations per navigation, or when you need effects to re-run on navigation (like analytics page-view tracking).


Chapter 5: Dynamic Routes — Every Pattern Explained

Single Dynamic Segment

code
app/users/[id]/page.tsx   →   /users/123, /users/abc, /users/anything

The folder name in square brackets is the parameter name. In the page, you await params to get it.

typescript
// app/users/[id]/page.tsx
export default async function UserPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  // id = '123' for /users/123
  return <div>User: {id}</div>
}

Multiple Dynamic Segments

code
app/users/[userId]/posts/[postId]/page.tsx  →  /users/1/posts/5
typescript
params: Promise<{ userId: string; postId: string }>

Catch-All Routes

code
app/docs/[...slug]/page.tsx  →  /docs/a, /docs/a/b, /docs/a/b/c

slug is an array of all path segments:

  • /docs/intro → slug = ['intro']
  • /docs/api/users → slug = ['api', 'users']
  • /docs/api/users/create → slug = ['api', 'users', 'create']

Use case: documentation sites where the URL is the path in a file tree.

Optional Catch-All Routes

code
app/docs/[[...slug]]/page.tsx  →  /docs (slug undefined), /docs/a, /docs/a/b

The double brackets make the catch-all optional. The route also matches the base path with no segments. slug is undefined when no segments are provided.

Why These Patterns?

Standard dynamic [id] covers the vast majority of cases: resource detail pages, profile pages, any URL with a single variable component.

Catch-all [...slug] is specifically for hierarchical content — documentation, CMS pages, file explorers — where the URL depth is variable and the path itself is meaningful data.

Precedence: When multiple dynamic routes could match a URL, Next.js resolves in this order:

  1. Static routes win: /users/settings is matched by app/users/settings/page.tsx before app/users/[id]/page.tsx
  2. Dynamic routes: [id] is more specific than [...slug]
  3. Catch-all routes: [...slug] comes last

Chapter 6: Route Groups — Organisation Without URL Impact

The problem: /dashboard/settings and /dashboard/analytics share a layout. But /auth/login and /auth/register need a completely different layout (no sidebar, centred content). How do you give different sections of your app different layouts without reflecting the grouping in the URL?

Route groups: wrap a folder name in parentheses. It becomes organisationally significant but URL-invisible.

code
app/
├── (marketing)/
│   ├── layout.tsx        ← Full-width marketing layout
│   ├── page.tsx          → /  (not /(marketing)/)
│   ├── pricing/
│   │   └── page.tsx      → /pricing
│   └── about/
│       └── page.tsx      → /about
├── (app)/
│   ├── layout.tsx        ← App layout with sidebar
│   ├── dashboard/
│   │   └── page.tsx      → /dashboard
│   └── settings/
│       └── page.tsx      → /settings
└── (auth)/
    ├── layout.tsx        ← Centred, minimal auth layout
    ├── login/
    │   └── page.tsx      → /login
    └── register/
        └── page.tsx      → /register

(marketing), (app), and (auth) are in the URL nowhere. The URLs are /, /pricing, /dashboard, /login — clean and unsegmented. But each group has its own layout.

Mental model: Route groups are folders for humans and build tools. They are invisible to users. Use them to organise large apps and apply different layouts to different sections.


Chapter 7: Parallel Routes and Intercepting Routes

These are advanced. Most apps never need them. But when you need them, nothing else will do.

Parallel Routes — Multiple Pages in One Layout

The problem: A dashboard wants to show an analytics chart and a recent activity feed simultaneously. Both are complex, both fetch their own data, both have their own loading states. You want them to load independently — if analytics is slow, the activity feed should still show.

Parallel routes: define multiple "slots" in a layout. Each slot is an independent, simultaneously rendered page.

code
app/dashboard/
├── layout.tsx
├── page.tsx                     → default slot
├── @analytics/
│   ├── page.tsx                 → analytics slot
│   └── loading.tsx
└── @activity/
    ├── page.tsx                 → activity slot
    └── loading.tsx

Folders prefixed with @ are "slots." The layout receives them as props:

typescript
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  activity,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  activity: React.ReactNode
}) {
  return (
    <div>
      <div className="main">{children}</div>
      <div className="grid grid-cols-2">
        {analytics}  {/* renders @analytics/page.tsx */}
        {activity}   {/* renders @activity/page.tsx */}
      </div>
    </div>
  )
}

Each slot streams independently. @analytics has its own loading.tsx — if analytics data takes 2 seconds, a skeleton shows there while @activity renders in 200ms.

Intercepting Routes — Modals That Are Also Pages

The problem: Instagram's photo feed. Click a photo from the feed: it opens in a modal, URL changes to /photos/123. Refresh the page: the photo opens as a full page, no modal. The same URL has two different presentations depending on how you arrived.

Intercepting routes: a route that "intercepts" navigation to another route if the navigation happens client-side (within the app). Direct URL access bypasses the interception.

code
app/
├── feed/
│   └── page.tsx                  → /feed (shows photo grid)
├── photos/
│   └── [id]/
│       └── page.tsx              → /photos/123 (full photo page)
└── feed/
    └── (.)photos/
        └── [id]/
            └── page.tsx          → /photos/123 opened from /feed = modal

(.)photos/[id] intercepts navigation to photos/[id] when navigated from the same level ((.) = same level). When you click a photo in the feed:

  • URL changes to /photos/123
  • The intercepting route renders a modal overlay on top of the feed
  • Refreshing hits /photos/123 directly, which renders the full page component

This is the "modal with shareable URL" pattern. Used by Figma, Pinterest, Instagram, GitHub (file viewer modal).


Chapter 8: Loading, Error, and Not-Found — Automatic UI States

loading.tsx — Streaming Skeletons

typescript
// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/3 mb-4" />
      <div className="h-4 bg-gray-200 rounded w-full mb-2" />
      <div className="h-4 bg-gray-200 rounded w-5/6" />
    </div>
  )
}

When Next.js renders a route that fetches data (an async Server Component), it wraps the page in a Suspense boundary. While the page is loading (data fetching in progress), Next.js streams the loading.tsx fallback to the browser instantly. The actual page content streams in when ready.

The user sees the skeleton immediately — zero blank screen — even on slow data connections.

Why is this automatic? The loading.tsx file is syntactic sugar for:

typescript
<Suspense fallback={<DashboardLoading />}>
  <DashboardPage />
</Suspense>

Next.js inserts this Suspense boundary automatically when it finds a loading.tsx file.

Critical: The layout above the loading segment is already rendered and painted before the skeleton appears. The user sees the sidebar, the header, the navigation — then the skeleton, then the content. The shell is instant. Only the content-specific part is deferred.

error.tsx — Error Boundaries

typescript
// app/dashboard/error.tsx
'use client'  // Error boundaries MUST be Client Components

import { useEffect } from 'react'

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void  // retry function
}) {
  useEffect(() => {
    // Log to your error tracking service (Sentry, etc.)
    console.error(error)
  }, [error])

  return (
    <div>
      <h2>Something went wrong in the dashboard</h2>
      <button onClick={reset}>Try again</button>
    </div>
  )
}

error.tsx wraps its route segment in a React Error Boundary. If any component in the subtree throws (during render or in a Server Action), the error boundary catches it and renders this fallback instead of crashing the page.

Why must error boundaries be Client Components? React Error Boundaries use componentDidCatch and getDerivedStateFromError — lifecycle methods that only exist in the client-side React runtime.

error.digest: a hash of the error that Next.js logs on the server. In production, you never expose the full error message to the client (security). The digest lets you correlate client-visible errors with server logs.

Error boundary hierarchy: errors bubble up to the nearest error boundary. If app/dashboard/users/page.tsx throws and there's no app/dashboard/users/error.tsx, it bubbles up to app/dashboard/error.tsx. If that doesn't exist, it bubbles up to app/error.tsx. If that doesn't exist, it reaches app/global-error.tsx (which replaces the entire root layout).

not-found.tsx

typescript
// app/users/[id]/not-found.tsx
export default function UserNotFound() {
  return (
    <div>
      <h2>User not found</h2>
      <a href="/users">Back to users</a>
    </div>
  )
}

Triggered by calling notFound() from next/navigation in a page or server action:

typescript
import { notFound } from 'next/navigation'

export default async function UserPage({ params }: Props) {
  const { id } = await params
  const user = await fetchUser(id)

  if (!user) {
    notFound()  // renders not-found.tsx, returns 404 status
  }

  return <div>{user.name}</div>
}

Why notFound() instead of returning null or rendering an error? notFound() does two things: renders the nearest not-found.tsx and sets the HTTP response status to 404. Returning null would render a blank page with a 200 status — search engines would index it as a valid page with no content.


Part 2: The Core Mental Model — Server vs Client

Chapter 9: Server Components vs Client Components

This is the most important concept in Next.js App Router. Everything else is downstream of understanding this.

The Default: Server Components

In the App Router, every component is a Server Component by default. This includes your page.tsx files, your layout.tsx files, and any component file you create.

A Server Component:

  • Runs only on the server (during the request, or at build time for static routes)
  • Has direct access to: databases, the filesystem, environment variables, server-only APIs
  • Ships zero JavaScript to the browser — none. Its code never appears in the client bundle.
  • Cannot use: useState, useEffect, useReducer, any other React hook
  • Cannot use: browser APIs (window, document, localStorage)
  • Cannot attach event listeners (onClick, onChange, etc.)
  • Can be async — you can await database queries directly in the component body
typescript
// app/users/page.tsx
// This is a Server Component. No 'use client' directive = Server Component.

import { db } from '@/lib/db'  // Direct database import. Safe. Never sent to browser.

export default async function UsersPage() {
  // Direct database query. No API route. No useEffect. No loading state.
  // This runs on the server. The user's browser never sees this code.
  const users = await db.query('SELECT id, name FROM users ORDER BY created_at DESC')

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

The rendered HTML is what ships to the browser. The SQL query, the database import, the db module — none of that JavaScript is in the client bundle.

'use client' — Opting Into the Client

To use React hooks, event handlers, or browser APIs, you must add 'use client' at the top of the file. This marks the file as a Client Component.

typescript
'use client'
// This directive MUST be the first line of the file (before imports)

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)

  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  )
}

A Client Component:

  • Its code IS included in the JavaScript bundle sent to the browser
  • Can use all React hooks
  • Can use all browser APIs
  • Can attach event listeners
  • Cannot be async (client components must return synchronously — they run during React's synchronous render cycle in the browser)
  • Cannot directly access databases or server-only secrets

The Boundary — The Most Misunderstood Rule

'use client' marks a boundary in the component tree, not just a single component.

When Next.js encounters 'use client' in a file, it says: "this component and everything imported by this component runs on the client." The 'use client' directive propagates downward through imports.

code
ServerComponent          ← Server. Runs on server only.
  └── 'use client'       ← Client boundary. Everything below is client.
        └── ClientA      ← Client. Even without 'use client', it's in a client file.
              └── ClientB ← Client. Imported by a client file = client.

You cannot import a Server Component from a Client Component. The client bundle is sent to the browser. If a Client Component imports a Server Component, the Server Component's code would need to be in the client bundle. But Server Components contain database credentials, SQL queries, filesystem access — code that must never reach the browser.

typescript
'use client'

// ❌ This is wrong. Server Component imported into Client Component.
import { ServerComponent } from './ServerComponent'

// Next.js will error: you cannot import a Server Component into a Client Component

But you CAN pass Server Components as props to Client Components:

typescript
// app/page.tsx (Server Component)
import { ClientShell } from '@/components/ClientShell'
import { ServerData } from '@/components/ServerData'  // Server Component

export default function Page() {
  return (
    <ClientShell>
      {/* ServerData is passed as children — it is not imported by the Client Component */}
      <ServerData />
    </ClientShell>
  )
}
typescript
// components/ClientShell.tsx (Client Component)
'use client'

export function ClientShell({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(true)

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}  {/* children is the ServerData component — rendered on server */}
    </div>
  )
}

ServerData is not imported by ClientShell. It is passed as a prop (children). React's rendering model allows this: the server renders ServerData, the result is passed into ClientShell as opaque content. The client shell manages interactivity; the server component manages data.

The Decision Framework

Ask these questions for every component:

Does it need user interaction? (click, hover, typing, focus) → Yes → 'use client'

Does it need React state? (useState, useReducer) → Yes → 'use client'

Does it need lifecycle effects? (useEffect, useLayoutEffect) → Yes → 'use client'

Does it need browser APIs? (window, document, navigator, localStorage) → Yes → 'use client'

None of the above? → Keep it as a Server Component

The principle: Push the 'use client' boundary as deep into the tree as possible. Keep as much as possible on the server. The client bundle should contain only the interactive pieces.

A common mistake: wrapping an entire page in 'use client' because one small button needs useState. The entire page's code goes into the client bundle, including any database utilities you might have accidentally imported. Instead:

typescript
// app/dashboard/page.tsx — Server Component (no 'use client')
import { LikeButton } from '@/components/LikeButton'  // This one is 'use client'

export default async function DashboardPage() {
  const posts = await db.getPosts()  // Server-only. Never in bundle.

  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <LikeButton postId={post.id} />  {/* Only this small component is client */}
        </article>
      ))}
    </div>
  )
}

Mental model: Server Components are free — zero bundle cost. Client Components pay a bundle cost. Minimise the area of the tree that is client-side. Interactive leaves, server-rendered trunk.


Part 3: Rendering Strategies

Chapter 10: Static, Dynamic, and Streaming — How Next.js Decides

Every page in Next.js is rendered using one of three strategies. Understanding when each applies — and how to control it — is fundamental to Next.js performance.

Static Rendering (Default)

If Next.js can determine at build time that a page always produces the same output, it pre-renders it. The HTML is built once during next build and cached forever (until the next build).

typescript
// app/about/page.tsx
// No dynamic data fetching. No user-specific data. Pure static content.
export default function AboutPage() {
  return <div>About our company. Founded in 2020.</div>
}

This page is rendered once at build time. Served from the CDN edge. The user receives HTML in single-digit milliseconds. No server computation at request time.

How Next.js detects static pages: it looks for "dynamic signals" in your code. If none are present, the page is static.

Dynamic Rendering

If Next.js detects any of the following, it opts the route into dynamic rendering — rendered fresh on every request:

  1. cookies() — accessing request cookies (per-user data)
  2. headers() — accessing request headers
  3. searchParams — accessed in page (query string values are per-request)
  4. fetch() with { cache: 'no-store' } — data that must be fresh on every request
  5. noStore() from next/cache — explicit opt-in
typescript
import { cookies } from 'next/headers'

export default async function ProfilePage() {
  // Accessing cookies opts this route into dynamic rendering
  const cookieStore = await cookies()
  const sessionToken = cookieStore.get('session')

  const user = await getUserFromSession(sessionToken?.value)

  return <div>Hello, {user.name}</div>
}

Why does accessing cookies make the page dynamic? Because cookies are per-user and per-request. The same URL visited by different users returns different content. Next.js cannot pre-render this at build time without knowing who the user is.

Streaming

Streaming is not an alternative to static/dynamic — it is a delivery mechanism that works with dynamic rendering.

Without streaming: Next.js renders the entire page on the server, then sends all the HTML at once. If a component takes 2 seconds to fetch data, the user waits 2 seconds before seeing anything.

With streaming: Next.js sends the layout and shell of the page immediately (whatever doesn't depend on slow data), then streams in the data-dependent content as it becomes ready.

typescript
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserProfile } from '@/components/UserProfile'      // Fast: 100ms
import { ActivityFeed } from '@/components/ActivityFeed'    // Slow: 2000ms
import { ActivitySkeleton } from '@/components/ActivitySkeleton'

export default function DashboardPage() {
  return (
    <div>
      {/* UserProfile streams in at ~100ms */}
      <Suspense fallback={<div>Loading profile...</div>}>
        <UserProfile />
      </Suspense>

      {/* ActivityFeed streams in at ~2000ms. Skeleton shows immediately. */}
      <Suspense fallback={<ActivitySkeleton />}>
        <ActivityFeed />
      </Suspense>
    </div>
  )
}

What the browser receives:

  • Immediately: the page shell (layout, navigation, Suspense fallback skeletons)
  • At 100ms: UserProfile's HTML streams in, replaces its skeleton
  • At 2000ms: ActivityFeed's HTML streams in, replaces its skeleton

The user is never looking at a blank page. Progressive, streamed rendering.

Why Suspense? Suspense is React's mechanism for expressing "this subtree is not ready yet." When an async Server Component inside a Suspense boundary is fetching data, React suspends that subtree and renders the fallback. When the data resolves, React streams the completed subtree to the client.


Chapter 11: fetch() in Next.js — Extended Semantics

Next.js extends the native fetch() API with caching and revalidation options. Understanding this is critical to understanding Next.js data fetching.

The Native Fetch Problem

Standard fetch() has no caching beyond HTTP headers. In a server-rendered app, every request from every user triggers every fetch call in your server components from scratch. If 1,000 users visit /products simultaneously, you make 1,000 calls to your products API.

Next.js solves this with a fetch cache — a server-side request memoisation and persistent cache that works between requests and across the build.

The Options

typescript
// 1. Cached (default for GET requests in Next.js)
// Result cached indefinitely. Re-used for all subsequent requests.
// Equivalent to: HTTP Cache-Control: max-age=31536000
const data = await fetch('https://api.example.com/products')

// 2. No cache — fetch fresh on every request
// Use for: user-specific data, real-time data
const data = await fetch('https://api.example.com/products', {
  cache: 'no-store'
})

// 3. Time-based revalidation — cache for N seconds, then refetch
// Use for: data that changes infrequently (product catalogue, blog posts)
const data = await fetch('https://api.example.com/products', {
  next: { revalidate: 3600 }  // Cache for 1 hour
})

// 4. Tag-based revalidation — cache indefinitely, invalidate on demand
// Use for: data you want to invalidate explicitly (e.g., after a CMS update)
const data = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] }
})

Request Memoisation

Within a single request, if the same fetch() URL is called multiple times (across different components in the tree), Next.js deduplicates — only one actual HTTP request is made.

typescript
// app/layout.tsx
async function getUser() {
  return fetch('https://api.example.com/user/me').then(r => r.json())
}
export default async function Layout() {
  const user = await getUser()  // Makes the HTTP request
  /* ... */
}

// app/dashboard/page.tsx
async function getUser() {
  return fetch('https://api.example.com/user/me').then(r => r.json())
}
export default async function DashboardPage() {
  const user = await getUser()  // Memoised — uses the cached result from Layout
  /* ... */
}

Both Layout and DashboardPage call getUser(). Only one HTTP request fires. This is request memoisation — scoped to a single render pass.

When Not to Use Fetch Extensions

The Next.js fetch() extensions work for HTTP requests. For database queries (Prisma, Drizzle, raw SQL), they don't apply. Use the unstable_cache function instead:

typescript
import { unstable_cache } from 'next/cache'

// Wrap your database query in a cache
const getCachedUser = unstable_cache(
  async (id: string) => {
    return db.user.findUnique({ where: { id } })
  },
  ['user'],           // cache key
  {
    revalidate: 60,   // seconds
    tags: ['users'],  // for on-demand invalidation
  }
)

export default async function UserPage({ params }: Props) {
  const { id } = await params
  const user = await getCachedUser(id)
  return <div>{user.name}</div>
}

Chapter 12: Revalidation — How Caches Stay Fresh

Two mechanisms. Use both appropriately.

Time-Based Revalidation (ISR — Incremental Static Regeneration)

typescript
// Page-level revalidation config
export const revalidate = 3600  // revalidate this entire route every 1 hour

// OR per-fetch
const data = await fetch(url, { next: { revalidate: 3600 } })

How it works:

  1. First request: page is rendered and cached
  2. Subsequent requests within 3600s: served from cache instantly
  3. After 3600s: cache is marked stale
  4. Next request: served the stale cache immediately (user gets fast response), AND triggers a background re-render
  5. Re-render completes: new cache replaces the old
  6. The request after that: gets the fresh cache

This is stale-while-revalidate at the page level. Users always get a fast response (from cache). The cache is silently refreshed in the background. This is the correct pattern for content that changes occasionally — blog posts, product pages, documentation.

On-Demand Revalidation (Tag-Based)

Tag-based revalidation lets you invalidate specific caches when data changes, regardless of time.

typescript
// In your data fetching — assign a tag
const posts = await fetch('/api/posts', { next: { tags: ['posts'] } })

// In a Server Action or Route Handler — invalidate the tag
import { revalidateTag } from 'next/cache'

export async function publishPost(postId: string) {
  await db.post.update({ where: { id: postId }, data: { published: true } })
  revalidateTag('posts')  // All caches tagged 'posts' are invalidated immediately
}

When you call revalidateTag('posts'), every cached fetch/query tagged with 'posts' is immediately invalidated. The next request to any page that uses those caches will trigger a fresh render.

Use case: a CMS publishes a new blog post. You call revalidateTag('posts'). Every page that shows post data is immediately invalidated and regenerated on the next request. No time-based delay. Instant propagation.


Part 4: Data Mutations — Server Actions

Chapter 13: Server Actions — Forms and Mutations from First Principles

The Problem Before Server Actions

The traditional React way to handle a form submission:

  1. User fills form
  2. onSubmit handler fires
  3. Client-side fetch('/api/submit', { method: 'POST', body: ... })
  4. API route handler validates, mutates database, returns response
  5. Client updates UI based on response

This requires: an API route, a useEffect or event handler, manual error handling, manual loading states. That's 50-100 lines for something conceptually simple: user submits data, database updates, UI reflects the change.

Server Actions collapse this into the component itself.

What a Server Action Is

A Server Action is an async function marked with 'use server' that runs exclusively on the server, but can be called directly from the client.

typescript
// app/posts/new/page.tsx

// Option 1: inline Server Action
export default function NewPostPage() {
  async function createPost(formData: FormData) {
    'use server'  // This function runs on the server. Always.

    const title = formData.get('title') as string
    const content = formData.get('content') as string

    // Direct database access. No API route needed.
    await db.post.create({ data: { title, content } })

    // Redirect after success
    redirect('/posts')
  }

  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" />
      <textarea name="content" placeholder="Content" />
      <button type="submit">Publish</button>
    </form>
  )
}

When the form is submitted:

  1. The browser POSTs the form data to a Next.js internal endpoint
  2. Next.js invokes the createPost function on the server
  3. The database is updated
  4. redirect('/posts') causes a server-side redirect
  5. The browser navigates to /posts

The client sent no manual fetch(). You wrote no API route. The database call runs on the server with full server privileges.

'use server' in a Separate File

For reuse across multiple components:

typescript
// app/actions/posts.ts
'use server'
// Every export from this file is a Server Action

import { db } from '@/lib/db'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string

  if (!title || title.length < 3) {
    return { error: 'Title must be at least 3 characters' }
  }

  await db.post.create({ data: { title } })
  revalidatePath('/posts')  // Invalidate the posts list cache
  redirect('/posts')
}

export async function deletePost(postId: string) {
  await db.post.delete({ where: { id: postId } })
  revalidatePath('/posts')
}

Server Actions in Client Components

Server Actions can be called from Client Components too — this is where they become very powerful.

typescript
'use client'

import { useState } from 'react'
import { createPost } from '@/app/actions/posts'

export function CreatePostForm() {
  const [error, setError] = useState<string | null>(null)
  const [isPending, setIsPending] = useState(false)

  async function handleSubmit(formData: FormData) {
    setIsPending(true)
    const result = await createPost(formData)  // Calls the Server Action
    setIsPending(false)

    if (result?.error) {
      setError(result.error)
    }
  }

  return (
    <form action={handleSubmit}>
      {error && <p className="text-red-500">{error}</p>}
      <input name="title" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}

useFormState and useFormStatus — React's Built-In Form Utilities

typescript
'use client'

import { useActionState } from 'react'  // React 19+
import { createPost } from '@/app/actions/posts'

export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, {
    error: null,
    success: false,
  })

  return (
    <form action={formAction}>
      {state.error && <p className="text-red-500">{state.error}</p>}
      <input name="title" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}

useActionState manages the server action's state (pending, result) in the client component. The isPending is true from the moment the form is submitted until the server action completes.

Server Action Security

Server Actions are public endpoints. A malicious user can POST to the internal URL that Next.js creates for your Server Action with arbitrary data.

Always validate input. Always check authorisation. Never trust formData blindly.

typescript
'use server'

import { auth } from '@/lib/auth'  // Your auth library
import { z } from 'zod'

const CreatePostSchema = z.object({
  title: z.string().min(3).max(100),
  content: z.string().min(10),
})

export async function createPost(formData: FormData) {
  // 1. Check authentication
  const session = await auth()
  if (!session) {
    return { error: 'Unauthorised' }
  }

  // 2. Validate and parse input
  const result = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  })

  if (!result.success) {
    return { error: result.error.flatten().fieldErrors }
  }

  // 3. Only now touch the database
  await db.post.create({
    data: {
      ...result.data,
      authorId: session.user.id,
    },
  })

  revalidatePath('/posts')
  redirect('/posts')
}

Part 5: Route Handlers (API Routes)

Chapter 14: When to Use Route Handlers vs Server Actions

Route Handlers are the App Router's API routes. They let you create HTTP endpoints at specific URLs.

Server Actions vs Route Handlers — the decision:

Use Server Actions when:

  • You're mutating data from a form or button
  • You want the action to integrate with React's form primitives
  • The caller is your own frontend

Use Route Handlers when:

  • You're building an API consumed by third parties (mobile apps, other services)
  • You need to handle webhooks (Stripe, GitHub, etc.)
  • You need full control over the HTTP response (custom headers, status codes)
  • The caller is not a React component (cron job, external service)

Creating a Route Handler

A route.ts file (cannot coexist with page.tsx in the same folder) at a given path defines an HTTP handler for that path.

typescript
// app/api/users/route.ts  → HTTP handler at /api/users

import { NextRequest, NextResponse } from 'next/server'

// Handles GET /api/users
export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const query = searchParams.get('q')

  const users = await db.user.findMany({
    where: query ? { name: { contains: query } } : {},
  })

  return NextResponse.json(users)  // Automatic serialisation + Content-Type header
}

// Handles POST /api/users
export async function POST(request: NextRequest) {
  const body = await request.json()

  const user = await db.user.create({ data: body })

  return NextResponse.json(user, { status: 201 })
}

Dynamic Route Handlers

typescript
// app/api/users/[id]/route.ts

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const user = await db.user.findUnique({ where: { id } })

  if (!user) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }

  return NextResponse.json(user)
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  await db.user.delete({ where: { id } })

  return new NextResponse(null, { status: 204 })  // No content
}

Webhooks — A Real-World Route Handler Use Case

typescript
// app/api/webhooks/stripe/route.ts

import Stripe from 'stripe'
import { headers } from 'next/headers'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: NextRequest) {
  const body = await request.text()  // Must be raw text, not parsed JSON
  const headersList = await headers()
  const signature = headersList.get('stripe-signature')!

  let event: Stripe.Event

  try {
    // Verify the webhook signature — confirms the request is from Stripe
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object
      await db.subscription.create({ data: { userId: session.client_reference_id! } })
      break
    }
    case 'customer.subscription.deleted': {
      await db.subscription.delete({ where: { stripeId: event.data.object.id } })
      break
    }
  }

  return NextResponse.json({ received: true })
}

Part 6: State in a Next.js Application

Chapter 15: The State Topology in Next.js

The curriculum's state taxonomy applies directly here:

code
State in a Next.js App
├── URL State         → useRouter, useSearchParams, usePathname
├── Server State      → React Query / SWR (client) or fetch (server)
├── Client UI State   → useState, useReducer, Zustand
└── Form State        → useActionState, useOptimistic

URL State — The Most Underused

URL state is state stored in the URL (path params, query params). It is:

  • Preserved on refresh
  • Shareable via link
  • Navigable (back/forward)
  • Server-readable (no hydration needed)
typescript
'use client'

import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { useCallback } from 'react'

export function ProductFilters() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()

  const setFilter = useCallback(
    (key: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString())
      params.set(key, value)
      router.push(`${pathname}?${params.toString()}`)
    },
    [router, pathname, searchParams]
  )

  return (
    <div>
      <button onClick={() => setFilter('category', 'electronics')}>
        Electronics
      </button>
      <button onClick={() => setFilter('sort', 'price-asc')}>
        Price: Low to High
      </button>
    </div>
  )
}

The filter state lives in the URL: /products?category=electronics&sort=price-asc. Refresh: same filters. Share the link: same filters. The server can read these params and pre-filter data server-side.

This is more powerful than useState for filter/sort state. With useState, filters reset on refresh. With URL state, they don't.

Zustand — Client-Side Global State

For state that is genuinely client-specific, not server-derived, and needs to be shared across components:

typescript
// store/ui.ts
import { create } from 'zustand'

interface UIStore {
  sidebarOpen: boolean
  theme: 'light' | 'dark'
  toggleSidebar: () => void
  setTheme: (theme: 'light' | 'dark') => void
}

export const useUIStore = create<UIStore>((set) => ({
  sidebarOpen: true,
  theme: 'light',
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  setTheme: (theme) => set({ theme }),
}))
typescript
'use client'

import { useUIStore } from '@/store/ui'

export function Sidebar() {
  const { sidebarOpen, toggleSidebar } = useUIStore()

  return (
    <aside className={sidebarOpen ? 'w-64' : 'w-0'}>
      <button onClick={toggleSidebar}>Toggle</button>
      {/* ... */}
    </aside>
  )
}

Server State — React Query with Next.js

For data that lives in a server, changes over time, and must be kept fresh:

typescript
// providers/QueryProvider.tsx
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'

export function QueryProvider({ children }: { children: React.ReactNode }) {
  // Each browser tab gets its own QueryClient — important for isolation
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,  // 1 minute before considered stale
        gcTime: 10 * 60 * 1000, // 10 minutes before garbage collected
      },
    },
  }))

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}
typescript
// app/layout.tsx
import { QueryProvider } from '@/providers/QueryProvider'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <QueryProvider>
          {children}
        </QueryProvider>
      </body>
    </html>
  )
}
typescript
'use client'

import { useQuery } from '@tanstack/react-query'

export function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
  })

  if (isLoading) return <Skeleton />
  if (error) return <ErrorMessage />
  return <div>{user.name}</div>
}

Part 7: Performance

Chapter 16: The Next.js Image Component — Why It Exists and What It Does

typescript
import Image from 'next/image'

// ❌ Wrong: native <img> tag bypasses all optimisation
<img src="/hero.jpg" alt="Hero" />

// ✅ Correct: Next.js Image handles everything
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority          // For above-fold images: preloads immediately
/>

What next/image does automatically:

  1. Format conversion: serves WebP or AVIF to browsers that support them. JPEG/PNG fallback for others. WebP is ~30% smaller than JPEG at equal quality.
  2. Responsive resizing: generates multiple sizes of the image. Serves the appropriate size per device via srcset. A mobile user doesn't download a 2400px image.
  3. Lazy loading by default: images below the fold load only when they approach the viewport. Saves bandwidth and improves initial load performance.
  4. Prevents layout shift (CLS): because you declare width and height, the browser reserves space before the image loads. No content jumping.
  5. Cache headers: serves images with long-lived cache headers from Next.js's image optimisation API.

Fill Layout for Unknown Dimensions

When you don't know the image dimensions (user uploads, CMS images):

typescript
<div className="relative w-full h-64">  {/* Container must have explicit size */}
  <Image
    src={post.coverImage}
    alt={post.title}
    fill                         // Fills the container
    className="object-cover"    // CSS: cover the container, maintain aspect ratio
  />
</div>

Priority Images

Always set priority on your LCP image (the largest visible image above the fold):

typescript
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1920}
  height={1080}
  priority  // Injects <link rel="preload"> — browser fetches this image ASAP
/>

Without priority, the image is lazy loaded. For the image that determines your LCP score, lazy loading means the user waits longer to see the most important visual on the page.


Chapter 17: Fonts — How Web Fonts Block Rendering and How Next.js Fixes It

Loading a web font naively:

html
<link href="https://fonts.googleapis.com/css2?family=Roboto" rel="stylesheet">

Problems:

  1. DNS lookup + TCP connection to fonts.googleapis.com — extra latency
  2. Render-blocking: the stylesheet must be fetched before the browser can finish building CSSOM
  3. Font swap (FOUT): the browser renders with a fallback font, then swaps to the loaded font. Text jumps. This is CLS.
  4. Privacy: Google Fonts tracks page visits
typescript
// app/layout.tsx
import { Inter, Playfair_Display } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',      // CSS variable for use in Tailwind
  display: 'swap',               // Show fallback font, swap when loaded (FOUT, but no FOIT)
})

const playfair = Playfair_Display({
  subsets: ['latin'],
  variable: '--font-playfair',
  weight: ['400', '700'],
})

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={`${inter.variable} ${playfair.variable}`}>
      <body className={inter.className}>
        {children}
      </body>
    </html>
  )
}

What next/font does:

  1. Downloads the font at build time and self-hosts it from your own domain. No Google Fonts DNS lookup at runtime.
  2. Generates optimal @font-face declarations with correct subset and format targeting.
  3. Automatically inlines the critical font CSS — no extra network request for the font stylesheet.
  4. Size-adjust for zero CLS: generates a size-adjust CSS property on the fallback font that matches the loaded font's metrics exactly. When the real font loads, the text takes up identical space. No layout shift.

Chapter 18: Metadata — SEO and Social Sharing

Next.js provides a typed Metadata API that generates <head> elements correctly.

typescript
// app/posts/[id]/page.tsx
import type { Metadata } from 'next'

interface Props {
  params: Promise<{ id: string }>
}

// generateMetadata runs on the server, can fetch data
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { id } = await params
  const post = await db.post.findUnique({ where: { id } })

  if (!post) {
    return { title: 'Post Not Found' }
  }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage, width: 1200, height: 630 }],
      type: 'article',
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
    alternates: {
      canonical: `https://mysite.com/posts/${id}`,
    },
  }
}

export default async function PostPage({ params }: Props) {
  /* ... */
}

Generated <head>:

html
<title>My Post Title</title>
<meta name="description" content="Post excerpt..." />
<meta property="og:title" content="My Post Title" />
<meta property="og:image" content="https://..." />
<meta name="twitter:card" content="summary_large_image" />
<link rel="canonical" href="https://mysite.com/posts/123" />

Why is generateMetadata async? Because realistic metadata requires data — you need the post's actual title and cover image, not a hardcoded placeholder. generateMetadata runs on the server and can await database queries. It runs in parallel with the page render (Next.js doesn't wait for it before starting the page render).


Part 8: Middleware

Chapter 19: Middleware — The Edge Layer Before Your Routes

Middleware runs before every matching request, on the Edge (not Node.js — a stripped-down V8 runtime that boots in <1ms, globally distributed). It has access to the request (URL, headers, cookies) but not the response body or the filesystem.

typescript
// middleware.ts  ← Must be in the root of the project (same level as app/)

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname

  // --- Authentication guard ---
  const isProtectedRoute = pathname.startsWith('/dashboard')
  const sessionToken = request.cookies.get('session-token')

  if (isProtectedRoute && !sessionToken) {
    // Redirect unauthenticated users to login
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('from', pathname)  // Preserve the intended destination
    return NextResponse.redirect(loginUrl)
  }

  // --- Geolocation-based redirect ---
  const country = request.geo?.country ?? 'US'
  if (pathname === '/' && country === 'DE') {
    return NextResponse.redirect(new URL('/de', request.url))
  }

  // --- Add request ID to headers (for logging/tracing) ---
  const requestId = crypto.randomUUID()
  const response = NextResponse.next()
  response.headers.set('x-request-id', requestId)
  return response
}

// Matcher: specifies which paths this middleware runs on
// Without this, it runs on EVERY request including _next/static, _next/image, etc.
export const config = {
  matcher: [
    // Match all paths except static files and Next.js internals
    '/((?!_next/static|_next/image|favicon.ico|public).*)',
  ],
}

Why the Edge runtime? Middleware must be fast — it runs before every request. The Edge runtime starts in under 1ms (vs Node.js which can take 50-200ms to boot a serverless function). It runs at CDN edge nodes globally, meaning a user in Singapore hits an edge node in Singapore, not a server in Virginia.

Edge runtime limitations: no Node.js APIs, no filesystem access, no native modules, limited npm packages. For middleware, this is acceptable — you're doing routing logic, not business logic.


Part 9: Authentication Patterns

Chapter 20: Auth in Next.js — The Correct Architecture

Authentication in Next.js has three layers, and the mistake most engineers make is confusing them.

The Three Layers

1. Middleware layer (edge): Protects routes before they even hit your server. Fast, coarse-grained. Check for the presence of a session token. Redirect if missing.

2. Server Component layer: After middleware passes, a server component can verify the session fully (fetch user from DB, check permissions). This is where you do role-based access control.

3. Client Component layer: Display UI based on auth state. Use context/hooks to pass user data down to components that need it.

Using Auth.js (NextAuth v5)

typescript
// auth.ts  ← Auth.js configuration
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import { db } from '@/lib/db'

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  callbacks: {
    async session({ session, token }) {
      session.user.id = token.sub!  // Add user ID to session
      return session
    },
  },
})
typescript
// middleware.ts
export { auth as middleware } from '@/auth'

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*'],
}
typescript
// app/dashboard/page.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const session = await auth()

  if (!session) {
    redirect('/login')  // Second line of defence if middleware missed it
  }

  return <div>Welcome, {session.user.name}</div>
}

Part 10: Advanced Patterns

Chapter 21: Optimistic Updates — Instant UI with Server Sync

Optimistic updates: update the UI immediately on user action, without waiting for the server response. If the server succeeds (it usually does), the UI is already correct. If the server fails, roll back.

typescript
'use client'

import { useOptimistic } from 'react'
import { toggleLike } from '@/app/actions/posts'

interface Post {
  id: string
  title: string
  likes: number
  likedByUser: boolean
}

export function PostCard({ post }: { post: Post }) {
  const [optimisticPost, addOptimisticLike] = useOptimistic(
    post,
    (currentPost, action: 'like' | 'unlike') => ({
      ...currentPost,
      likes: action === 'like' ? currentPost.likes + 1 : currentPost.likes - 1,
      likedByUser: action === 'like',
    })
  )

  async function handleLike() {
    const action = optimisticPost.likedByUser ? 'unlike' : 'like'
    addOptimisticLike(action)  // Update UI instantly
    await toggleLike(post.id)  // Then sync with server (may fail)
  }

  return (
    <div>
      <h2>{optimisticPost.title}</h2>
      <button onClick={handleLike}>
        {optimisticPost.likedByUser ? '❤️' : '🤍'} {optimisticPost.likes}
      </button>
    </div>
  )
}

useOptimistic takes the current state and a reducer. When you call the updater, the UI immediately shows the optimistic state. When the server action completes, React reconciles the real state. If the server returns an error, the optimistic state reverts automatically.


Chapter 22: Streaming AI Responses — SSE in Next.js

typescript
// app/api/chat/route.ts
import { createGoogleGenerativeAI } from '@ai-sdk/google'
import { streamText } from 'ai'

const google = createGoogleGenerativeAI({
  apiKey: process.env.GOOGLE_AI_API_KEY!,
})

export async function POST(request: NextRequest) {
  const { messages } = await request.json()

  const result = await streamText({
    model: google('gemini-2.0-flash'),
    messages,
  })

  // Returns a ReadableStream as SSE
  return result.toDataStreamResponse()
}
typescript
'use client'

import { useChat } from 'ai/react'

export function ChatUI() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
    api: '/api/chat',
  })

  return (
    <div>
      <div className="messages">
        {messages.map(m => (
          <div key={m.id} className={m.role === 'user' ? 'text-right' : 'text-left'}>
            {m.content}
          </div>
        ))}
      </div>

      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Ask anything..."
        />
        <button type="submit" disabled={isLoading}>
          {isLoading ? 'Thinking...' : 'Send'}
        </button>
      </form>
    </div>
  )
}

Vercel's AI SDK handles the SSE protocol, streaming token consumption, and state management. Without it, you'd implement the ReadableStream parsing manually. The SDK is worth the dependency for AI streaming use cases.


Part 11: Deployment

Chapter 23: Build Output and Deployment Targets

bash
next build

Produces .next/ containing:

  • Pre-rendered HTML for static routes
  • Server-side JS bundles for dynamic routes
  • Client-side JS chunks for hydration
  • A manifest mapping URLs to file paths

Vercel (Optimal for Next.js)

Vercel is Next.js's creator. Their platform has native support for every Next.js feature:

  • Static routes → served from global CDN edge
  • Dynamic routes → deployed as serverless functions, per region
  • Middleware → deployed to Edge Runtime globally (100+ locations)
  • Image optimisation → handled at edge
  • ISR cache → shared across serverless function instances
bash
# Deploy to Vercel
npx vercel

Zero config. The next.config.ts is read, the output type is detected, functions are deployed.

Self-Hosted (Docker on Hetzner/VPS)

For full control, cost savings, or data residency requirements:

typescript
// next.config.ts
const nextConfig: NextConfig = {
  output: 'standalone',  // Produces a minimal self-contained server
}

output: 'standalone' generates a standalone/ directory with a minimal Node.js server that runs your app. No node_modules of hundreds of MB — only what's needed.

dockerfile
# Dockerfile
FROM node:20-alpine AS base

# Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production

# Copy only what's needed to run
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

EXPOSE 3000
CMD ["node", "server.js"]

This produces a Docker image of ~150-200MB (vs 1-2GB without standalone output).

Environment Variables

bash
# .env.local  ← Never commit. In .gitignore.
DATABASE_URL=postgresql://...
GOOGLE_CLIENT_SECRET=...
NEXTAUTH_SECRET=...

# .env.example  ← Commit this. Documents required vars without values.
DATABASE_URL=
GOOGLE_CLIENT_SECRET=
NEXTAUTH_SECRET=
typescript
// In server code (Server Components, Route Handlers, Server Actions)
// All env vars are accessible
const dbUrl = process.env.DATABASE_URL

// In client code
// Only vars prefixed with NEXT_PUBLIC_ are accessible
// They are inlined at build time into the client bundle
const publicApiUrl = process.env.NEXT_PUBLIC_API_URL

NEXT_PUBLIC_ is inlined at build time, not runtime. The value is baked into the JS bundle during next build. If you change the value, you must rebuild. Never put secrets in NEXT_PUBLIC_ variables — they are visible in the client bundle to anyone who inspects it.


Part 12: The System Design Playbook

Chapter 24: Architecting a Next.js App from a Blank Page

Given any product requirement, run this framework before writing a line of code.

Step 1: Map Routes to Route Types

For each route in your app, decide:

RouteTypeReason
/ — landing pageStaticSame for all users, rarely changes
/pricingStaticSame content, revalidate daily
/blog/[slug]ISR (1 hour)Changes occasionally, not per-user
/dashboardDynamicPer-user, real-time data
/dashboard/reportsDynamic + StreamingLong data fetch, stream components
/login, /registerStatic (form)Static HTML, Server Action for submit
/api/webhooks/*Route HandlerExternal service callbacks

Step 2: Define the Component Boundary Map

For each page, draw the server/client boundary:

code
DashboardPage (Server)
├── Sidebar (Server — no interactivity, just links)
├── UserGreeting (Server — data from session)
├── StatCards (Server — data fetch, no interaction)
├── RecentActivity (Server + Suspense — slow fetch, stream it)
└── NotificationsButton (Client — needs useState for dropdown open/close)
    └── NotificationsList (Server — passed as children)

Rule: if a component needs state or events, mark it Client. Everything else stays Server.

Step 3: Data Fetching Strategy Per Component

typescript
// Server Component — direct DB call, no API route
const stats = await db.getStats(userId)

// Server Component — external API with caching
const rates = await fetch('https://api.exchangerate.com', {
  next: { revalidate: 3600 }
})

// Client Component — real-time data
const { data } = useQuery({ queryKey: ['live-price'], refetchInterval: 5000 })

// Client Component — streaming AI
const { messages } = useChat({ api: '/api/chat' })

Step 4: Auth Architecture

code
Request arrives
  └── Middleware (Edge)
        └── Check session token presence
              ├── No token + protected route → redirect /login
              └── Token present → pass through
                    └── Server Component
                          └── Verify session in DB
                                └── Check role/permission
                                      ├── Insufficient permission → 403
                                      └── Authorised → render

Step 5: Folder Structure

code
src/
├── app/
│   ├── (auth)/           ← Auth pages group
│   ├── (marketing)/      ← Public pages group
│   ├── (app)/            ← Authenticated app group
│   ├── api/              ← Route Handlers
│   └── actions/          ← Server Actions (by domain)
├── components/
│   ├── ui/               ← Primitive UI (Button, Input, etc.)
│   └── [feature]/        ← Feature-specific components
├── lib/
│   ├── db.ts             ← Database client
│   ├── auth.ts           ← Auth config
│   └── validations.ts    ← Zod schemas
└── hooks/                ← Custom client-side hooks

Chapter 25: The 10 Most Common Next.js Mistakes

1. Putting 'use client' on every component — kills the bundle-size benefits of Server Components. Push the boundary as deep as possible.

2. Using useEffect to fetch data in Client Components when a Server Component would do — Server Components fetch on the server, no loading state, no client bundle cost.

3. Forgetting loading.tsx on slow routes — users see blank space. Takes 2 minutes to add. Add it.

4. params not awaited (Next.js 15) — params and searchParams are now Promises. const { id } = params is wrong. const { id } = await params is correct.

5. Using router.push() for form submissions — use Server Actions and redirect(). router.push() from a form handler is the wrong abstraction.

6. No input validation in Server Actions — Server Actions are public HTTP endpoints. Always validate with Zod, always check auth.

7. Long-lived revalidate on user-specific pages — if the page depends on per-user data, it should be export const revalidate = 0 (always dynamic) or use cache: 'no-store'.

8. Importing server-only modules into client components — database clients, email libraries, secret-using modules. If you accidentally import them into a Client Component, they end up in the browser bundle. Use server-only package to prevent this.

typescript
// lib/db.ts
import 'server-only'  // Will throw a build error if imported in client code

import { PrismaClient } from '@prisma/client'
export const db = new PrismaClient()

9. Ignoring the hydration error — the most common Next.js error. Cause: component renders differently on server vs client. Common culprits: Date.now(), Math.random(), window access, browser extensions modifying the DOM. Fix: use suppressHydrationWarning carefully, or move non-deterministic code into useEffect.

10. Not using next/link for internal navigation — native <a href> causes a full page reload. next/link does client-side navigation (no reload, preserves layout, prefetches the route on hover).

typescript
import Link from 'next/link'

// ❌ Full page reload
<a href="/about">About</a>

// ✅ Client-side navigation
<Link href="/about">About</Link>

Chapter 26: The Mental Models You Keep Forever

1. Server Components are free. Client Components are paid. Free = zero bundle cost. Paid = they ship their code to the browser. Minimise the paid surface area.

2. 'use client' is a boundary, not a tag. Everything below the boundary is client-side, regardless of whether the files have the directive.

3. Layouts don't re-render on navigation. Pages do. Persistent UI → layout. Route-specific UI → page.

4. The file system is the router. The folder structure is the URL structure. Parentheses are invisible. Square brackets are parameters. @ signs are slots.

5. Data freshness has a cost. Static = free but stale. ISR = cheap and freshish. Dynamic = server cost on every request. Stream = best UX, most architecture. Match freshness requirement to strategy.

6. Server Actions are endpoints. Treat them with the same security discipline as API routes. Validate input. Check auth. Never trust the client.

7. Hydration requires matching HTML. The server and client must render identically. Any browser-only API in a component that also runs on the server will cause a hydration mismatch.

8. URL state is the best state. It survives refresh, enables sharing, enables bookmarking, is server-readable. Prefer it over useState for filter, sort, tab, pagination state.

9. Caching is explicit in Next.js. Nothing is cached by accident. You declare the caching strategy. Understanding cache: 'no-store', revalidate, and tags gives you complete control.

10. The framework is not magic. Every Next.js feature is a response to a concrete constraint. Rendering strategies solve the SEO/speed problem. Server Components solve the bundle-size problem. Server Actions solve the API-boilerplate problem. Streaming solves the slow-data-blank-screen problem. Understand the problem, and the feature becomes obvious.


What You Can Now Do

After reading this book, you can:

Set up a production Next.js project from scratch — project structure, tsconfig, next.config, fonts, metadata, all correctly configured.

Design the routing architecture of any app — flat routes, nested layouts, route groups, dynamic segments, parallel slots, intercepting modals.

Make every rendering decision correctly — which routes are static, which are ISR, which are dynamic, which stream. With explicit justification, not guessing.

Write secure Server Actions — validated with Zod, authenticated, with proper cache invalidation.

Draw the server/client boundary correctly — keeping maximum code on the server, pushing the 'use client' boundary as deep as possible.

Architect the state topology — URL state for shareable state, server state for server data, client state for UI-only state.

Diagnose production problems — hydration errors (server/client mismatch), performance issues (wrong rendering strategy, no streaming, blocking fetches), bundle size issues (too much client-side code).

Deploy to Vercel or self-host on Docker — with correct environment variable setup and production-optimised Dockerfile.

Argue any Next.js architectural decision — because you know why every feature exists, you can defend or challenge any choice from first principles.


The framework changes. The reasoning doesn't. When App Router becomes App Router v2, when Server Components evolve, when new primitives arrive — you will understand them faster than everyone who memorised the current API, because you know what problems they exist to solve.