Module 5 · Next.js · Drills
Reading is not knowing. Type every one of these yourself — in a scratch Next.js app — before you reveal the solution. Production muscle is built by repetition.
Drill 1 tagging & revalidate
Tag a fetch that loads the document list with "documents", then write the one line you'd call inside a Server Action after an upload so that list refreshes.
// the read — tag it const res = await fetch(`${process.env.API_URL}/docs`, { next: { tags: ["documents"] }, }) // the write — invalidate it (inside the "use server" action) import { revalidateTag } from "next/cache" revalidateTag("documents") // next render of the list refetches
The mental model: tag your reads, revalidate on your writes. Without the tag, revalidateTag has nothing to clear.
Drill 2 caching layers
Name the four Next.js 15 caches and say, for each, whether it is server-side or client-side.
// 1 · Request Memoization — server, one render pass (dedupes fetches) // 2 · Data Cache — server, persists across requests/users // 3 · Full Route Cache — server, stores rendered static routes // 4 · Router Cache — client, in the browser for fast navigation
Three live on the server, one in the browser. Saying this cleanly is a near-guaranteed interview win.
Drill 3 force-dynamic
You have a dashboard that must be re-rendered on every request. Add the route-segment export that forces dynamic rendering, and the fetch option that never caches.
// route-level: opt the whole segment out of static/route caching export const dynamic = "force-dynamic" // fetch-level: always go to the source fetch(url, { cache: "no-store" })
Reading cookies() or headers() would also force dynamic — but the explicit export makes intent obvious.
Drill 4 zod + useActionState
Write a Server Action that validates a title (1–200 chars) with zod and returns an error message on failure. Then wire it into a form with useActionState showing the error and a pending state.
// actions.ts "use server" import { z } from "zod" const Schema = z.object({ title: z.string().min(1).max(200) }) type State = { ok?: boolean; error?: string } export async function save(_p: State, fd: FormData): Promise<State> { const r = Schema.safeParse({ title: fd.get("title") }) if (!r.success) return { error: "Title must be 1–200 chars" } return { ok: true } }
// form.tsx (client) "use client" import { useActionState } from "react" import { save } from "./actions" export function Form() { const [state, action, pending] = useActionState(save, {}) return ( <form action={action}> <input name="title" /> <button disabled={pending}>{pending ? "Saving…" : "Save"}</button> {state.error && <p>{state.error}</p>} </form> ) }
Because action is a real Server Action, the form still submits if JS hasn't loaded — progressive enhancement for free.
Drill 5 middleware gate
Protect every route under /docs by redirecting users without a session cookie to /login — and make sure middleware does not run on other routes.
// middleware.ts import { NextResponse } from "next/server" import type { NextRequest } from "next/server" export function middleware(req: NextRequest) { const session = req.cookies.get("authjs.session-token") if (!session) return NextResponse.redirect(new URL("/login", req.url)) return NextResponse.next() } export const config = { matcher: ["/docs/:path*"] }
Remember the limit: this only proves the cookie exists. The real verification is await auth() server-side in the page or action.
Drill 6 choose the strategy
For each page, pick static, dynamic, or PPR — and justify it in one line:
(a) a public pricing page · (b) a per-user account dashboard · (c) a docs page with a static header but a personalised "recently viewed" widget.
// (a) Pricing → STATIC // Same HTML for everyone, rarely changes — prerender once. // (b) Dashboard → DYNAMIC // Per-user data, must be fresh — render every request // (it goes dynamic anyway once you read cookies()/auth()). // (c) Docs + widget → PPR // Static shell (header/nav) + the personalised widget in // a <Suspense> dynamic hole. Best of both.
The decision is per-region with PPR: anything personalised or slow goes in a Suspense boundary; everything else is prerendered.
Drill 7 current user
In a Server Component, read the current user with Auth.js v5 and redirect to /login if there's no session. Then greet them by name.
// app/docs/page.tsx import { auth } from "@/auth" import { redirect } from "next/navigation" export default async function Page() { const session = await auth() if (!session?.user) redirect("/login") return <h1>Welcome, {session.user.name}</h1> }
No props, no client fetch — auth() works directly on the server. This server-side guard is the real security boundary, not the middleware redirect.
/docs, (2) the upload is a zod-validated Server Action that re-checks auth(), calls FastAPI with the user's token, and revalidateTag('documents'), and (3) the list reads a tagged fetch so it refreshes the instant the upload succeeds.
Build · the whole loop
Write the middleware, the action (auth → validate → call API → revalidate), and the tagged list fetch.
// middleware.ts — gate the area export { auth as middleware } from "@/auth" export const config = { matcher: ["/docs/:path*"] }
// app/docs/actions.ts — the hardened mutation "use server" import { z } from "zod" import { revalidateTag } from "next/cache" import { auth } from "@/auth" const Schema = z.object({ title: z.string().min(1).max(200), file: z.instanceof(File).refine((f) => f.size > 0, "Empty file"), }) type State = { ok?: boolean; error?: string } export async function uploadDoc(_p: State, fd: FormData): Promise<State> { const session = await auth() // 1 · authorize if (!session?.user) return { error: "Not authenticated" } const r = Schema.safeParse({ // 2 · validate title: fd.get("title"), file: fd.get("file"), }) if (!r.success) return { error: r.error.issues[0].message } const body = new FormData() // 3 · call FastAPI body.set("title", r.data.title) body.set("file", r.data.file) const res = await fetch(`${process.env.API_URL}/upload`, { method: "POST", body, headers: { Authorization: `Bearer ${session.accessToken}` }, }) if (!res.ok) return { error: "Upload failed" } revalidateTag("documents") // 4 · refresh the list return { ok: true } }
// app/docs/doc-list.tsx — the tagged read import { auth } from "@/auth" export async function DocList() { const session = await auth() const res = await fetch(`${process.env.API_URL}/docs`, { headers: { Authorization: `Bearer ${session?.accessToken}` }, next: { tags: ["documents"] }, }) const docs = await res.json() return <ul>{docs.map((d) => <li key={d.id}>{d.title}</li>)}</ul> }
The chain — gate, authorize, validate, call, revalidate — is the exact shape of every secure mutation you'll ship. The tag on the read is what makes revalidateTag on the write actually refresh the UI.
Click a card to flip it. Say the answer out loud before you flip — that's the rep that builds storage strength.
<Suspense>.auth() every time.const session = await auth() from Auth.js v5 — works in components, actions, middleware.NEXT_PUBLIC_ — inlined at build. Never put secrets there.output: "standalone" — a minimal, traced Node server you can containerise.Tick each only if you can do it without looking:
useActionState