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:
- The problem — what was painful before this feature existed
- The mechanism — what Next.js actually does (not what the docs say it does — what it actually does)
- The code — real, complete examples explained line by line
- The rules — what you can and cannot do, and why
- The failure modes — how this breaks in production
- 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:
- Browser requests
yourdomain.com - Server returns
index.html— a nearly empty file with a<div id="root"></div>and a<script>tag - Browser downloads, parses, and executes your entire JS bundle
- React mounts into
#root, fetches data, renders the UI - 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:
-
Compiles your TypeScript/JSX — using either Webpack (stable) or Turbopack (fast, newer). Your
.tsxfiles become JavaScript the browser can run. -
Analyses your routes — scans the
app/directory, builds a routing map from the folder structure. -
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).
-
Generates static HTML for static routes — Next.js pre-renders every statically determinable page into
.htmlfiles. When a user requests these pages, the server returns pre-built HTML instantly. No computation at request time. -
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
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 insrc/. Keeps root clean.--import-alias "@/*": allowsimport { Button } from '@/components/Button'instead of'../../../components/Button'. Use this everywhere.
What Gets Created
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
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
{
"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 catchesundefinedaccess, 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-aliasat 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:
<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.tsxfile inside a folder makes that segment publicly accessible - A
layout.tsxfile inside a folder wraps all child routes in that segment
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:
| File | Purpose | Why it exists |
|---|---|---|
page.tsx | Route's unique content | Defines what's shown at this URL |
layout.tsx | Persistent wrapper | Shared UI that survives navigation (sidebar, header) |
loading.tsx | Streaming skeleton | Instant fallback while page data loads |
error.tsx | Error boundary | Catches errors in this route segment |
not-found.tsx | 404 UI | Custom not-found for this segment |
route.ts | API endpoint | HTTP handler (GET, POST, etc.) |
middleware.ts | Request interceptor | Runs before every request in scope |
template.tsx | Re-mounting layout | Like 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
// 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:
// 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
// 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:
// 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:
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
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.
// 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
app/users/[userId]/posts/[postId]/page.tsx → /users/1/posts/5
params: Promise<{ userId: string; postId: string }>
Catch-All Routes
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
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:
- Static routes win:
/users/settingsis matched byapp/users/settings/page.tsxbeforeapp/users/[id]/page.tsx - Dynamic routes:
[id]is more specific than[...slug] - 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.
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.
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:
// 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.
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/123directly, 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
// 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:
<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
// 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
// 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:
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 canawaitdatabase queries directly in the component body
// 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.
'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.
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.
'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:
// 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>
)
}
// 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:
// 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).
// 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:
cookies()— accessing request cookies (per-user data)headers()— accessing request headerssearchParams— accessed in page (query string values are per-request)fetch()with{ cache: 'no-store' }— data that must be fresh on every requestnoStore()fromnext/cache— explicit opt-in
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.
// 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
// 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.
// 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:
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)
// 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:
- First request: page is rendered and cached
- Subsequent requests within 3600s: served from cache instantly
- After 3600s: cache is marked stale
- Next request: served the stale cache immediately (user gets fast response), AND triggers a background re-render
- Re-render completes: new cache replaces the old
- 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.
// 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:
- User fills form
onSubmithandler fires- Client-side
fetch('/api/submit', { method: 'POST', body: ... }) - API route handler validates, mutates database, returns response
- 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.
// 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:
- The browser POSTs the form data to a Next.js internal endpoint
- Next.js invokes the
createPostfunction on the server - The database is updated
redirect('/posts')causes a server-side redirect- 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:
// 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.
'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
'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.
'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.
// 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
// 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
// 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:
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)
'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:
// 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 }),
}))
'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:
// 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>
)
}
// app/layout.tsx
import { QueryProvider } from '@/providers/QueryProvider'
export default function RootLayout({ children }) {
return (
<html>
<body>
<QueryProvider>
{children}
</QueryProvider>
</body>
</html>
)
}
'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
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:
- 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.
- 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. - Lazy loading by default: images below the fold load only when they approach the viewport. Saves bandwidth and improves initial load performance.
- Prevents layout shift (CLS): because you declare
widthandheight, the browser reserves space before the image loads. No content jumping. - 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):
<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):
<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:
<link href="https://fonts.googleapis.com/css2?family=Roboto" rel="stylesheet">
Problems:
- DNS lookup + TCP connection to
fonts.googleapis.com— extra latency - Render-blocking: the stylesheet must be fetched before the browser can finish building CSSOM
- Font swap (FOUT): the browser renders with a fallback font, then swaps to the loaded font. Text jumps. This is CLS.
- Privacy: Google Fonts tracks page visits
// 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:
- Downloads the font at build time and self-hosts it from your own domain. No Google Fonts DNS lookup at runtime.
- Generates optimal
@font-facedeclarations with correct subset and format targeting. - Automatically inlines the critical font CSS — no extra network request for the font stylesheet.
- Size-adjust for zero CLS: generates a
size-adjustCSS 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.
// 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>:
<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.
// 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)
// 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
},
},
})
// middleware.ts
export { auth as middleware } from '@/auth'
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
}
// 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.
'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
// 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()
}
'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
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
# 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:
// 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
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
# .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=
// 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:
| Route | Type | Reason |
|---|---|---|
/ — landing page | Static | Same for all users, rarely changes |
/pricing | Static | Same content, revalidate daily |
/blog/[slug] | ISR (1 hour) | Changes occasionally, not per-user |
/dashboard | Dynamic | Per-user, real-time data |
/dashboard/reports | Dynamic + Streaming | Long data fetch, stream components |
/login, /register | Static (form) | Static HTML, Server Action for submit |
/api/webhooks/* | Route Handler | External service callbacks |
Step 2: Define the Component Boundary Map
For each page, draw the server/client boundary:
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
// 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
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
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.
// 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).
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.