Module 5 · Next.js · Deep Dive

Next.js in Production

You can build features. Now ship them safely: pick the right rendering strategy per page, master the four caching layers, harden Server Actions, gate routes with Auth.js v5, and deploy without surprises.

BasicIntermediateBuild

Why this matters The gap between a demo and production is mostly two things: caching you understand and security you can defend. In DocChat, the document list must never go stale after an upload, the upload itself must reject garbage and confirm the user is logged in, and the whole /docs area must be unreachable to strangers. This lesson is the Next.js 15 (App Router, 2026) production playbook: rendering strategies including Partial Prerendering, the full caching model, locked-down Server Actions, Auth.js v5, and a real deployment story. It's where lessons 5.1 and 5.2 turn into something you'd put your name on.
In this lesson
  1. Rendering strategies & PPR
  2. The four caching layers
  3. Controlling the caches
  4. Server Actions, hardened
  5. Route Handlers vs Server Actions
  6. Middleware: gating & limits
  7. Auth.js v5 (NextAuth)
  8. error / loading / not-found
  9. Images, fonts, env & config
  10. Deployment: Vercel vs self-host
  11. Build: authed, cached DocChat upload
  12. Check yourself

1 · Rendering strategies & Partial Prerendering

Lesson 5.2 introduced static vs dynamic. In production you choose deliberately, per route, because the choice decides your latency, your hosting cost, and how fresh your data is. There are three core strategies plus the one that unifies them.

StrategyRenderedPick it for
StaticOnce, at build (or on-demand)Marketing, docs, blog — same HTML for everyone.
DynamicPer request, on the serverPer-user data: dashboards, the DocChat list.
StreamingPer request, in chunksA fast shell + slow parts that arrive late.

A route flips to dynamic automatically the moment it reads request-time data — cookies(), headers(), searchParams, or a fetch you've opted out of caching. You rarely set this by hand; you trigger it by what you read.

Streaming is the bridge: wrap a slow async component in <Suspense> and Next.js sends the surrounding HTML immediately, then streams the slow chunk in. The user sees a page in milliseconds instead of staring at a blank tab while one query runs.

Partial Prerendering (PPR) is the current direction that makes you stop choosing per route and start choosing per region. One page becomes a static shell (prerendered, served instantly from the edge) with dynamic holes (streamed in per request) wherever you put a Suspense boundary around dynamic data.

app/docs/page.tsx
import { Suspense } from "react"

export const experimental_ppr = true   // opt this route into PPR

export default function Page() {
  return (
    <main>
      <h1>Your documents</h1>        {/* static shell — instant */}
      <nav>…</nav>                       {/* static shell */}
      <Suspense fallback={<DocListSkeleton />}>
        <DocList />                      {/* dynamic hole — per user, streamed */}
      </Suspense>
    </main>
  )
}

The header, nav, and skeleton ship in the prerendered shell; the per-user list streams into its hole. You get a static page's first-paint speed and dynamic data, on one route. That's why PPR is the model to reach for in 2026: it removes the old all-or-nothing trade-off.

How to choose — the 10-second rule Same HTML for everyone and rarely changes → static. Different per user / must be fresh → dynamic. Mostly static but one slow or personalised region → PPR with that region in a <Suspense>. DocChat's /docs page is the textbook PPR case: a static chrome around a dynamic, per-user list.
PHP bridge: static ≈ a cached HTML file served by Nginx; dynamic ≈ a normal PHP page that hits the DB every request; PPR ≈ serving a cached shell instantly, then filling the personalised <?php ?> bits via a fast second pass — except the framework streams it in one response.

2 · The four caching layers

The single biggest source of "why is my data stale / why is it slow" bugs is not knowing that Next.js 15 has four distinct caches, each at a different scope and lifetime. Learn them as a stack — a request flows down through them.

