Module 5 · Next.js · Drills

Drills: Next.js in Production

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.

How to use this page Each drill is a small task. Attempt it first, then click “Show solution” to compare. If yours works differently but correctly — great, that's fluency. Tick each box as you go; your progress is saved in this browser.

A · Warm-up reps Basic

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.

Show solution
// 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.

Show solution
// 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.

Show solution
// 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.

B · Stretch Intermediate

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.

Show solution
// 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.

Show solution
// 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.

Show solution
// (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.

Show solution
// 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.

C · Build challenge Build

Mini-project — authed, cache-revalidating upload Assemble DocChat's full production upload flow: (1) middleware gates /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.

Show solution
// 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.

D · Rapid recall Flashcards

Click a card to flip it. Say the answer out loud before you flip — that's the rep that builds storage strength.

The four Next.js 15 caches?
Request Memoization, Data Cache, Full Route Cache, Router Cache.
click to flip
What does PPR combine on one route?
A static shell (instant) with dynamic holes streamed in via <Suspense>.
click to flip
Why authorize inside a Server Action?
It compiles to a public endpoint — anyone can call it. Check auth() every time.
click to flip
Read the current user on the server?
const session = await auth() from Auth.js v5 — works in components, actions, middleware.
click to flip
Which env vars reach the browser?
Only ones prefixed NEXT_PUBLIC_ — inlined at build. Never put secrets there.
click to flip
Self-host build output?
output: "standalone" — a minimal, traced Node server you can containerise.
click to flip

E · Self-check before moving on

Tick each only if you can do it without looking:

Next All ticked? You can now ship Next.js you'd defend in production — rendering, caching, secure mutations, and auth all under control. Continue the course to the next module and keep building toward the DocChat capstone.