Module 5 · Next.js · Deep Dive
You can already render pages and fetch data. Now make Next.js the backend-for-frontend — mutate data with Server Actions, control caching, proxy to FastAPI, and keep tokens server-side.
BasicIntermediateBuild
/api layer for every action. Next.js 15 (App Router, 2026) gives you Server Actions, fine-grained caching, and Route Handlers to do exactly this. This is the piece that turns "a React app that calls an API" into "a full-stack app interviewers respect."
A Server Action is an async function that runs on the server but is called from the client — no fetch, no route, no JSON plumbing. You mark it with the "use server" directive, then wire it straight into a form's action.
app/docs/actions.ts
"use server" import { revalidatePath } from "next/cache" export async function addDoc(formData: FormData) { const title = formData.get("title") // runs on the server — safe to touch secrets, the DB, FastAPI await fetch("http://api:8000/docs", { method: "POST", body: JSON.stringify({ title }), headers: { "Content-Type": "application/json" }, }) revalidatePath("/docs") // refresh the list (next section) }
Call it from a server component by passing it to <form action={addDoc}>. The form posts directly to the function — Next.js handles the network for you.
app/docs/page.tsx
import { addDoc } from "./actions" export default function Page() { return ( <form action={addDoc}> <input name="title" /> <button>Add</button> </form> ) }PHP bridge: a Server Action is your old
<form method="post" action="save.php"> — server code triggered by a form submit — but type-safe, co-located, and with no separate endpoint file to route.
'use server' that run on the server and are invoked directly from the client — usually via a form's action or an event handler. They replace the boilerplate of writing an API route plus a fetch for every mutation, run in a secure context (DB, secrets), and pair with revalidatePath/revalidateTag to refresh cached data after the write."
Next.js caches rendered pages and data aggressively. After a mutation you must tell it what's now stale. Two tools:
| Function | Use it when |
|---|---|
revalidatePath("/docs") | You know the route(s) that show this data. |
revalidateTag("docs") | You tagged the fetch and want every page using it refreshed. |
// tag a fetch so it can be invalidated by name const res = await fetch("http://api:8000/docs", { next: { tags: ["docs"] }, }) // ...later, inside a Server Action after a write: revalidateTag("docs") // every page reading "docs" re-fetches
The mental model: write, then revalidate. Forget the revalidate and your UI shows yesterday's data — a classic bug interviewers probe for.
By default a route is rendered statically (once, at build) when nothing forces it to be dynamic. It becomes dynamic (rendered per request) the moment you read cookies/headers or opt a fetch out of caching. You steer this per-fetch:
// Always fresh — never cache (dynamic). Good for user-specific data. fetch(url, { cache: "no-store" }) // Cache, but re-fetch at most every 60s (incremental). fetch(url, { next: { revalidate: 60 } }) // Default in Next 15: not cached unless you opt in. fetch(url, { cache: "force-cache" })
fetch is not cached by default — you opt in with force-cache or next.revalidate. Older tutorials assume the opposite. Say this in an interview and you sound current.
cache: "no-store" ≈ a normal PHP request that hits the DB every time; revalidate: 60 ≈ a cached page you regenerate on a one-minute cron.
A slow data call shouldn't block the whole page. Wrap the slow part in <Suspense> and Next.js streams the rest immediately, slotting the slow chunk in when ready:
import { Suspense } from "react" export default function Page() { return ( <main> <h1>Your documents</h1> <Suspense fallback={<p>Loading docs…</p>}> <DocList /> {/* async server component */} </Suspense> </main> ) }
For a whole route, drop a loading.tsx beside page.tsx — Next.js shows it automatically while the page's data resolves. It's a free, route-level Suspense boundary.
Sometimes you do want a real endpoint — to proxy to FastAPI, attach a secret token, or expose a JSON API to your own client code. That's a Route Handler: a route.ts exporting HTTP-method functions.
app/api/docs/route.ts
import { NextResponse } from "next/server" export async function GET() { const res = await fetch("http://api:8000/docs", { headers: { Authorization: `Bearer ${process.env.API_TOKEN}` }, cache: "no-store", }) const data = await res.json() return NextResponse.json(data) }
The API_TOKEN lives only on the server — the browser calls /api/docs and never sees it. This is the BFF (backend-for-frontend) pattern: a thin Next.js layer in front of FastAPI that keeps secrets server-side and shapes responses for your UI.
api/docs.php), but the file name (route.ts) maps to the URL by folder — app/api/docs → /api/docs.
After login, store the JWT in an httpOnly cookie — JavaScript can't read it, so XSS can't steal it. You set and read it from server code:
import { cookies } from "next/headers" // set it (in a Server Action / Route Handler after login) (await cookies()).set("token", jwt, { httpOnly: true, secure: true, sameSite: "lax", path: "/", }) // read it later, server-side const token = (await cookies()).get("token")?.value
middleware.ts at the project root can read the same cookie to gate routes — redirect to /login when it's missing — before the page even renders. (Full auth flow comes in the capstone module; this is the shape.)
Tailwind is the 2026 default for Next.js styling — utility classes in your markup, no separate CSS files to wrangle. Set it up in one step:
# inside a Next.js app — scaffolds config + imports
npx tailwindcss init -p
Then style with utilities directly. Each class is one CSS rule; you compose them:
<button className="rounded-md bg-red-700 px-4 py-2 text-white hover:bg-red-800"> Upload </button>PHP bridge: if you ever used Bootstrap's
btn btn-primary, Tailwind is the same idea taken further — tiny single-purpose classes instead of pre-baked components.
Before shipping, two essentials:
.env — read with process.env.X. Public values get the NEXT_PUBLIC_ prefix; everything else stays server-only.next build locally — it type-checks, lints, and reports which routes are static vs dynamic. If it fails here, it fails in production.# .env.local API_TOKEN=secret-never-in-the-browser NEXT_PUBLIC_API_URL=https://api.docchat.app # build for production next build
Every public page needs a real <title> and Open Graph tags — for Google and for the link preview when someone shares it. In the App Router you never touch <head> directly; you export a metadata object and Next.js renders the tags for you.
app/about/page.tsx
import type { Metadata } from "next" // static — known at build time export const metadata: Metadata = { title: "About · DocChat", description: "Chat with your documents.", openGraph: { title: "About · DocChat", images: ["/og.png"] }, }
When the title depends on data — a doc's name, a user's profile — use the async generateMetadata instead. It receives the same params as the page and can fetch:
app/docs/[id]/page.tsx
import type { Metadata } from "next" export async function generateMetadata( { params }: { params: Promise<{ id: string }> } ): Promise<Metadata> { const { id } = await params const doc = await fetch(`http://api:8000/docs/${id}`).then(r => r.json()) return { title: `${doc.title} · DocChat` } // per-page title }PHP bridge: this is the same job you did by echoing
<title><?= $doc['title'] ?></title> into the head — but declarative, deduped, and merged with site-wide defaults from your root layout.
A middleware.ts file at the project root runs before a matched request reaches the page. The canonical use is auth gating: read the JWT cookie and redirect unauthenticated users to /login before any protected page renders.
middleware.ts
import { NextResponse } from "next/server" import type { NextRequest } from "next/server" export function middleware(req: NextRequest) { const token = req.cookies.get("token")?.value if (!token) { return NextResponse.redirect(new URL("/login", req.url)) } return NextResponse.next() } // only run on protected routes — not every request export const config = { matcher: ["/docs/:path*", "/settings/:path*"], }
req.cookies and NextResponse.redirect unauthenticated users to /login before the page renders, scoped with a matcher. But middleware only checks the cookie exists; the real check is server-side in the page or layout — verify the token and load the user there, and 401/redirect if it's invalid. You never trust the client: the redirect is UX, the server-side verification is the actual security boundary."
Two built-ins kill the most common performance and layout-shift bugs for free.
next/image optimizes images automatically: lazy-loads off-screen ones, serves responsive sizes, and — because you pass width and height — reserves the box up front so the page doesn't jump (no layout shift):
import Image from "next/image" <Image src="/hero.png" alt="DocChat" width={800} height={400} />
next/font self-hosts Google fonts at build time — no request to Google, and the font metrics are inlined so text doesn't reflow when the font loads (zero layout shift):
import { Inter } from "next/font/google" const inter = Inter({ subsets: ["latin"] }) // in the root layout <body className={inter.className}>{children}</body>PHP bridge: instead of dropping a
<link href="fonts.googleapis.com..."> and an <img> with no dimensions, the framework handles optimization, hosting, and sizing — the two things you used to fix by hand after a Lighthouse audit.
Incremental Static Regeneration (ISR) lets a page be static and stay fresh. Set a route-level revalidate and Next.js rebuilds it in the background at most that often:
// app/blog/page.tsx — regenerate at most every 60 seconds export const revalidate = 60
That's time-based. The sharper tool is on-demand revalidation: tag a fetch, then invalidate it the instant data changes — no waiting for a timer.
| Strategy | When it refreshes |
|---|---|
export const revalidate = 60 | On a timer — at most once per 60s. |
revalidateTag("docs") | Instantly, when you call it after a write. |
revalidatePath("/docs") | Instantly, for a known route. |
// tie a fetch into both: time-based AND tag-invalidatable fetch(url, { next: { revalidate: 60, tags: ["docs"] } }) // then, in a Server Action after a write, refresh it immediately: revalidateTag("docs")
fetch is not cached — you opt in with next.revalidate or cache: "force-cache". This matters because ISR now only applies to the fetches you explicitly mark: dynamic, user-specific data stays fresh automatically, and you reach for caching deliberately on the stable, shared data that benefits from it. Stating this flip signals you're on the current version.
Two file conventions give you streaming and 404s with almost no code. A loading.tsx beside page.tsx is an automatic Suspense boundary — Next.js streams it instantly while the page's data resolves:
app/docs/loading.tsx
export default function Loading() { return <p>Loading documents…</p> }
For missing data, call the notFound() helper — it stops rendering and shows the nearest not-found.tsx:
import { notFound } from "next/navigation" const doc = await getDoc(id) if (!doc) notFound() // renders app/docs/not-found.tsx, sends a 404
Together with <Suspense> (section 4), these are the building blocks of a route that loads progressively and fails gracefully — no spinners-of-doom, no blank 404s.
fetch, no manual refresh.
Try it yourself first, then compare. A clean version:
app/docs/actions.ts
"use server" import { revalidatePath } from "next/cache" import { cookies } from "next/headers" export async function uploadDoc(formData: FormData) { const token = (await cookies()).get("token")?.value // forward the multipart form straight to FastAPI await fetch(`${process.env.NEXT_PUBLIC_API_URL}/upload`, { method: "POST", body: formData, headers: { Authorization: `Bearer ${token}` }, }) revalidatePath("/docs") // the list re-renders with the new doc }
app/docs/page.tsx
import { uploadDoc } from "./actions" export default function Page() { return ( <form action={uploadDoc} className="flex gap-2"> <input type="file" name="file" required /> <button className="rounded-md bg-red-700 px-4 py-2 text-white"> Upload </button> </form> ) }
That's the whole loop: form → Server Action → FastAPI → revalidatePath → fresh list. No API route, no client state, token never leaves the server. You'll grow this into the real DocChat upload in the capstone.
Answer from memory — retrieval is what moves this from "I read it" to "I know it".
What does the "use server" directive mark?
After a mutation, how do you refresh data?
Which fetch option forces always-fresh data?
Where does an API route handler file live?
Why store the JWT in an httpOnly cookie?
Where does middleware gating belong here?
For a per-document page title, you use?