CacheScopeLivesWhat it stores
Request MemoizationOne render passThat render onlyDedupes identical fetches in a single request.
Data CacheServer, all usersUntil revalidatedThe results of cached fetches / cached functions.
Full Route CacheServer, all usersUntil revalidated/redeployRendered HTML & RSC payload of static routes.
Router CacheOne user's browserSession (short)Visited route payloads, for instant back/forward.

Request Memoization is the one most people miss. Inside a single render, if three components each call fetch("/api/user"), Next.js runs the network request once and hands the same result to all three. You don't configure it — it just means you can fetch the same data wherever you need it without prop-drilling or worrying about duplicate calls.

// All three resolve from ONE actual fetch during this render:
async function getUser() {
  const res = await fetch(`${process.env.API_URL}/me`, { next: { tags: ["me"] } })
  return res.json()
}
// <Header/>, <Sidebar/>, <Page/> can each await getUser() — deduped automatically.

The Data Cache is the persistent, cross-request, cross-user store of fetch results. A cached fetch survives between requests until you revalidate it. The Full Route Cache sits on top: for a static route, Next.js caches the whole rendered output, so the route is served without re-running your components. The Router Cache is client-side — when a user navigates back to a page they just saw, the browser reuses the cached payload for an instant transition.

Interview answer · "explain Next.js caching" "Four layers. Request Memoization dedupes identical fetches within one render. The Data Cache persists fetch results across requests and users until revalidated. The Full Route Cache stores the rendered HTML/RSC of static routes. The Router Cache is the client-side cache of visited routes for instant navigation. The key 2026 detail: in Next 15 fetch isn't cached by default, so the Data and Route caches only apply where I opt in — and I invalidate them with revalidateTag / revalidatePath after a mutation."

3 · Controlling the caches

Knowing the layers is half of it; production is about steering them. Here is the full control surface, from one fetch up to a whole route.

Per fetch — the most precise lever:

// Never cache — always fresh (makes the route dynamic):
fetch(url, { cache: "no-store" })

// Cache in the Data Cache, refresh at most every 60s:
fetch(url, { next: { revalidate: 60 } })

// Cache AND tag it, so it can be invalidated on demand by name:
fetch(url, { next: { revalidate: 60, tags: ["documents"] } })

Per route — segment config exports, when you want to set the whole page's behaviour:

// app/docs/page.tsx
export const dynamic = "force-dynamic"   // render every request, skip the Route Cache
export const revalidate = 60             // or: ISR — regenerate the static route every 60s

After a mutation — invalidate the Data Cache (and dependent routes) so the next read is fresh:

import { revalidateTag, revalidatePath } from "next/cache"

revalidateTag("documents")   // every fetch tagged "documents" refetches next time
revalidatePath("/docs")        // a specific route's cache is busted
2026 direction · explicit caching with 'use cache' Next.js is moving toward explicit, opt-in caching. Instead of memorising which defaults cache what, you mark a cacheable unit — a function, component, or route — with the 'use cache' directive and set its lifetime with cacheLife. This is the current direction (stabilising through 2025–26); describe it as where the framework is heading: nothing is cached unless you say so, and you say so in one obvious place.
"use cache"
import { cacheLife, cacheTag } from "next/cache"

export async function getDocuments() {
  cacheLife("hours")        // how long this stays fresh
  cacheTag("documents")     // invalidate with revalidateTag("documents")
  return db.documents.findMany()
}

The discipline that keeps DocChat correct: tag your reads, revalidate on your writes. Tag the document-list fetch with ["documents"]; call revalidateTag("documents") in the upload action. The list is now always fresh exactly when it needs to be, and cached the rest of the time.

4 · Server Actions, hardened

Lesson 5.2 showed the happy path: a 'use server' function wired to a form. Production adds three things that path skipped — validation, state & progressive enhancement, and security. Get these wrong and a Server Action is a wide-open API endpoint.

Validate with zod

Never trust formData. Parse it through a schema and return a typed result. Zod gives you both the runtime check and the TypeScript type.

app/docs/actions.ts
"use server"

import { z } from "zod"
import { revalidateTag } from "next/cache"

