Module 5 · Next.js · Deep Dive

Next.js Full-Stack & Advanced

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

Why this matters In DocChat, the browser must never see your FastAPI token. The frontend uploads a document, the list refreshes, and a question gets answered — all without a hand-written /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."
In this lesson
  1. Server Actions — mutations, no API
  2. Refreshing data: revalidatePath/Tag
  3. Rendering & caching
  4. Streaming with Suspense
  5. Route Handlers as a BFF
  6. Auth: the httpOnly cookie
  7. Styling with Tailwind
  8. Deploy-readiness
  9. Metadata & SEO
  10. Middleware & auth gating
  11. next/image & next/font
  12. ISR & revalidation depth
  13. loading.tsx & not-found.tsx
  14. Build: DocChat upload
  15. Check yourself

1 · Server Actions — mutations without an API

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.
Interview answer · "what are server actions?" "Server Actions are async functions marked '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."

2 · Refreshing data after a write

Next.js caches rendered pages and data aggressively. After a mutation you must tell it what's now stale. Two tools:

FunctionUse 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.

3 · Static vs dynamic rendering & the fetch cache

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" })
2026 note · the default flipped In Next.js 15, 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.
PHP bridge: 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.

4 · Streaming with Suspense & loading.tsx

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.

5 · Route Handlers as a backend-for-frontend

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.

PHP bridge: a Route Handler is your old PHP "API" file (api/docs.php), but the file name (route.ts) maps to the URL by folder — app/api/docs/api/docs.

6 · Auth: the JWT in an httpOnly cookie

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.)

7 · Styling with Tailwind

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.

8 · Deploy-readiness

Before shipping, two essentials:

# .env.local
API_TOKEN=secret-never-in-the-browser
NEXT_PUBLIC_API_URL=https://api.docchat.app

# build for production
next build

9 · Metadata & SEO

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.

10 · Middleware & auth gating

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*"],
}
Edge note · keep it light Middleware runs on the edge runtime, before your page — it must stay fast. Do not hit a database or call FastAPI here; just check the cookie's presence and redirect. Real verification (decode the JWT, load the user) belongs in the page or layout that renders behind it.
Interview answer · "how do you protect a route in Next.js App Router?" "Two layers. Middleware at the root does the cheap gate — read the JWT cookie via 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."

11 · next/image & next/font

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.

12 · ISR & revalidation depth

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.

StrategyWhen it refreshes
export const revalidate = 60On 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")
2026 note · fetches aren't cached by default In Next.js 15 a plain 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.

13 · Loading & not-found states

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.

14 · Build it

Your tangible win Build the DocChat upload form: a Server Action that posts an uploaded file to the FastAPI backend, then revalidates the document list so the new file appears instantly — no client 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.

15 · Check yourself

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

Recall quiz

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?

Primary source ⭐ Next.js Docs — Data Fetching, Caching & Mutating. The canonical, authoritative reference for Server Actions, the fetch cache, revalidation, and streaming covered above.