const UploadSchema = z.object({
  title: z.string().min(1, "Title is required").max(200),
  file: z.instanceof(File).refine((f) => f.size > 0, "File is empty"),
})

useActionState & progressive enhancement

To show validation errors and a pending state, give the action a previous state and consume it with React 19's useActionState. Because the form's action is still a real Server Action, the form works before JavaScript loads — that's progressive enhancement, and it's free here.

// the action's new signature: (prevState, formData) => newState
export async function uploadDoc(_prev: State, formData: FormData): Promise<State> {
  const parsed = UploadSchema.safeParse({
    title: formData.get("title"),
    file: formData.get("file"),
  })
  if (!parsed.success) {
    return { error: parsed.error.issues[0].message }   // shown in the UI
  }
  // ...do the work, then:
  revalidateTag("documents")
  return { ok: true }
}
app/docs/upload-form.tsx (client)
"use client"
import { useActionState } from "react"
import { uploadDoc } from "./actions"

export function UploadForm() {
  const [state, action, pending] = useActionState(uploadDoc, {})
  return (
    <form action={action}>
      <input name="title" />
      <input type="file" name="file" />
      <button disabled={pending}>{pending ? "Uploading…" : "Upload"}</button>
      {state.error && <p className="text-red-600">{state.error}</p>}
    </form>
  )
}

Security — treat actions as public endpoints

The mistake that fails interviews A Server Action compiles to a public HTTP endpoint. Anyone can invoke it with a crafted request — they do not have to use your form. So "the button is only on the logged-in page" is not security. You must authenticate and authorize inside the action itself, every time, before doing any work.
import { auth } from "@/auth"   // Auth.js v5 (next section)

export async function uploadDoc(_prev: State, formData: FormData): Promise<State> {
  const session = await auth()
  if (!session?.user) return { error: "Not authenticated" }   // authZ first

  const parsed = UploadSchema.safeParse({ /* … */ })
  if (!parsed.success) return { error: parsed.error.issues[0].message }
  // only now is it safe to touch FastAPI / the DB
}

Order matters: auth → validate → act → revalidate. That sequence is the production shape of every mutation you'll ship.

5 · Route Handlers vs Server Actions

Both run server code. They are not interchangeable — choosing the wrong one is a common design smell. The rule is about who calls it.

Use a Server Action when…Use a Route Handler when…
Your own UI triggers a mutation (form submit, button).An external client needs a URL: webhooks, mobile apps, cron.
You want zero API boilerplate & type-safe args.You need a stable REST/JSON contract or custom headers/status.
You'll revalidate right after the write.You're streaming a file, an SSE feed, or proxying with a secret.

A Route Handler is a route.ts exporting HTTP-method functions. Here's a POST handler — the right tool for, say, a Stripe webhook hitting your app from outside:

app/api/webhook/route.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"

export async function POST(req: NextRequest) {
  const body = await req.json()
  // verify a signature header here — it's a public endpoint too
  return NextResponse.json({ received: true }, { status: 200 })
}

export async function GET() {
  return NextResponse.json({ ok: true })
}
PHP bridge: a Route Handler is your standalone api/webhook.php — a URL the outside world calls. A Server Action is closer to a form posting back to the same page that rendered it. For DocChat's own upload button, the Server Action wins; if you later add a mobile app, give it a Route Handler.

6 · Middleware: gating & its limits

Middleware runs before a matched request reaches your route, on the edge runtime. Its production job is the cheap, fast gate: is there a session cookie? If not, redirect to login before any page renders.

middleware.ts
export { auth as middleware } from "@/auth"   // Auth.js v5 provides middleware

export const config = {
  matcher: ["/docs/:path*", "/settings/:path*"],   // gate only these
}

Or write it by hand to see the shape clearly:

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()
}
The limits — say these in an interview The edge runtime is not Node.js: no fs, no native DB drivers, no heavy npm packages, and a tight CPU budget. So middleware must stay lightweight — check the cookie's presence and redirect, nothing more. Do not query a database or call FastAPI here. And middleware only proves a cookie exists; the real verification (decode the token, load the user, check ownership) happens server-side in the page, layout, or action behind it. Middleware is UX; the server-side check is the security boundary.

7 · Authentication with Auth.js v5

Auth.js v5 (the renamed NextAuth) is the 2026 standard for App Router auth. You configure it once and get a single auth() function that works in Server Components, Server Actions, Route Handlers, and middleware — plus signed, httpOnly session cookies handled for you.

auth.ts (project root)
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Credentials({
      async authorize(creds) {
        // verify against FastAPI, return a user object or null
        const res = await fetch(`${process.env.API_URL}/login`, {
          method: "POST", body: JSON.stringify(creds),
        })
        if (!res.ok) return null
        return res.json()   // { id, email, name }
      },
    }),
  ],
  pages: { signIn: "/login" },
})

Auth.js also exposes the Route Handler for its own callback URLs in one line:

app/api/auth/[...nextauth]/route.ts
export { GET, POST } from "@/auth"   // re-export handlers

Now read the current user anywhere on the server by calling auth() — no prop drilling, no client request:

app/docs/page.tsx (Server Component)
import { auth } from "@/auth"
import { redirect } from "next/navigation"

export default async function Page() {
  const session = await auth()
  if (!session?.user) redirect("/login")   // server-side guard (the real one)
  return <h1>Welcome, {session.user.name}</h1>
}
Interview answer · "how does auth work in your Next app?" "Auth.js v5. One config exports an auth() helper I call in Server Components, actions, and middleware. Middleware does the cheap redirect for unauthenticated users on protected matchers; the real guard is await auth() server-side in the page or action, which I check before rendering or mutating. Sessions live in a signed, httpOnly cookie so client JS can't read or forge them. Every Server Action re-checks auth() because actions are public endpoints."

8 · error.tsx, loading.tsx & not-found.tsx

Three file conventions give a route resilient UX with almost no code. Drop them beside page.tsx and Next.js wires them up.

loading.tsx — an automatic Suspense boundary. Shown instantly while the route's data resolves (this is what powers streaming for the whole segment):

app/docs/loading.tsx
export default function Loading() {
  return <p>Loading documents…</p>
}

error.tsx — a per-segment error boundary. It must be a Client Component (it catches runtime errors and offers a retry):

app/docs/error.tsx
"use client"

export default function Error({ error, reset }: {
  error: Error; reset: () => void
}) {
  return (
    <div>
      <p>Something went wrong loading your documents.</p>
      <button onClick={reset}>Try again</button>
    </div>
  )
}

not-found.tsx — rendered when you call the notFound() helper (and for unmatched URLs), with a real 404 status:

import { notFound } from "next/navigation"

const doc = await getDoc(id)
if (!doc) notFound()   // renders app/docs/not-found.tsx, sends 404

Together: loading for the wait, error for the crash, not-found for the miss. A production route handles all three, so a single slow query or thrown error never takes down the whole app.

9 · Images, fonts, environment & config

next/image and next/font (covered in 5.2) remove layout shift and self-host fonts for free — keep using them; they directly lift the Core Web Vitals interviewers and Google both score you on.

The production detail that bites people is environment variables. There are two kinds, and mixing them up leaks secrets or breaks the build:

PrefixVisible toUse for
API_TOKEN (no prefix)Server onlySecrets: tokens, DB URLs, the Auth.js secret.
NEXT_PUBLIC_API_URLBrowser too (inlined at build)Non-secret values the client genuinely needs.
.env.local
# server-only — NEVER reaches the browser
AUTH_SECRET=long-random-string
API_TOKEN=secret-never-in-the-browser

# inlined into the client bundle at build time
NEXT_PUBLIC_API_URL=https://api.docchat.app
Two gotchas Never put a secret behind NEXT_PUBLIC_ — it is baked into the JavaScript every visitor downloads. And because public vars are inlined at build time, changing one means you must rebuild, not just restart. Server-only vars are read fresh from the environment.

10 · Deployment: Vercel vs self-host

Two real paths, and you should be able to argue for either.

Vercel — the zero-config path. Push to Git; it builds, deploys, serves static assets from a CDN, runs your dynamic routes and middleware as serverless/edge functions, and gives you preview URLs per pull request. PPR, ISR, and on-demand revalidation work out of the box. It's the fastest way to ship and the path most teams use.

Self-host on Node — full control, any cloud, no vendor lock-in. The key is the standalone output: Next.js traces exactly which files your app needs and emits a minimal, self-contained server you can drop into a small Docker image.

next.config.ts
const nextConfig = {
  output: "standalone",   // emit a minimal Node server in .next/standalone
}
export default nextConfig
# Dockerfile (essence) — copy the traced standalone build and run it
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
CMD ["node", "server.js"]
Interview answer · "Vercel or self-host?" "Vercel for speed-to-ship and zero infra — CDN, serverless functions, preview deployments, and first-class support for PPR/ISR. Self-host on Node when I need control or to avoid lock-in: I set output: 'standalone' so Next traces dependencies into a minimal server, containerise it, and run it anywhere. Either way I run next build in CI first — it type-checks, lints, and tells me which routes are static vs dynamic before production does."

11 · Build: an authed, cache-revalidating upload

Your tangible win Wire DocChat's real upload flow end to end: middleware + Auth.js gate /docs, the document list reads a tagged fetch, and the upload is a zod-validated, authorized Server Action that calls FastAPI and revalidateTag('documents') so the list updates the instant the file lands.

The three pieces, assembled. First the gate:

middleware.ts
export { auth as middleware } from "@/auth"
export const config = { matcher: ["/docs/:path*"] }

The list, reading a tagged fetch so it participates in on-demand revalidation:

app/docs/doc-list.tsx
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"] },   // ← the tag we'll invalidate
  })
  const docs = await res.json()
  return <ul>{docs.map((d) => <li key={d.id}>{d.title}</li>)}</ul>
}

And the hardened action — auth → validate → call FastAPI → revalidate:

app/docs/actions.ts
"use server"

import { z } from "zod"
import { revalidateTag } from "next/cache"
import { auth } from "@/auth"

const UploadSchema = z.object({
  title: z.string().min(1, "Title is required").max(200),
  file: z.instanceof(File).refine((f) => f.size > 0, "File is empty"),
})

type State = { ok?: boolean; error?: string }

export async function uploadDoc(_prev: State, formData: FormData): Promise<State> {
  // 1 · authorize — actions are public endpoints
  const session = await auth()
  if (!session?.user) return { error: "Not authenticated" }

  // 2 · validate
  const parsed = UploadSchema.safeParse({
    title: formData.get("title"),
    file: formData.get("file"),
  })
  if (!parsed.success) return { error: parsed.error.issues[0].message }

  // 3 · call FastAPI with the user's token (secret stays server-side)
  const body = new FormData()
  body.set("title", parsed.data.title)
  body.set("file", parsed.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" }

  // 4 · revalidate — the list refetches on next render
  revalidateTag("documents")
  return { ok: true }
}

That is the full production loop: a stranger can't reach /docs (middleware), the action re-checks the session even if someone hits it directly (security), bad input is rejected with a message (zod + useActionState), the token never touches the browser (server-side fetch), and the list is fresh the moment the upload succeeds (revalidateTag). You'll mount the UploadForm from section 4 on this page and ship it in the capstone.

12 · Check yourself

Answer from memory — retrieval is what moves this from "I read it" to "I know it".

Recall quiz

What does Partial Prerendering combine on one route?

Which cache dedupes identical fetches in one render?

Why must you authorize inside a Server Action?

When do you reach for a Route Handler over an action?

What is middleware's real security limit?

How do you read the current user in a Server Component?

Which value is safe to expose to the browser?

What does output standalone produce?

Primary source ⭐ Next.js Docs — Caching for the four layers and revalidation, plus Auth.js — Getting Started for v5 sessions and middleware. The canonical, authoritative references for everything above.