Next.js NotesRohit’s interview study guide
Chapter 14

Review Questions

Rapid revision before an interview: short questions with model answers you can say out loud in a minute, each linked to the topic on this site that explains it in depth.

5 topics

#Routing and rendering questions

Added

This page is for the last few days before an interview, when you don't need to learn Next.js any more but need to say it well, quickly. Each question is one an interviewer might ask, or one you should be able to answer to yourself while reading someone's code. The first paragraph of each answer is the version you'd say out loud, 30 to 90 seconds: the answer first, the reason straight after. The walkthrough under it is the reasoning you need when the follow-up is "why?", and the link at the end goes to the topic that teaches it properly.

Treat it as a deck of flashcards with the working shown. Read the question, answer it in your own words, then open the answer and compare. Most Next.js answers turn on three things, so check you named them: which version (and whether Cache Components is on), where the code runs (at build time, on the server per request, or in the browser), and what has already been sent (a prerendered shell, a status line, a cached payload). An answer that gets the behaviour right but doesn't say which version it's true for is half an answer, because interviewers learned Next.js at different points.

Everything here was checked on Next.js 16.4.3 with the site's template defaults (cacheComponents: true, partialPrefetching: true) unless a question says otherwise. Where an answer states a build result, the question includes the app's files and the route table next build printed; the site's checker rebuilds those apps on every validation and fails if a table changes. Runtime results (status codes, headers, what the browser received) came from next start -p 4514 with curl or headless Chrome on 25 October 2026, and each question says which.

The defaults that interviewers mix up most, in one table. "CC" is Cache Components, which create-next-app turns on for new 16.4 apps; an upgraded app has it off until you enable it. (Details in Next.js versions that changed the rules.)

Behaviour13–1415, and 16 without CC16 with CC
fetch() with no optionsCached (force-cache), with exceptions after cookies()/headers()Not cached; but on a static route it still runs only at build timeNot cached; must sit inside <Suspense> or a 'use cache' scope, or the build fails
params, searchParams, cookies(), headers()SynchronousPromises (15 still allowed sync access with a warning; 16 removed it)Promises; reading them is request-time data
A GET Route Handler that reads nothingStatic by defaultDynamic by default (dynamic = 'force-static' to opt in)Prerendered like a page
Page segments in the client router cacheReused for 30 s (dynamic) or 5 min (static)Dynamic pages not reused (staleTimes.dynamic 0); static still 5 minReused for the stale time from cacheLife (at least 30 s); visited routes kept hidden with <Activity>
Request interception filemiddleware.ts, Edge runtimemiddleware.ts (Node.js runtime experimental in 15.2, stable in 15.5)proxy.ts, Node.js only; middleware.ts deprecated
Bundler for next dev / next buildwebpack (Turbopack dev beta)Turbopack dev stable, webpack buildTurbopack for both (--webpack to opt out)
Partial Prerendering14: experimental preview15: experimental.ppr in canaries; 16 removed the flag, so none without CCThe model: every route has a static shell (◐ in the build)

This first group covers the fundamentals, routing, rendering and the build output, and the performance and SEO features.

Fundamentals and versions

What is Next.js, and when would you not use it?
Show answer

A React framework: React is the UI library, Next.js decides routing, rendering, data fetching, bundling and the server around it. "Routes are folders under app/. Components are Server Components by default, so they can read data directly and their code never reaches the browser; I add 'use client' only where the user interacts. At build time Next.js prerenders everything that doesn't depend on the request, and per request it renders and streams the rest. I'd choose it when first-load speed, real HTML and SEO matter, or when I want the backend-for-frontend in the same project. For an internal single-page app behind a login, talking to an existing API, a Vite app with React Router can be simpler: no server to run, fewer concepts."

What’s happening
  1. Start with the split of responsibilities: React renders components; Next.js owns the file-system router, the build, the server, caching and the deployment output.
  2. Name the two features that make it more than "React with SSR": Server Components (no client JavaScript for server-only code) and prerendering per component (the static shell).
  3. Correct the common misconception that it means server rendering on every request: a ○ route in the build output renders once, during next build, and is served as a file.
  4. Give a real reason not to use it. A framework that needs a Node server (or a platform) is a cost; if there's no SEO need and the data comes from an existing API, a static SPA may be the better trade.

Learn more: What Next.js is and why teams use it.

You open an unfamiliar repository. How do you tell whether it uses the App Router or the Pages Router, and which version rules apply?
Show answer

Look for app/ (with layout.tsx and page.tsx files) versus pages/ (with _app.tsx and data functions like getServerSideProps), then read the next version in package.json and the flags in next.config. "Both routers can live in one app during a migration, so both folders may exist. The version decides the defaults: 13–14 cache fetch by default, 15 doesn't and makes params and cookies() async, 16 has proxy.ts and Cache Components. And cacheComponents: true in next.config means the 'use cache' model, where export const revalidate and dynamic no longer apply."

What’s happening
  1. app/ files: page.tsx, layout.tsx, loading.tsx, route.ts, 'use client' directives, async components. That's the App Router (stable since 13.4).
  2. pages/ files: _app.tsx, _document.tsx, pages/api/*, getStaticProps/getServerSideProps. That's the Pages Router, still supported in 16.
  3. Both together: an incremental migration. A URL can only exist in one of them, and navigating between the two routers is a full page load.
  4. Then the version and config: next 15+ means async request APIs; middleware.ts vs proxy.ts tells you pre- or post-16 naming; cacheComponents: true tells you which caching chapter applies.

Learn more: App Router vs Pages Router and Project structure and special files.

What changed between Next.js 13, 14, 15 and 16?
Show answer

13 introduced the App Router; 14 made Server Actions stable; 15 changed the defaults; 16 changed the model. "13 brought the App Router and React Server Components, stable in 13.4. 14 made Server Actions stable and removed next export. 15 made params, searchParams, cookies() and headers() asynchronous, and stopped caching fetch, GET Route Handlers and page segments in the client router cache by default; it also moved the App Router to React 19. 16 removed the synchronous fallback for request APIs, made Turbopack the default bundler, renamed middleware.ts to proxy.ts on the Node.js runtime, and introduced Cache Components: you cache exactly what you mark with 'use cache', and every route is partially prerendered. 16.3 added Partial Prefetching, one prefetched shell per route."

What’s happening
  1. The thread through all of it: each major version made caching more explicit. 13–14 cached implicitly, 15 removed the implicit caching, 16 lets you cache a function or component instead of a whole route.
  2. 15's changes are the ones that silently change behaviour in old code: a fetch that was cached in 14 runs again in 15, unless the route is static (next question).
  3. 16's are the ones that break builds or behaviour: sync params access is gone (that one still builds, but reads undefined), revalidateTag needs a second argument, parallel slots need default.tsx, next lint is gone.
  4. Saying "it depends on the version, and here's how each one behaves" is itself the senior answer; interviewers often learned it at one of these points.

Learn more: Next.js versions that changed the rules and Multi-zones, monorepos and upgrading.

A page awaits a slow, random-valued query and uses no request APIs. Two visitors load it a minute apart. What does each see in 14, 15 and 16?

In 16, answer both with and without Cache Components. Here is the previous model on 16.4.3 (Cache Components off), with the same query on three pages:

next.config.ts
import type { NextConfig } from "next";

// The previous model: no Cache Components (and so no partialPrefetching, which requires it).
const nextConfig: NextConfig = {};

export default nextConfig;
lib/db.ts
// Stands in for a database query: slow, and a different answer every time it runs.
export async function getStock() {
  await new Promise((r) => setTimeout(r, 100));
  return Math.floor(Math.random() * 100);
}
app/stock/page.tsx
import { getStock } from "@/lib/db";

export default async function Stock() {
  return <h1>{await getStock()} left</h1>;
}
app/fresh/page.tsx
import { getStock } from "@/lib/db";

export const revalidate = 60;

export default async function Fresh() {
  return <h1>{await getStock()} left</h1>;
}
app/me/page.tsx
import { cookies } from "next/headers";
import { getStock } from "@/lib/db";

export default async function Me() {
  const name = (await cookies()).get("name")?.value ?? "guest";
  return <h1>{name}: {await getStock()} left</h1>;
}

The route table also lists routes that later questions on this page add to the same app.

Show answer

In 14, 15 and 16 without Cache Components, /stock shows both visitors the same number, frozen at build time. With Cache Components the same page doesn't build until you decide: cache it or stream it. "The previous model decides per route: nothing in /stock needs the request, so the build prerenders it and the query runs once, during next build. revalidate = 60 makes it ISR, regenerated at most once a minute; reading cookies() makes the route dynamic, so the query runs per request. With Cache Components, an uncached await outside <Suspense> is a build error, and I choose 'use cache' with a cacheLife, or a Suspense boundary so it streams per request."

next build
Route (app)         Revalidate  Expire
┌ ○ /_not-found
├ ƒ /api/hello
├ ƒ /api/time
├ ○ /fresh                  1m      1y
├ ƒ /me
├   /posts/[slug]
│ └ ● /posts/hello
└ ○ /stock


○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand
curl -s -D - localhost:4514/<path> on next start (the <h1>, React's <!-- --> separators removed, and the cache headers)
/stock  <h1>75 left</h1>  x-nextjs-cache: HIT  Cache-Control: s-maxage=31536000
/stock  <h1>75 left</h1>  x-nextjs-cache: HIT  Cache-Control: s-maxage=31536000
/fresh  <h1>51 left</h1>  x-nextjs-cache: HIT  Cache-Control: s-maxage=60, stale-while-revalidate=31535940
/me     <h1>guest: 66 left</h1>  Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
/me     <h1>guest: 35 left</h1>  Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
What’s happening
  1. /stock uses no request API, so the previous model calls it static (○). getStock ran during the build and the HTML with 75 was stored; both requests were cache HITs with the same number, and it stays 75 until the next deploy.
  2. export const revalidate = 60 keeps the route static but gives it a lifetime: the table shows 1m and the response s-maxage=60, stale-while-revalidate=…. After a minute, the next visitor still gets the old copy and triggers a regeneration (ISR).
  3. await cookies() makes the whole route dynamic (ƒ): no-store, and a new number on each request.
  4. Versions: 14 behaves the same for this non-fetch query; with a fetch instead, 14 would also cache the response in the Data Cache, and 15 wouldn't, but the static route still freezes it at build. That's why "fetch is uncached in 15" doesn't mean "fetched per request".
  5. With Cache Components (16 default), awaiting uncached data outside <Suspense> fails the build with "encountered uncached or runtime data during prerendering". You add 'use cache' plus cacheLife('minutes') to get ISR-like behaviour, or wrap the component in <Suspense> to render it per request inside a static shell.

Learn more: Static and dynamic rendering and The previous model.

A Next.js 14 tutorial reads params.slug synchronously. What happens on 16.4.3?
Show answer

It builds and deploys, then renders an empty value, because params is a Promise and a Promise has no slug property. "Next.js 15 made params, searchParams, cookies() and headers() asynchronous so a route waits for request data only where it reads it, and kept synchronous access as a temporary fallback with a warning. 16 removed the fallback. If the props are typed by hand, TypeScript can't catch it; only next dev prints the sync-dynamic-apis error. I type pages with the generated PageProps<'/route'> helper and run the async-request codemod when upgrading."

What’s happening
  1. In 13–14, params was a plain object: params.slug was the string.
  2. In 15, params is a Promise that also exposes the properties for compatibility, with a dev warning.
  3. In 16, it's only a Promise. Chapter 1 built exactly this page: next build passed and production rendered <h1>Post: </h1>.
  4. The fix is const { slug } = await params (or use(params) in a Client Component), and PageProps<"/blog/[slug]">, which knows params is a Promise of { slug: string }.

Learn more: Next.js versions that changed the rules and Dynamic routes.

Walk me through what happens when a user opens a page in an App Router app.
Show answer

The server renders Server Components into an RSC payload, renders HTML from it, and streams both; the browser paints the HTML and hydrates only the Client Components; later <Link> navigations fetch only RSC payloads for the segments that change. "The request reaches the server, proxy.ts runs if it matches, and the URL is matched to segments: root layout, nested layouts, page. If the route was prerendered, the stored HTML is sent; if it's partially prerendered, the static shell goes out first and the server renders only the holes into the same response. The payload describes the rendered tree with slots and props for Client Components, and it's inlined in the HTML in script tags. After hydration, links prefetch, and a click renders from the client cache or fetches a payload; shared layouts stay mounted with their state."

What’s happening
  1. Server first: next.config redirects and headers, then proxy.ts, then route matching.
  2. Render: Server Components produce the RSC payload (rendered output, not code); Client Components are rendered to HTML on the server too, from the same payload.
  3. Response: HTML streams with self.__next_f.push(...) scripts carrying the payload. With Partial Prerendering the shell is a stored file and only the holes are rendered per request.
  4. Browser: paint, download the client chunks, hydrate. Server Component code is never downloaded.
  5. Navigation: <Link> prefetches when it enters the viewport; a click uses the prefetched shell or sends one GET with an rsc: 1 header, and React merges the payload into the existing tree. No HTML after the first load.

Learn more: How a request becomes a page and How the RSC payload works.

Routing

What's the difference between a page and a layout, and what re-renders when you navigate between two pages that share a layout?
Show answer

A page.tsx makes a folder a URL and renders what's unique to it; a layout.tsx wraps everything below it and persists across navigations between those pages. "On a client navigation the router compares the old and new URL segment by segment and only renders from the deepest shared layout down. The shared layout isn't re-rendered or re-fetched, and Client Components in it keep their state. With Cache Components the page you left isn't even unmounted: up to three recent routes are kept hidden with <Activity>, so their state survives a round trip."

What’s happening
  1. /shop/shirts to /shop/hats: root matches, shop matches, shirts vs hats differs, so ShopLayout is the deepest shared layout.
  2. Everything at and above ShopLayout is reused: no server render, no payload for those segments, no client re-render. A cart counter in it keeps its count.
  3. Only the hats page segment is rendered (or taken from the prefetch cache if it's static).
  4. Consequence for code: side effects in a layout's render (page-view tracking) run once per section, not per page. Track in a Client Component's effect keyed on usePathname().

Learn more: Pages and layouts.

You click a layout's counter three times, then go to /about. What does the counter show with <Link href="/about">, and with <a href="/about">?
Show answer

3 with <Link>, 0 with <a>. "<Link> is a soft navigation: the client router fetches only the segments that change and React applies them inside the existing tree, so the layout and its state survive. A plain <a> is a hard navigation: the browser throws the document away, requests new HTML and hydrates from scratch, so every Client Component starts again from its initial state."

What’s happening
  1. The counter's useState lives in a Client Component inside the root layout; after three clicks it holds 3.
  2. <Link> intercepts the click, uses the prefetched payload (or fetches it) and keeps every shared layout mounted: 3.
  3. <a> isn't intercepted: new document, new hydration, useState(0) runs again: 0. Chapter 1 checked both in headless Chrome.
  4. Use <Link> or router.push inside the app; plain <a> for external links or when a full reload is the point.

Learn more: How a request becomes a page and Linking and navigating.

What does loading.tsx do, and how is it different from writing <Suspense> yourself?
Show answer

It's the file convention for a <Suspense> boundary around the segment's page: Next.js wraps the page in Suspense with loading.tsx as the fallback, inside the segment's layout. "The server sends the layout and the fallback at once and streams the page when it's ready; on client navigations the fallback is part of the prefetched shell, so it appears instantly on click. It's the same mechanism as <Suspense>, just at page granularity. I use my own boundaries for finer streaming, because everything inside one boundary waits for its slowest part. Two limits: it can't cover what the same folder's layout awaits, and once streaming starts the status code is fixed."

What’s happening
  1. Placement: layout → error boundary → loading as Suspense → page. So the layout renders without waiting for the page.
  2. First load: the response starts with 200, layout and fallback, and stays open until the page's HTML streams in.
  3. Navigation: the fallback was prefetched, so the URL and UI change immediately while the page's data loads.
  4. Granularity: three independent widgets under one loading.tsx all wait for the slowest. Give each its own <Suspense> to stream them separately.

Learn more: Loading UI and streaming with loading.js and Streaming with Suspense.

Predict the status codes

Six requests to this app on next start, with no cookies: (a) /old-blog/hello, (b) /account, (c) /retired, (d) /moved, (e) /blog/nope for the first time, (f) /blog/nope again.

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  async redirects() {
    return [{ source: "/old-blog/:slug", destination: "/blog/:slug", permanent: true }];
  },
};

export default nextConfig;
proxy.ts
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  if (!request.cookies.has("session")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/account/:path*"] };
app/blog/[slug]/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";

const posts: Record<string, string> = { hello: "Hello, world" };

export function generateStaticParams() {
  return [{ slug: "hello" }];
}

async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const title = posts[slug];
  if (!title) notFound();
  return <h1>{title}</h1>;
}

export default function BlogPost({ params }: PageProps<"/blog/[slug]">) {
  return (
    <main>
      <p>Blog</p>
      <Suspense fallback={<p>Loading post…</p>}>
        <Post params={params} />
      </Suspense>
    </main>
  );
}
app/retired/page.tsx
import { notFound } from "next/navigation";

export default function Retired() {
  notFound();
}
app/moved/page.tsx
import { redirect } from "next/navigation";

export default function Moved() {
  redirect("/blog/hello");
}
app/account/page.tsx
export default function Account() {
  return <h1>Your account</h1>;
}
app/login/page.tsx
export default function Login() {
  return <h1>Sign in</h1>;
}
Show answer

(a) 308, (b) 307, (c) 404, (d) 307, (e) 200 with a noindex tag, (f) 404. "Config redirects and proxy run before any rendering, so they can set any status: permanent: true is a 308, NextResponse.redirect defaults to 307. notFound() and redirect() called while rendering also set real statuses, as long as nothing has been sent yet; on a static page that happened at build time and the result is stored. But once a static shell has been streamed, the status line is gone: a notFound() inside <Suspense> gives the not-found UI with 200 and noindex. When the status matters, decide before streaming: in config, in proxy, or above every Suspense boundary."

next build
Route (app)
┌ ○ /_not-found
├ ○ /account
├   /blog/[slug]
│ ├ ◐ /blog/[slug]
│ └ ○ /blog/hello
├ ○ /login
├ ○ /moved
└ ○ /retired


ƒ Proxy (Middleware)

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
curl -s -D - localhost:4514/<path> (status line and the headers that matter)
(a) /old-blog/hello    308 Permanent Redirect   location: /blog/hello
(b) /account           307 Temporary Redirect   location: /login
(c) /retired           404 Not Found            x-nextjs-cache: HIT, <meta name="robots" content="noindex"/>
(d) /moved             307 Temporary Redirect   location: /blog/hello, x-nextjs-cache: HIT
(e) /blog/nope         200 OK                   Cache-Control: private, no-cache, no-store…, <meta name="robots" content="noindex"/>
(f) /blog/nope         404 Not Found            x-nextjs-cache: HIT (MISS in one re-run; 404 every time)
    /blog/nope2        200 OK                   (a new slug: the same as (e) again)
What’s happening
  1. (a) next.config redirects are checked before proxy and before routes. permanent: true gives 308, not 301: 307 and 308 keep the request method, so a redirected POST stays a POST.
  2. (b) The proxy's matcher covers /account; no session cookie, so NextResponse.redirect answers 307 before the page is touched. With -b session=1 the same URL returned 200. (This is an optimistic check for a nicer redirect, not authorisation.)
  3. (c) and (d): both pages are ○. Their notFound() and redirect() ran during next build, and Next.js stored the outcome: a 404 with noindex, and a 307 with location, both served as cache HITs. A static page can still return a real error or redirect status.
  4. (e) /blog/nope isn't in generateStaticParams, so it's served from the ◐ shell: 200 and the "Blog" heading are flushed first, then Post calls notFound(). The status can't change any more, so Next.js renders the not-found UI into the stream with noindex.
  5. (f) After that first request Next.js kept the not-found result for that URL, so the second request was a 404, a stored HIT (in one of my re-runs, with the second request sent straight after the first, it was a MISS, still 404). Every new unknown slug repeats (e) once. Monitoring that counts 404s by status code under-counts these; search engines are fine because of noindex.

Learn more: not-found, redirects and status codes and Redirects, rewrites and headers in proxy.

What are parallel routes, and what happens to a slot when you refresh a page it has no match for?
Show answer

@slot folders render several pages in one layout at once, as props next to children; on a refresh, a slot with no match for the URL renders its default.tsx. "Each slot matches the URL against its own subtree and has its own loading and error states. On a soft navigation, a slot that has no page for the new URL keeps showing its previous one; on a hard navigation there is no previous state, so it renders default.tsx, which usually returns null or calls notFound(). Since 16 a slot needs a page or a default.tsx for every URL its siblings match, or the build fails. Choosing which slot to show by role is not access control: both render and both reach the payload."

What’s happening
  1. app/dashboard/@team and @analytics become team and analytics props of dashboard/layout.tsx. The @ folders don't appear in the URL.
  2. Click from /dashboard to /dashboard/settings where only @team has a settings page: team switches, children and analytics keep their old pages.
  3. Refresh: the server renders /dashboard/settings from scratch, with no memory of what the other slots showed, so they render default.tsx.
  4. That difference between soft and hard navigation is the whole reason default.tsx exists. The 16 upgrade guide says every slot needs one; what 16.4.3's build enforces (strict route matching, on by default) is a page or default.tsx in every sibling slot for each URL one slot matches (chapter 2).

Learn more: Parallel routes.

How do you build a photo modal that has its own URL, and what does a refresh on that URL show?
Show answer

A parallel @modal slot plus an intercepting route like (.)photos/[id] inside it; a refresh shows the full photo page, not the modal. "Clicking a link to /photos/2 inside the app is a soft navigation, so the router can intercept it: the @modal slot renders the photo in a modal and children keeps the gallery underneath, while the address bar says /photos/2. A refresh, or opening a shared link, is a hard navigation that isn't intercepted, so you get app/photos/[id]/page.tsx. The slot needs a default.tsx returning null, a catch-all so other links close it, and closing uses router.back() so history behaves."

What’s happening
  1. (.) intercepts a segment at the same level, (..) one level up, (...) from the root. They count route segments, so @modal and (group) folders don't count.
  2. Soft navigation from the gallery: the client sends the current URL (next-url header), the server matches the intercepting route, modal over gallery.
  3. Refresh: no "previous page" for this request, no interception. children renders the photo page, @modal renders its default (null).
  4. Back after the refresh: a soft navigation to /, so the gallery without a modal. Interception is a property of how you arrived, not of the URL.

Learn more: Intercepting routes and modals.

When would you use template.tsx instead of layout.tsx?
Show answer

When the wrapper must remount on every navigation between its children: a template is keyed by the segment below it, a layout persists. "With a template, Client Component state inside resets, the DOM is recreated and effects run again on each navigation, which is what you want for per-navigation logging, enter animations, a Suspense fallback that shows every time, or a form that should start empty on each page. In 16 with Cache Components there's a twist: routes you leave are kept in <Activity>, so going back to a recent page restores its template's state; for 'always fresh', reset explicitly or key on useRouter().bfcacheId."

What’s happening
  1. Layout: one instance for the section, state kept across /feedback/a → /feedback/b.
  2. Template: new key per child segment, so a → b mounts a new template and a new page.
  3. Search params don't change the key: ?sort=new → ?sort=old keeps both mounted. Key a component on the value if it must reset.
  4. Chapter 2 measured the Cache Components difference: after a → b → a, the counters read 2 · 2 · 2 with Cache Components and 2 · 0 · 0 without.

Learn more: Templates vs layouts.

What does <Link> prefetch, and why does navigation feel slow in next dev but instant in production?
Show answer

In production a <Link> prefetches when it enters the viewport; next dev never prefetches. "Without Cache Components, static routes are prefetched fully and dynamic routes down to the nearest loading.tsx. With Cache Components and Partial Prefetching, the default for new 16.4 apps, each route gets one shared App Shell: the static and cached parts plus the fallbacks around URL-dependent content. prefetch={true} adds a per-link prefetch that resolves that link's params and cached data, so even a dynamic page renders instantly; prefetch={false} turns it off, and then the click waits for the server with the old page on screen. To make navigation instant I shape the destination: cache what I can, and put params, searchParams and uncached reads inside Suspense with useful fallbacks."

What’s happening
  1. Viewport-triggered: links prefetch only after hydration and only in production builds.
  2. Default link to a static page: the whole page is in the client cache, so the click makes no request.
  3. Default link to a page that reads params: the route's shared App Shell renders at once, the param-dependent part needs a request.
  4. prefetch={true}: the link's own content is prefetched too, complete on click. prefetch={false}: nothing cached, the router can't render anything until the response arrives.
  5. Chapter 2 measured this in headless Chrome: about 25 ms to full content for a static route and a prefetch={true} link, a fallback at once then content after the request for the default dynamic link, and about 430 ms of nothing for prefetch={false}.

Learn more: Prefetching and instant navigation.

You go Dashboard → Settings → back to Dashboard with a <Link>. The dashboard page is dynamic. Do you see the old data instantly, or a loading state again? Does the answer depend on the version?
Show answer

On 15 and 16 you see the loading state and fresh data; on 14 you'd have seen the old page instantly, reused from the client router cache for 30 seconds. "Next.js 15 stopped reusing page segments from dynamic renders on navigation: staleTimes.dynamic went from 30 seconds to 0. Layouts and loading states are still reused, so the click shows the cached shell at once and the page streams in. Browser back and forward still restore the cached page. You can opt back in with the experimental staleTimes setting, and with Cache Components the reuse time for cached content is the stale value from cacheLife."

What’s happening
  1. The dashboard layout is shared and stays mounted; only the page segment is needed.
  2. 15+: the router has the prefetched shell (the loading.tsx fallback) but not a reusable page, so it shows the fallback and requests the page. Chapter 2 saw the skeleton again, then the data about 2 s later.
  3. 14: the visited page was kept for 30 s (5 minutes for static pages and prefetch={true}), so the click showed the old revenue immediately, possibly stale.
  4. This is one of the 15 default changes interviewers ask about, with fetch and GET handlers no longer being cached.

Learn more: Loading UI and streaming with loading.js and Caching layers at a glance.

Rendering and the build output

Predict the symbol next build prints for each route

Cache Components on. / is plain JSX; /menu awaits a 'use cache' function with cacheLife("hours"); /account reads cookies() inside <Suspense>; /ticker renders a cacheLife("seconds") component inside <Suspense>; /posts/[slug] lists one slug in generateStaticParams; /blocking reads cookies() at the top with export const instant = false.

app/page.tsx
export default function Home() {
  return <h1>Coffee shop</h1>;
}
app/menu/page.tsx
import { cacheLife } from "next/cache";

async function getMenu() {
  "use cache";
  cacheLife("hours");
  await new Promise((r) => setTimeout(r, 50)); // stands in for a database query
  return ["Flat white", "Cold brew"];
}

export default async function Menu() {
  const items = await getMenu();
  return <ul>{items.map((item) => <li key={item}>{item}</li>)}</ul>;
}
app/account/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

async function Greeting() {
  const name = (await cookies()).get("name")?.value ?? "guest";
  return <p>Welcome back, {name}</p>;
}

export default function Account() {
  return (
    <main>
      <h1>Your account</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <Greeting />
      </Suspense>
    </main>
  );
}
app/ticker/page.tsx
import { Suspense } from "react";
import { cacheLife } from "next/cache";

async function Price() {
  "use cache";
  cacheLife("seconds");
  return <p>BTC {(60000 + Math.random() * 1000).toFixed(0)}</p>;
}

export default function Ticker() {
  return (
    <Suspense fallback={<p>Loading price…</p>}>
      <Price />
    </Suspense>
  );
}
app/posts/[slug]/page.tsx
export function generateStaticParams() {
  return [{ slug: "hello" }];
}

export default async function Post({ params }: PageProps<"/posts/[slug]">) {
  const { slug } = await params;
  return <h1>Post: {slug}</h1>;
}
app/blocking/page.tsx
import { cookies } from "next/headers";

export const instant = false;

export default async function Blocking() {
  const name = (await cookies()).get("name")?.value ?? "guest";
  return <h1>Hello {name}</h1>;
}
Show answer

/ ○, /menu ○ with 1h / 1d, /account ◐, /ticker ◐, /posts/hello ○ with a ◐ /posts/[slug] fallback row, /blocking ƒ. "○ means fully prerendered, ◐ a prerendered static shell with holes streamed per request, ƒ rendered on demand. Cached data with a long enough lifetime goes into the prerender, and the Revalidate and Expire columns show its profile. Request data inside Suspense makes a hole. A very short cache lifetime is treated like request data. And instant = false opts a route out of needing a shell, so it blocks on the server: ƒ."

next build
Route (app)          Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ◐ /account
├ ƒ /blocking
├ ○ /menu                    1h      1d
├   /posts/[slug]
│ ├ ◐ /posts/[slug]
│ └ ○ /posts/hello
└ ◐ /ticker


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
What’s happening
  1. /: nothing to wait for, so the whole page is HTML from the build.
  2. /menu: the 'use cache' result can be computed at build time and stored, so the page is still ○; the hours profile shows as 1h (revalidate) and 1d (expire).
  3. /account: the heading is in the shell, Greeting reads a cookie, so it's a hole with Loading… as its prerendered fallback: ◐.
  4. /ticker: seconds has an expire under five minutes, so it isn't prerendered even though it's cached. It becomes a hole, also ◐.
  5. /posts/[slug]: the listed slug is fully prerendered (○ /posts/hello); the ◐ /posts/[slug] row is the shell used for any other slug. With Cache Components there's no ● symbol; that's the previous model's (the rq-legacy table above has ● /posts/hello).
  6. /blocking: reading cookies() at the top without Suspense would normally fail the build. instant = false allows the route to block, so it's ƒ.

Learn more: The development and build workflow, Partial Prerendering and Static and dynamic rendering.

Can you tell from the response headers whether a route is ○, ◐ or ƒ?

Same app, curl -s -D - on each route.

Show answer

Static from not-static, yes; ◐ from ƒ, not reliably. The route table is the authority. "A ○ page comes back with x-nextjs-cache: HIT and a long s-maxage, or the revalidate time from its cache profile. Anything rendered per request comes back private, no-cache, no-store with x-nextjs-postponed: 1. On 16.4.3 the ƒ route sent exactly the same headers as the ◐ ones, so for 'is this partially prerendered?' I read next build."

curl -s -D - localhost:4514/<path> (cache headers only)
/              x-nextjs-cache: HIT   Cache-Control: s-maxage=31536000
/menu          x-nextjs-cache: HIT   Cache-Control: s-maxage=3600, stale-while-revalidate=82800
/posts/hello   x-nextjs-cache: HIT   Cache-Control: s-maxage=31536000
/account       x-nextjs-postponed: 1 Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
/ticker        x-nextjs-postponed: 1 Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
/blocking      x-nextjs-postponed: 1 Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
What’s happening
  1. ○ routes are served from the stored prerender (HIT). /menu's headers translate its hours profile: s-maxage=3600 (revalidate after an hour) and stale-while-revalidate=82800 (up to a day in total, the expire time).
  2. ◐ routes send the stored shell, then render the holes for this request; anything personal may be in the response, hence no-store.
  3. /blocking is ƒ in the table, yet its headers include x-nextjs-postponed: 1 too. I didn't find this documented; it's what 16.4.3 sent.
  4. Every response also carried x-nextjs-prerender: 1 and a Vary on the router headers (rsc, next-router-state-tree, …), so the symbol can't be read off those either.

Learn more: The development and build workflow and Static and dynamic rendering.

How does Next.js decide whether a page is static or dynamic?
Show answer

It renders every route at build time and looks at what the code touches: anything that needs the request, or uncached I/O, can't be prerendered. "In the previous model the decision is per route: one cookies() call, searchParams, no-store fetch or dynamic = 'force-dynamic' makes the whole route ƒ, and a database call without one is silently baked in at build time. With Cache Components it's per component: the build keeps a static shell, request-time parts must be inside <Suspense> and stream into the same response, and uncached data must be cached with 'use cache' or streamed, or the build fails. I check the route table, never next dev, which renders everything on demand."

What’s happening
  1. Request-time inputs: cookies(), headers(), searchParams, unknown params, connection(), and uncached I/O. <Suspense> itself never makes anything dynamic; it only gives a hole a place to stream.
  2. Previous model: the route is dynamic if any of them is touched anywhere in its tree, including the root layout. A theme cookie in the root layout turns every route into ƒ.
  3. Cache Components: the shell is everything outside the boundaries that wait; the build error forces you to choose cache or stream for each read.
  4. Biggest trap in both models: awaiting at the top of a layout or page makes everything below it request-time. Pass the promise down and await it in the small component that needs it.

Learn more: Static and dynamic rendering and Runtime data: cookies, headers and searchParams.

/posts/other isn't in generateStaticParams. What do the first and second visitors get?

Same rq-symbols app (Cache Components on), curl -s -D - three times. For comparison, the rq-legacy app (Cache Components off) has the same page:

app/posts/[slug]/page.tsx
export function generateStaticParams() {
  return [{ slug: "hello" }];
}

export default async function Post({ params }: PageProps<"/posts/[slug]">) {
  const { slug } = await params;
  return <h1>Post: {slug}</h1>;
}
Show answer

The first visitor gets the prerendered shell straight away with the content streamed in; from then on the full page is stored and served as a cache hit. "That's ISR for dynamic routes. Listed params are built ahead; an unlisted one is rendered on its first request and kept. With Cache Components and Partial Prefetching (both on in new 16.4 apps) the first visit isn't blocked: it gets the route's App Shell and the content streams, and the complete page is saved for the next visitor. Without Partial Prefetching the first request waits for the full render. dynamicParams doesn't exist in that model; to reject unknown values you call notFound(), and if the first visit must be complete, ensureStatic = 'navigation' makes it wait."

curl -s -D - localhost:4514/posts/other, three times (cache headers only)
1st  x-nextjs-postponed: 1   Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
2nd  x-nextjs-cache: HIT     Cache-Control: s-maxage=31536000
3rd  x-nextjs-cache: HIT     Cache-Control: s-maxage=31536000
What’s happening
  1. Build: generateStaticParams returned hello, so /posts/hello is a file; every other slug uses the ◐ /posts/[slug] shell.
  2. First request for other: the shell goes out and params resolves at request time, so the response is per request (no-store, postponed).
  3. In the background Next.js stores the full page for other, so the second and third requests are HITs with a year-long s-maxage.
  4. The previous model (the rq-legacy app, Cache Components off) does the same with a blocking first render: /posts/other came back x-nextjs-cache: MISS, then HIT. dynamicParams = false there turns unlisted values into 404s.

Learn more: generateStaticParams and dynamicParams.

Explain ISR. A page revalidates every 60 seconds and nobody visits it for an hour. What does the next visitor see?
Show answer

The old page, instantly; their request triggers a regeneration, and only the visitor after that sees new content. "ISR is stale-while-revalidate for static pages. Within the revalidate window requests get the cached copy; after it, the next request still gets the cached copy and starts a background regeneration, which replaces the copy when it succeeds. So freshness is 'revalidate time plus one request', and with no traffic nothing regenerates. If a regeneration throws, the last good page keeps being served. When a change must show at once, I don't shorten the timer; I invalidate on demand with a tag when the data changes. With Cache Components there's also expire: past it, the next request waits for a fresh render instead of getting the stale copy."

What’s happening
  1. Previous model: export const revalidate = 60 (or revalidate: 60 from getStaticProps in the Pages Router). Cache Components: cacheLife on the 'use cache' inside the route.
  2. An hour of no traffic: nothing ran. The 61st-minute visitor gets the hour-old page (x-nextjs-cache: STALE) and triggers the render.
  3. The next visitor gets the regenerated page (HIT).
  4. expire is the ceiling: chapter 5's quiz had a request two hours after the build with expire: 3600, and it blocked for a fresh render (REVALIDATED) instead of serving the stale copy.
  5. Multi-instance caveat: by default each server instance has its own ISR cache, so instances can disagree until a shared cache handler is configured.

Learn more: Incremental Static Regeneration and cacheLife: how long cached data lives.

What does streaming change, and what does it cost?
Show answer

It changes what the user waits for, not how long the data takes: the shell arrives at once and each Suspense boundary streams in when its data is ready. "Time to first byte drops to the shell's render time, the browser starts painting and loading CSS and fonts while the server works, and each boundary streams and hydrates independently. The total time is still the slowest query. The costs: the status code and headers are committed with the first byte, so notFound(), redirect() and errors after that are handled in the HTML; an LCP element inside a boundary waits for it; fallbacks must reserve space to avoid layout shift; and anything between the server and the user that buffers (nginx, some CDNs, compression) can undo it."

What’s happening
  1. Mechanism: the shell has a <template> placeholder per boundary; later chunks carry the boundary's HTML in a hidden <div> and a tiny script that swaps it in.
  2. Granularity is per boundary: two components in one boundary are revealed together, even if one is ready much earlier (chapter 4 watched a name sit hidden in the HTML for 500 ms).
  3. Status: 200 goes with the shell. Inside a boundary, notFound() becomes not-found UI plus noindex, redirect() a client-side redirect, an error the error.tsx UI.
  4. Infrastructure: nginx needs X-Accel-Buffering: no; check streaming against the production URL, not just locally.

Learn more: Streaming with Suspense and Streaming SSR in practice.

What is Partial Prerendering, and where did the experimental.ppr flag go?
Show answer

One route, partly prerendered and partly rendered per request: the static shell is built ahead with Suspense fallbacks for the holes, and at request time the server resumes only the holes and streams them into the same response. "At build time Next.js renders until it hits something that needs the request and uses the nearest fallback there, saving the HTML and React's postponed state. The shell can come from a CDN, and the dynamic parts follow in one response. With Cache Components, PPR isn't a flag any more: it's how every route works, and ◐ in the build output marks a route with holes. The experimental.ppr flag and experimental_ppr segment export from 14 and the 15 canaries were removed in 16; cacheComponents: true replaced them."

What’s happening
  1. Build: render, postpone at each request-time read, store shell plus postponed state.
  2. Request: send the shell immediately (200), resume rendering only the postponed parts, stream them.
  3. Shell quality is up to you: one boundary around the whole body gives a valid ◐ with an empty shell. Check what comes before the first <template id="B:…"> in curl output.
  4. Version answer: 14 previewed it, 15 had it in canaries behind experimental.ppr, 16 made it the model under Cache Components (and says PPR in 16 works differently from the 15 canaries).

Learn more: Partial Prerendering and The caching model in Next.js 16.

Edge or Node.js runtime? And where does proxy.ts run?
Show answer

Node.js. In 16 the Edge runtime is deprecated for routes, Cache Components reject the runtime export, and proxy.ts always runs on Node.js. "Edge only had web APIs: no fs, no native modules, no TCP database drivers, no ISR, and its latency advantage mostly disappeared when the data lived in one region anyway. A middleware.ts still runs on Edge in 16, with a deprecation warning, which is the one reason to keep the old name for now. For a fast first byte far from the server I'd serve the prerendered shell from a CDN and keep server code on Node near the database."

What’s happening
  1. Default: Node.js for pages, Route Handlers, Server Actions and proxy.
  2. Edge: deprecated (runtime = 'edge' is flagged in the docs), and not allowed with Cache Components.
  3. Proxy: Node.js, can't be configured; the upgrade guide says to keep middleware.ts if you need Edge.
  4. History: middleware launched on Edge in 12; a Node.js runtime for it arrived experimentally in 15.2 and stable in 15.5; 16 renamed it to proxy on Node only.

Learn more: Edge and Node.js runtimes and proxy.ts (formerly middleware).

When would you use output: 'export', and what stops working?
Show answer

When the site needs no server at request time: docs, marketing, a portfolio, or a client-heavy SPA talking to an existing API. "next build writes plain HTML, CSS and JS into out/, which any static host can serve, and you keep Next.js routing, prefetching and per-route HTML. You lose everything that needs a running server: cookies and headers, Server Actions, ISR and revalidation, proxy, redirects and rewrites from config, default image optimisation, after(), and in 16 Cache Components and Partial Prerendering. Dynamic routes need every param at build time. next export the command is gone: deprecated in 13.3, removed in 14."

What’s happening
  1. Output: one HTML file per route plus _next/static; the host needs rewrites for clean URLs.
  2. Request-time features fail the build or don't exist: there is no server to run them. That includes cacheComponents: true: chapter 6's export build failed with "PPR cannot be enabled in export mode", so exports use the previous model.
  3. next/image needs a custom loader or unoptimized, because the optimiser is part of the server.
  4. Good path for teams: start with an export, move to next start or a platform when the first request-time need appears.

Learn more: Static exports and single-page apps.

Walk me through how you'd render an e-commerce site in Next.js 16.
Show answer

Per component, not per route: static where it's shared, cached with tags where it changes, streamed where it's personal, and per request only for checkout. "Marketing and home pages are fully static. Product and category data is shared, so it's 'use cache' with a cacheLife matching how often it changes and a tag per product, invalidated by the CMS webhook or the admin action; popular products go in generateStaticParams. The personal parts, cart count and recommendations, are small components inside <Suspense>, so the product page is a prerendered shell with those streamed in: fast first byte, LCP in the shell, nothing personal cached. Checkout renders per request and is never cached; the sign-in redirect lives in proxy.ts for a real 307, and the page checks the session again before touching the order. Then I verify with the route table and the response headers."

What’s happening
  1. Shared and slow-changing: cache the data, not the route, so one product change revalidates every page that shows it.
  2. Personal: never in a shared cache; stream it, or use 'use cache: private' for per-user content you want prefetchable.
  3. One cookies() read in a shared layout is the most common way to lose static pages; keep it in the smallest component that needs it.
  4. Verify: ○/◐ in the table where you expect them, HIT and s-maxage headers on shared pages.

Learn more: Choosing a strategy per route.

Performance and SEO

Why use next/image instead of <img>, and what's the one thing it can't do for you?
Show answer

Right-sized modern formats, no layout shift and lazy loading by default; but it can't know how wide the image will be, so you must write sizes. "It generates a srcset and re-encodes to WebP or AVIF, so a phone downloads a file that fits; width and height, or fill in a sized box, reserve space; it lazy-loads unless I mark the LCP image with preload (the 16 name for priority). The costs are an optimisation server that does CPU work on first request and stores every variant, and configuration: remotePatterns pinned to exact hosts and paths. Without a correct sizes the browser assumes 100vw and downloads far too much."

What’s happening
  1. The browser, not Next.js, picks the file: it evaluates sizes, multiplies by the device pixel ratio, and takes the smallest candidate at least that wide (750w for a 375-pixel slot at 2×, as chapter 10 observed).
  2. A missing sizes on a responsive image gives a fixed 1x/2x srcset from the intrinsic width: a 2400-pixel file for a 300-pixel slot.
  3. Security: a broad remotePatterns (** or a shared bucket host) lets anyone use your server to fetch and resize their images.
  4. 16 changes: priority became preload, and the defaults for qualities, imageSizes, minimumCacheTTL and redirects changed (maximumRedirects 3).

Learn more: next/image and Core Web Vitals in Next.js.

How does next/font prevent layout shift?
Show answer

It self-hosts and preloads the font files, and generates a fallback font adjusted to the real font's metrics, so text takes the same space before and after the swap. "Font files are downloaded at build time and served from my own origin, with no request to Google at runtime. The fallback @font-face uses a local system font with size-adjust, ascent-override and descent-override computed from the web font, so lines don't re-wrap when it arrives. I call each font once in a shared module, because every call is a separate font instance, and I put its className on the element whose font I want, or use its CSS variable."

What’s happening
  1. Build: the font loader call is evaluated at build time, so options must be literals and the result assigned to a const.
  2. Page: a <link rel="preload"> for the subset the page uses, plus the generated classes.
  3. Swap: fallback text already has nearly the right metrics, so CLS from fonts is close to zero.
  4. Trap from chapter 10: the class on <html> lost to the template's body { font-family: … } rule; the font downloaded but Chrome reported Arial.

Learn more: next/font.

How do you add third-party scripts without hurting performance?
Show answer

With next/script and the latest strategy that still works: lazyOnload for chat and social widgets, the default afterInteractive for analytics and tag managers, beforeInteractive only for what must run before the app, in the root layout. "Page-specific scripts go in that page or its layout so other routes don't pay; next/script loads each once across client navigations. Heavy embeds get facades, like the YouTube component in @next/third-parties. Callbacks such as onLoad are functions, so they need a Client Component. Then I measure INP, because a tag manager can cost more main-thread time than the whole app."

What’s happening
  1. beforeInteractive: injected into the initial HTML, runs before hydration, only works on full document loads.
  2. afterInteractive: after hydration, the default.
  3. lazyOnload: during browser idle time.
  4. Raw <script> tags in JSX misbehave on client navigation: chapter 10 saw a synchronous one never execute and an async one run twice.

Learn more: next/script and third-party scripts.

The root layout sets openGraph: { siteName: "Acme" }; a post's generateMetadata returns openGraph: { type: "article", title }. Does the post have og:site_name?
Show answer

No: metadata is merged shallowly, so the page's openGraph object replaces the layout's whole openGraph object. "Next.js resolves metadata from the root layout down to the page and merges top-level keys: a later segment's openGraph wins entirely, while keys it doesn't set, like description, survive. I keep shared Open Graph fields in a module and spread them, or extend (await parent).openGraph in generateMetadata. Metadata only works in Server Components, and with Cache Components, metadata that reads request data on an otherwise static page fails the build until I cache it or mark the page dynamic."

What’s happening
  1. Order: root layout, nested layouts, page; each contributes an object.
  2. Merge: per top-level key. openGraph from the page replaces openGraph from the layout, including siteName.
  3. title templates and other top-level keys still apply, which is why the bug is easy to miss.
  4. Fix: openGraph: { ...sharedOpenGraph, type: "article", title }. Chapter 10 checked the rendered <head>.

Learn more: Metadata and SEO.

How do you reduce the JavaScript a Next.js app ships?
Show answer

Push code to the server first, then split what's conditional, then watch dependencies. "Server Components ship no JavaScript, so 'use client' marks small interactive leaves, not whole pages: a directive on a header pulls everything it imports into the bundle. Conditional UI (modals, editors, charts behind a click) goes behind next/dynamic, with ssr: false only for components that can't render on the server, and only inside a Client Component. For barrel-file libraries there's optimizePackageImports, and the bundle analyser (next experimental-analyze from 16.1, renamed next analyze in 16.4) shows what each route ships. I don't lazy-load what everyone sees above the fold; that only moves the cost later and adds layout shift."

What’s happening
  1. Server first: a Markdown renderer or a date library used only in Server Components costs the browser nothing.
  2. Boundaries: one 'use client' high in the tree makes its whole import graph client code.
  3. Splitting: next/dynamic is React.lazy plus a loading state; call it at module level.
  4. Measuring: the network panel before and after an interaction, and the analyser per route; 16 removed the First Load JS column from the build output.

Learn more: Lazy loading and bundle size and The 'use client' boundary.

Our LCP got worse after moving to Next.js. Where do you look?
Show answer

At what the LCP element is and when its bytes start downloading: the image or font, the HTML, then JavaScript. "If it's an image: next/image with preload or fetchPriority="high", a correct sizes, not lazy-loaded. If it's text: fonts self-hosted and preloaded with next/font. Then the HTML: is the route ○ or a ◐ shell with the LCP element in the shell, or is it dynamic and blocked on a slow query before anything streams? Then JavaScript: a hero that only appears after hydration, behind ssr: false or fetched in useEffect, can't paint until the bundle runs. I measure production with useReportWebVitals, never next dev."

What’s happening
  1. Identify the element (DevTools Performance panel or the web-vitals attribution).
  2. Image path: lazy-loading the LCP image is the classic regression; preload adds the <link rel="preload">.
  3. HTML path: check TTFB and the route table; an LCP element inside a Suspense boundary waits for its data.
  4. JS path: client-only rendering of the hero delays LCP until hydration.
  5. Field data beats Lighthouse: one simulated phone isn't your users.

Learn more: Core Web Vitals in Next.js and Streaming SSR in practice.

#Server and Client Components questions

Added

These questions are about where code runs and what crosses the wire. The mental model to bring to every one of them: an App Router app has two module graphs. The server graph starts at every page, layout and route file and never reaches the browser; the client graph starts at each 'use client' file and includes everything those files import, and it runs twice, once on the server to produce HTML and once in the browser to hydrate. Between them sits a serialiser: whatever a Server Component hands to a Client Component is turned into the RSC payload, sent inside the HTML and read back in the browser.

So the questions reduce to three checks. Which graph is this module in? (Follow the imports from the nearest 'use client'.) Can this value be serialised? (Data yes, functions no, except Server Actions.) Is it meant to be public? (Everything serialised is readable in View Source.) If you can answer those three for a piece of code, you can answer every question below.

What does "Server Components by default" buy you, and what does it cost?
Show answer

Less JavaScript, direct data access, and data that stays on the server unless you pass it; the cost is that Server Components can't be interactive. "A Server Component's code and its dependencies never ship, so a page that renders Markdown with a heavy library costs the browser nothing for it. It can be async and query the database or call an internal API with a secret, with no API route and no client loading state for the first render. Only what I pass as props to Client Components is sent. In return it can't use state, effects, event handlers or browser APIs, so I add small Client Components where the user interacts and think about what crosses the boundary."

What’s happening
  1. Default: every component under app/ is a Server Component until a 'use client' file is reached.
  2. Output, not code: the browser receives the Server Component's rendered result in the RSC payload, never its source or imports.
  3. Data: async function Page() { const rows = await db.query(…) } is normal; credentials stay in process.env on the server.
  4. Limits: useState, useEffect, onClick and window belong in Client Components; the build rejects hook imports in server modules (next question).

Learn more: Server Components by default.

A component uses useState and has no 'use client'. What happens?
Show answer

The build fails: importing useState into a Server Component module is a compile error that tells you to add the directive. "Hooks only exist on the client, so Turbopack checks the server graph for them. The fix is a 'use client' at the top of that file, or better, moving the interactive part into its own small Client Component and keeping the rest on the server. The directive has to be the very first statement; after an import it's just a string and the file stays a Server Component."

I built this page in a sandbox copy of the template (it isn't one of this page's checked apps, because it doesn't build):

next build (a page.tsx that imports useState, no directive)
Error: Turbopack build failed with 1 error:
./src/app/page.tsx:1:10
Error: You're importing a module that depends on `useState` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.
What’s happening
  1. app/page.tsx has no directive, so it's in the server graph.
  2. It imports useState from react; the server build of React doesn't allow it, and Turbopack reports it at the import, line 1, column 10.
  3. Adding 'use client' makes the page a Client Component; then it can't export metadata or be async, which is why the usual fix is a separate counter.tsx with the directive, rendered by a server page.tsx.
  4. The opposite mistake builds: adding 'use client' to an async component compiles and only breaks in the browser ("is an async Client Component"), so production ships it silently.

Learn more: Server Components by default and The 'use client' boundary.

What does 'use client' actually do?
Show answer

It marks a module as an entry point into the client graph: that module and everything it imports are compiled for the browser, and server modules that import it get a reference instead of the code. "It doesn't mean 'browser only': Client Components are still rendered to HTML on the server. A Server Component can render a Client Component and pass it serialisable props, but can't call its functions; every export of a 'use client' file becomes a client reference on the server, even a constant. I only need the directive at the boundary, not in every file below it, and I put it on the smallest interactive component, because everything under it ships."

What’s happening
  1. Boundary, not label: files imported by a 'use client' file need no directive of their own; they're in the client graph already.
  2. References: the server sees MAX_LENGTH exported from a client file as a function reference, not 280 (chapter 3 printed function [Function (anonymous)]); calling a helper from it fails the build.
  3. SSR: the client graph also runs on the server for the first HTML, so window is undefined there; read browser values in effects.
  4. Placement: a directive on Header ships Header and everything it imports; a directive on a small HeaderMenu ships only the menu.

Learn more: The 'use client' boundary.

A Server Component page imports Nav (no directive), which imports Logo (no directive). The page also imports CartButton ('use client'), which imports formatPrice from lib/money.ts. Nav imports lib/money.ts too. Which code reaches the browser?
Show answer

CartButton and lib/money.ts; not Nav, not Logo, not the page. "Imports decide the graphs. Nav and Logo are only reached through server imports, so they're Server Components: their output travels as payload data, their code doesn't. CartButton is a client entry, so its code ships, and lib/money.ts comes with it because CartButton imports it. money.ts is in both graphs; the server copy serves Nav, the client copy ships with CartButton."

What’s happening
  1. Start at the page: server graph. Follow Nav → Logo: no directive anywhere, so server-only.
  2. CartButton has the directive: the server graph keeps a reference, the client graph gets the module.
  3. Everything CartButton imports joins the client graph without needing a directive: lib/money.ts.
  4. A module imported from both sides is compiled into both. That's fine for pure helpers; a module that reads secrets should start with import "server-only", so a client import fails the build.
  5. If Nav later needs a dropdown, add a small 'use client' NavMenu; don't put the directive on Nav, which would pull Logo into the bundle too.

Learn more: The 'use client' boundary.

Can a Client Component render a Server Component?
Show answer

It can display one, but not import one: pass the Server Component in as children or another prop. "If a 'use client' file imports a component, that component becomes client code too, because imports define the graph. But a Server Component can write <Drawer><CartSummary /></Drawer>: the server renders CartSummary first and passes its output to Drawer as children, and the drawer only decides where it appears. That's how providers in the root layout work, and how a client modal or tabs can wrap server-fetched content without shipping its code. Opening and closing the drawer never re-runs CartSummary; only a navigation, router.refresh() or a revalidating Server Action renders it again."

What’s happening
  1. The owner (the component whose code wrote the JSX) decides what renders; the parent only positions it.
  2. children arrives in the Client Component as already-rendered output, part of the payload.
  3. Client state changes re-render the Client Component, which shows or hides the elements it already holds.
  4. Chapter 3's composition app: CartSummary ran once, at build time, however often the drawer opened.

Learn more: Composition patterns.

Why can't you pass a function from a Server Component to a Client Component, and what's the exception?
Show answer

Because props cross the network as serialised data, and a function's code and closure live in server memory; the exception is a Server Action, which crosses as a reference the browser can call. "React serialises props into the RSC payload: plain objects, arrays, strings, numbers, Dates, Maps, Sets, BigInts, typed arrays, JSX, and even Promises that resolve later. A function can't be serialised; with 'use server' it becomes an action ID, the browser gets a stub, and calling it sends a POST that runs the real function on the server. So either I move the handler into the Client Component or I make it a Server Action."

What’s happening
  1. Build error for a plain function named like a handler (onHover): "Event handlers cannot be passed to Client Component props." It's reported while prerendering, so it fails next build.
  2. Class instances without toJSON fail too ("Only plain objects, and a few built-ins…"); ones with toJSON are silently converted (a URL arrives as a string).
  3. An undefined prop disappears: the key isn't in props on the client.
  4. With Cache Components the build checks prerenderability first: new Date() in a static page fails with "encountered the unstable value" before any serialisation error. Chapter 3 fixed one prop per build and got the errors in that order.

Learn more: Passing data across the boundary.

A Server Component reads user rows (name, email, password hash) and passes them to a Client Component that renders only the names. What does the browser receive?
lib/users.ts
import "server-only";

// Stands in for a database table.
const rows = [
  { id: 1, name: "Ada", email: "ada@example.com", passwordHash: "$2b$10$adaHASHadaHASH" },
  { id: 2, name: "Linus", email: "linus@example.com", passwordHash: "$2b$10$linHASHlinHASH" },
];

export async function getUsers() {
  return rows;
}
components/user-list.tsx
"use client";

import { useState } from "react";

export function UserList({ users }: { users: { id: number; name: string }[] }) {
  const [selected, setSelected] = useState<number | null>(null);
  return (
    <ul>
      {users.map((u) => (
        <li key={u.id} onClick={() => setSelected(u.id)}>
          {u.name}
          {selected === u.id ? " ✓" : ""}
        </li>
      ))}
    </ul>
  );
}
app/page.tsx
import { getUsers } from "@/lib/users";
import { UserList } from "@/components/user-list";

export default async function Home() {
  const users = await getUsers();
  return <UserList users={users} />; // whole rows, though the list shows only names
}
app/safe/page.tsx
import { getUsers } from "@/lib/users";
import { UserList } from "@/components/user-list";

export default async function Safe() {
  const users = (await getUsers()).map(({ id, name }) => ({ id, name })); // a DTO
  return <UserList users={users} />;
}
Show answer

The whole rows: every email and password hash is in the HTML of /, inside the RSC payload script, although the visible list shows only names. "Props to a Client Component are serialised so the browser can hydrate it, and serialisation sends the object you passed, not the fields the component happens to render. The TypeScript type { id; name } doesn't strip anything at runtime. The fix is a DTO at the boundary, ideally returned by a server-only data access layer, so /safe sends only id and name."

next build
Route (app)
┌ ○ /
├ ○ /_not-found
└ ○ /safe


○  (Static)  prerendered as static content
curl -s localhost:4514/ and /safe, searched with grep
/      visible: <li>Ada <li>Linus    password hashes in the HTML: 2   emails: 2
       …\",\"email\":\"ada@example.com\",\"passwordHash\":\"$$2b$10$adaHASHadaHASH\"},{\"id\":2,\"name\":…
/safe  visible: <li>Ada <li>Linus    password hashes in the HTML: 0   emails: 0
What’s happening
  1. Both pages are ○: the rows were read at build time and the page, payload included, is a static file every visitor and CDN receives.
  2. UserList is a Client Component, so React must send its props to the browser. They go into self.__next_f.push(...) script tags in the HTML (the excerpt above; $$ is how the payload escapes a $).
  3. The visible markup is identical on both pages, which is why this leak survives code review: nothing on screen is wrong.
  4. /safe maps each row to { id, name } before the boundary, so nothing else exists to serialise.
  5. Defences in depth: import "server-only" on the data module (already here: it keeps the module off the client, not the data you pass), DTOs from the data access layer, and optionally React's taint APIs to throw if a marked object reaches a Client Component.

Learn more: Data security: the Data Access Layer and Passing data across the boundary.

How do you keep secrets out of the client? Does a variable without NEXT_PUBLIC_ ever reach the browser?
Show answer

Three layers: only NEXT_PUBLIC_ variables are inlined into client code, modules that read secrets start with import "server-only", and I pass Client Components only DTOs. And yes, an unprefixed secret can reach the browser: through the HTML, if a Client Component reads it. "NEXT_PUBLIC_ values are pasted into the bundle at build time, so they're public and frozen per build. Unprefixed ones are absent from the browser bundle, but a Client Component is also rendered on the server, where process.env.SECRET is real, so its server render writes the value into the HTML. The guard turns a client import of a server module into a build error with an import trace."

next build (sandbox: a 'use client' widget importing lib/db.ts, which starts with import "server-only")
./src/lib/db.ts:1:1
Error: 'server-only' cannot be imported from a Client Component module
> 1 | import "server-only";
Import traces:
  Client Component Browser:
    ./src/lib/db.ts [Client Component Browser]
    ./src/components/widget.tsx [Client Component Browser]
    ./src/components/widget.tsx [Server Component]
    ./src/app/page.tsx [Server Component]
What’s happening
  1. Build-time inlining: process.env.NEXT_PUBLIC_API_URL in client code becomes a string literal; changing the container's environment later doesn't change it.
  2. Server render of a Client Component: chapter 3 put process.env.WEATHER_API_KEY in a 'use client' component on a static page; the key was in the prerendered HTML, and the browser rendered undefined (a hydration mismatch on top of the leak).
  3. The guard: the same build also printed a second, misleading message for that file ("…but you are using it in the Pages Router"); the one above, with its trace, is the useful one.
  4. Rule of thumb from the docs: only the data access layer touches process.env, and Client Components get values they're meant to show.

Learn more: server-only, client-only and environment safety and Environment variables and secrets.

How do you use React context in the App Router?
Show answer

The provider is a Client Component that renders {children}, and a Server Component layout renders it around its children. "Context only exists on the client, so a 'use client' file creates the context, holds any state and renders children. The root or section layout stays a Server Component and wraps its children in the provider; the pages inside stay Server Components, because they're passed in as rendered output. Client Components below can read the context; Server Components can't, so they read the source directly. To share server data, I start the request in the layout without awaiting it, put the Promise in context and unwrap it with use() under Suspense. And I put providers as deep as possible: a root provider that reads request data makes every route partly dynamic."

What’s happening
  1. providers.tsx with 'use client': createContext, useState, <Ctx.Provider value={…}>{children}</Ctx.Provider>.
  2. app/layout.tsx (server): <Providers>{children}</Providers>.
  3. Pages remain server code: being rendered inside a client provider doesn't make them client components (the composition rule).
  4. Third-party libraries that use hooks but don't ship 'use client': re-export them from your own 'use client' file.

Learn more: Context, providers and third-party libraries.

What is the RSC payload, and what happens on the network when you click a <Link>?
Show answer

The payload is React's serialised description of the rendered Server Component tree, with slots and props for Client Components; a click usually needs nothing or one GET with an rsc: 1 header. "On the first load the payload is inlined in the HTML. When a link enters the viewport, the router prefetches the target's tree and its static segments. On click, a fully static page renders straight from that cache with no request; if part of it is dynamic, the router sends one GET to the URL with rsc: 1 and a header describing the tree it already has, and the server renders only the segments below the deepest shared layout, streaming them. React merges the result, so shared layouts and their state survive. A CDN that drops the rsc header or caches by path only can return HTML where the router expects a payload, and navigations become full reloads."

What’s happening
  1. Format: numbered lines of React's Flight format, such as ["$","p",null,{"children":"…"}] for an element, and ["$","$L1b",null,{"initialLikes":12}] for a Client Component: the module in row 1b, with these props.
  2. First load: self.__next_f.push([1, "…"]) scripts in the HTML carry it for hydration.
  3. Static target: chapter 3 saw "Home" render with no request at all, from the prefetched segment.
  4. Dynamic target: the prefetched shell first ("Live / Loading time…"), then one rsc: 1 request filling the hole; the root layout came back as null, i.e. reused.

Learn more: How the RSC payload works.

Where does each console.log appear: in a Server Component, and in a Client Component, in production?
Show answer

A Server Component logs only in the server's output; a Client Component logs in the browser and, for its server render, in the server's output too. "Server Components run on the server, so console.log goes to the Node.js process: the terminal or the platform's log stream. In next dev those logs are also replayed in the browser console with a 'Server' badge, which is a development feature; under next start the browser never sees them. A Client Component logs in the browser on hydration and each re-render, and during SSR its logs go to the server output too. And for a ○ page, the server log appears in the build output, because that's when it rendered."

What’s happening
  1. Server Component on a dynamic route: one server log per render (possibly two per request on 16.4 with partial prefetching; see the data fetching group below).
  2. Server Component on a static route: logs during next build, never per request.
  3. Client Component: SSR log on the server (where window is undefined), then browser logs.
  4. Production debugging of server code means server logs plus the error digests shown in error UI.

Learn more: Logging and debugging and Server Components by default.

What causes hydration mismatches in Next.js, and how do you avoid them?
Show answer

The server HTML and the browser's first render must produce the same markup; anything that differs between the two (time, randomness, window, locale, localStorage) breaks that. "Client Components render on the server first, so a value read during render on the server and again in the browser can differ: Date.now(), Math.random(), window.innerWidth, navigator.language. React reports a hydration error and re-renders the client tree. I read browser-only values in useEffect or event handlers, render a stable placeholder first, or move the value to the server. A secret read in a Client Component causes one too: real value in the HTML, undefined in the browser."

What’s happening
  1. Server render: values from the server's clock, locale and environment.
  2. Browser first render: must match exactly; React compares as it hydrates.
  3. Mismatch: in production it's Minified React error #418 in the console, and the client re-renders.
  4. Fix pattern: const [w, setW] = useState<number | null>(null); useEffect(() => setW(innerWidth), []).

Learn more: How a request becomes a page.

#Data fetching and caching questions

Added

Caching is where Next.js interviews go wrong most often, because the answer changed in every major version and both people in the room may be right for different years. Before answering, say which model you're describing: the previous model (13–15, and 16 without cacheComponents), where a route is static or dynamic as a whole and fetch has a Data Cache, or Cache Components (16, the default for new apps), where nothing is cached unless you write 'use cache' and every route is a static shell with streamed holes.

The mental model that covers both: there are four copies Next.js can answer from, and each has its own lifetime and its own way to be cleared. Per render, request memoization lets two components ask for the same data and share one call. On the server, cached data ('use cache' entries, or the old Data Cache) persists across requests and users. Also on the server, the prerendered output of each route (the old Full Route Cache, the static shell in 16). In the browser, the client router cache keeps prefetched and visited payloads. "The page is stale" always means one of those four kept a copy, and the fix depends on which.

LayerLivesShared byCleared by
Request memoization (fetch GET dedup, React cache())One render passComponents in that renderThe end of the render
Cached data ('use cache'; previously the Data Cache, unstable_cache)Until revalidate/expire, or invalidatedAll requests and users (per server instance by default)updateTag, revalidateTag, revalidatePath, time
Prerendered route output (static shell; previously the Full Route Cache)Until the data inside it is revalidated, or the next deployAll requestsRevalidating what it contains, a new build
Client router cacheThe browser tab, for the stale time (min 30 s)One userrouter.refresh(), a Server Action that revalidates or calls refresh(), a reload
How do you fetch data in the App Router?
Show answer

In a Server Component, directly: make it async and await a fetch, an ORM call or any function, right where the data is rendered. "It runs only on the server, so credentials and query code never ship, and only the rendered output is sent. fetch isn't cached by default since 15. With Cache Components, an uncached read has to sit inside <Suspense>, so the shell can be sent at once and the data streams in, or be cached with 'use cache'; otherwise the build fails and tells me to choose. I fetch on the client only for data that changes while the page is open, depends on browser APIs, or is triggered by input, like autocomplete."

What’s happening
  1. Colocate the read with the component that renders it; no getServerSideProps, no API route in between.
  2. Don't fetch your own Route Handler from a Server Component: it's an extra HTTP hop to yourself, it needs an absolute URL, and during next build there's no server to answer. Import the function the handler calls.
  3. Decide per read: shared and slow-changing goes in 'use cache' with a cacheLife; per request or personal goes inside <Suspense>.
  4. Keep queries in a server-only data access layer that checks authorisation and returns DTOs.

Learn more: Fetching data in Server Components.

How do you avoid request waterfalls in Server Components?
Show answer

Start independent requests together, and don't let component nesting serialise them. "The obvious waterfall is sequential awaits in one component: three independent 300–500 ms requests took 1.2 s that way and 0.54 s with Promise.all in chapter 4's measurement. The sneaky one is nesting: a child can't start fetching until its parent has awaited and returned it, so parent-awaits-then-renders-child is a chain even with no sequential awaits in any single component. I hoist requests (start the promises in the parent and pass them down), render siblings instead of nesting, or preload: call the function early without awaiting and let memoization reuse the in-flight request. And each independent section gets its own Suspense boundary so it streams on its own."

What’s happening
  1. Sequential: const a = await getA(); const b = await getB(); costs a + b.
  2. Parallel: const [a, b] = await Promise.all([getA(), getB()]) costs max(a, b).
  3. Nesting: <Artist> awaits the artist, then renders <Albums>, which only now starts its query.
  4. Hoisting: start getAlbums(id) in the page and pass the promise, or render <Albums> as a sibling under its own <Suspense>.
  5. Streaming doesn't fix a waterfall inside one boundary; it only lets other boundaries show earlier.

Learn more: Parallel and sequential fetching.

What's the difference between request memoization and caching?
Show answer

Memoization lasts one render and is thrown away; caching persists across requests and users. "Identical GET fetches, and calls to a function wrapped in React's cache(), run once and share their result within one render pass. That's what lets the layout and the page both call getUser() without a second query. Caching is 'use cache' with cacheLife in 16, or the Data Cache with force-cache and revalidate in 13–15: stored, shared, invalidated by time or tags. Memoization doesn't apply in Route Handlers or Server Actions, compares cache() arguments by identity so objects never match, and is skipped for a fetch with an AbortSignal."

What’s happening
  1. Scope: memoization belongs to one React render; the next request starts empty.
  2. fetch: automatic for GETs with the same URL and options. Other I/O: wrap the function in cache() once, at module level, and import that wrapper.
  3. Arguments: primitives ("u1") match; two { id: "u1" } literals don't, so chapter 4's object version ran the query every time.
  4. Not a render: Route Handlers and Server Actions get no memoization at all.
  5. Persistent caching is what actually stops repeats across requests, and on 16.4 even within one request (next question).

Learn more: Request memoization and React cache().

A component inside <Suspense> reads cookies() and logs each render. How many log lines does one request produce on 16.4.3?

Template defaults (cacheComponents and partialPrefetching on). The same app has a second route whose component awaits connection() instead.

app/cookies/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

async function Greeting() {
  const name = (await cookies()).get("name")?.value ?? "guest";
  console.log(`[render] Greeting for ${name}`);
  return <p>Hello {name}</p>;
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Greeting />
    </Suspense>
  );
}
app/connection/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";

async function Clock() {
  await connection();
  console.log("[render] Clock");
  return <p>{new Date().toISOString()}</p>;
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Clock />
    </Suspense>
  );
}
Show answer

Two for the cookie route, one for the connection() route; with partialPrefetching: false, one each. "The documented rule is one render per request, and memoization is per render. But with Partial Prefetching on, a request-time render can run the dynamic part a second time: Next.js 16.4.3's source describes a 'final runtime prerender' after the dynamic render, whose result is embedded in the response's payload so the client can reuse it. So I keep renders free of side effects, I don't promise 'this query runs once per request' without measuring, and for an expensive read I use 'use cache' or 'use cache: private' rather than relying on cache()."

next build
Route (app)
┌ ○ /_not-found
├ ◐ /connection
└ ◐ /cookies


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
next start -p 4514 log: one curl -b name=ada /cookies, then one curl /connection
partialPrefetching: true (template)          partialPrefetching: false (one line changed)
[render] Greeting for ada                    [render] Greeting for ada
[render] Greeting for ada                    [render] Clock
[render] Clock
What’s happening
  1. Both routes are ◐: a static shell with the fallback, and one hole each.
  2. /cookies with the template config: the hole rendered twice for one request. Chapter 4 traced the cause to the "runtime prerender" that Partial Prefetching adds, which re-runs components to produce a reusable result for the client cache; the bundled docs don't describe it.
  3. /connection: one log line. The second pass still starts Clock but stops at await connection(): in my re-run with an extra log before connection(), that one printed twice per request and the one after it once (chapter 5 measured the same).
  4. With partialPrefetching: false (a sandbox copy of this app, nothing else changed), the cookie route rendered once, the textbook behaviour.
  5. Chapters 1, 3, 5, 8 and 11 saw the same second render (components, after() callbacks in a page, a database call in a trace) and attribute it to the same cause. A fetch in that second pass wasn't repeated in chapter 4's test, but a plain database call was.

Learn more: Request memoization and React cache().

Can a layout pass the user it loaded to its page as a prop?
Show answer

No. The layout only gets children, which is Next.js's router element, not the page; layouts and pages render independently and in parallel. Both call the same memoized function instead. "fetch GETs dedupe automatically; for a database query I wrap the function in React's cache() at module level, and the layout and page each call it: one query per render. For Client Components, the layout can start the request without awaiting and put the promise in context, and consumers unwrap it with use(). A layout also persists across navigations, so a value passed down would be the one from the first page load. In the Pages Router this was _app with props or a provider."

What’s happening
  1. cloneElement(children, { user }) adds the prop to the router element, which ignores it; the page still receives only params and searchParams (chapter 4 checked).
  2. Parallel rendering: neither waits for the other, so there is no "pass down" moment.
  3. Memoization makes "call it twice" free within one render pass.
  4. Awaiting the user at the top of the layout would also block the whole layout, and with Cache Components fail the build without a boundary.

Learn more: Sharing data between layouts and pages.

When should a Next.js app fetch on the client?
Show answer

Only when the server can't or shouldn't: data that changes while the page is open, data that depends on browser APIs, and requests triggered by user input. "Everything the first screen needs I fetch in Server Components, because a client fetch can't start until HTML, JavaScript and hydration are done, and that gap grows on real networks and phones. When the client does need a cache, I seed SWR or TanStack Query with the server's data (SWR fallback, TanStack HydrationBoundary) so there's no spinner on first paint, and the client fetches from a Route Handler, never from the database directly. I don't use Server Actions for reads: they're POSTs, uncached and queued one at a time."

What’s happening
  1. Live data: polling, Server-Sent Events from a Route Handler, websockets via a separate service.
  2. Browser-dependent: geolocation, viewport, localStorage.
  3. Input-driven: search-as-you-type, with debouncing and race handling.
  4. First paint: server-rendered, then hydrated into the client cache.

Learn more: Fetching on the client and Server Actions vs Route Handlers.

What changed about caching in Next.js 16?
Show answer

With Cache Components, nothing is cached implicitly: you mark functions or components with 'use cache', give them a lifetime with cacheLife and tags with cacheTag, and everything else that can finish at build time is prerendered anyway. "Anything that needs the request (cookies, headers, search params, uncached I/O) has to sit inside <Suspense>, or the build fails. So every route gets a static shell that can be served instantly, with dynamic holes streamed in. In 13 and 14, fetch was cached by default and one cookies() call made the whole route dynamic; 15 made fetch uncached by default; 16 makes caching explicit and per component instead of per route. Route segment config like revalidate and dynamic doesn't apply with Cache Components."

What’s happening
  1. Explicit: 'use cache' on a function, component or file; cacheLife('hours'); cacheTag('products').
  2. Prerendered by default: plain JSX and long-lived cached output go into the shell.
  3. Forced choice: an uncached read outside Suspense is a build error, so "accidentally static" (14's problem) and "accidentally dynamic" (15's) both disappear.
  4. <Suspense> doesn't make anything dynamic; what a component reads does.
  5. Two related 16 API changes: revalidateTag needs a profile argument, and updateTag and refresh are new.

Learn more: The caching model in Next.js 16.

How is 'use cache' different from React's cache() and from useMemo?
Show answer

useMemo lives in one component instance in the browser, cache() lasts one server render, 'use cache' is a persistent server cache shared across requests and users. "'use cache' serialises the result and stores it, in memory by default or in a configured cache handler, keyed by the build, the function's identity, its arguments and any variables it closes over, with a lifetime from cacheLife and tags from cacheTag. Because its output can be prerendered, it also decides what goes into the static shell. Arguments and return values must be serialisable, and request APIs like cookies() can't be used inside it."

What’s happening
  1. Key: a cached function that closes over team and takes month is keyed by both; calling it twice with the same values in one render runs the body once.
  2. Across requests: same key, served from the stored entry until it's revalidated or evicted.
  3. Restart or a new instance: the default in-memory store is empty again; on serverless or several servers, configure a shared handler for 'use cache: remote'.
  4. cache() is the right tool for "same request, many callers" (a session lookup); 'use cache' for "many requests, same answer" (a product).

Learn more: 'use cache'.

Explain stale, revalidate and expire in cacheLife.
Show answer

Three clocks: stale is for the browser, revalidate is server-side stale-while-revalidate, expire is the hard ceiling. "stale is how long the client router reuses the content without asking the server, at least 30 seconds. After revalidate, the next request still gets the cached version instantly and triggers a background regeneration. After expire, with no traffic in between, the next request waits for a fresh render. So revalidate bounds how often the work is done, and expire bounds how old the content anyone sees can be. The default profile is 5 minutes / 15 minutes / never; and lifetimes with an expire under five minutes, like the seconds profile, aren't prerendered: they become holes."

What’s happening
  1. Request within revalidate: HIT, cached version.
  2. After revalidate, before expire: STALE, cached version, and a regeneration starts.
  3. After expire: the request blocks and renders fresh.
  4. Time-based revalidation is lazy: "every 10 seconds" means "at most every 10 seconds, when someone asks".
  5. Short lifetimes: /ticker in the rq-symbols table above is ◐ although it's cached, because seconds expires after one minute.

Learn more: cacheLife: how long cached data lives.

A Server Action adds a note and calls revalidateTag("notes", "max"). What does the user see right after, and on the next two reloads? What changes with updateTag?
lib/notes.ts
import "server-only";
import { cacheLife, cacheTag } from "next/cache";

const notes = ["Buy beans"]; // stands in for a database table

export async function addNote(text: string) {
  notes.push(text);
}

export async function countNotes() {
  return notes.length; // uncached: always the live value
}

export async function listNotes() {
  "use cache";
  cacheLife("hours");
  cacheTag("notes");
  return [...notes];
}
app/actions.ts
"use server";

import { revalidateTag, updateTag } from "next/cache";
import { addNote } from "@/lib/notes";

export async function addWithRevalidateTag() {
  await addNote(`Note ${Date.now() % 1000}`);
  revalidateTag("notes", "max");
}

export async function addWithUpdateTag() {
  await addNote(`Note ${Date.now() % 1000}`);
  updateTag("notes");
}
app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { countNotes, listNotes } from "@/lib/notes";
import { addWithRevalidateTag, addWithUpdateTag } from "./actions";

async function LiveCount() {
  await connection();
  return <p id="live">Database: {await countNotes()} notes</p>;
}

export default async function Home() {
  const notes = await listNotes();
  return (
    <main>
      <p id="cached">Cached list: {notes.length} notes</p>
      <Suspense fallback={<p>Counting…</p>}>
        <LiveCount />
      </Suspense>
      <form action={addWithRevalidateTag}>
        <button id="revalidate">Add (revalidateTag)</button>
      </form>
      <form action={addWithUpdateTag}>
        <button id="update">Add (updateTag)</button>
      </form>
    </main>
  );
}
Show answer

With revalidateTag(…, "max"): nothing changes on screen, the first reload still shows the old list, and only the second reload shows the new note. With updateTag: the action's own response already shows it. "revalidateTag with a profile is stale-while-revalidate: it marks the tag stale, the next request is served the old entry and triggers the regeneration, and the one after gets fresh data. It's right for a CMS webhook, where nobody is waiting. updateTag expires the tag immediately and, because it's called in a Server Action, the response carries the re-rendered page: read-your-own-writes. It only works in Server Actions; in a Route Handler I use revalidateTag(tag, { expire: 0 }) if it must be gone at once."

next build
Route (app)      Revalidate  Expire
┌ ◐ /                    1h      1d
└ ○ /_not-found


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
headless Chrome on next start: #cached | #live after each step, and the requests the page sent
load                                   Cached list: 1 notes | Database: 1 notes
click "Add (revalidateTag)", +1.5 s    Cached list: 1 notes | Database: 1 notes   requests: POST / next-action=00e516c3…
reload 1                               Cached list: 1 notes | Database: 2 notes
reload 2                               Cached list: 2 notes | Database: 2 notes
click "Add (updateTag)", +1.5 s        Cached list: 3 notes | Database: 3 notes   requests: POST / next-action=00a88a5f…
reload 3                               Cached list: 3 notes | Database: 3 notes
What’s happening
  1. The route is ◐ with 1h / 1d: the cached list is in the static shell, the live count is a hole.
  2. The revalidateTag click sent one POST / with a Next-Action header (that's what a Server Action is on the wire). The database got the note, but the response brought no new page: even the uncached live count on screen stayed at 1.
  3. Reload 1 was served the stale list (1) with the live count at 2, and triggered the background regeneration; reload 2 got the regenerated list (2).
  4. The updateTag click: the tag expired immediately and the action's response contained the re-rendered page, so both numbers read 3 without a reload.
  5. If the cached function had no cacheTag, neither call would help; and redirect() after a write must come after the invalidation, because it throws.

Learn more: cacheTag and revalidating on demand and Revalidating and redirecting after a mutation.

When do you use revalidatePath and refresh() instead of tags?
Show answer

revalidatePath when you don't know which tags a page's data uses; refresh() when only uncached data changed and you just want the current page re-rendered. "revalidatePath('/products') invalidates by route, which over-invalidates (everything cached under that path) and under-invalidates (other pages showing the same data aren't touched), so tags are better when the data is tagged. refresh() from next/cache, called in a Server Action, makes the action's response include a fresh render of the current route; it doesn't touch any cache, so it suits things like a notification count read from cookies or an uncached query. In a Client Component outside an action, router.refresh() does the same."

What’s happening
  1. Tag: precise, works across every page that used the data.
  2. Path: coarse, but works without tags, and in the previous model it also covers the route's prerendered output.
  3. refresh(): re-render only, no invalidation; a useOptimistic overlay without it (or updateTag) disappears and shows the old list.
  4. All of these return normally (they don't throw), so an action can call them and still return a value to useActionState.

Learn more: cacheTag and revalidating on demand and Optimistic updates with useOptimistic.

Why can't you read cookies inside 'use cache', and what do you do instead?
Show answer

Because a cached result is shared by everyone who calls it with the same arguments; if it could read cookies itself, the first user's cookie would decide what everyone sees, and the cookie wouldn't be part of the key. "So cookies(), headers() and searchParams are forbidden in cached scopes, including in helpers they call. I read the value in an uncached component inside <Suspense> and pass what I need as an argument, which puts it in the key: one entry per currency or language, not per user. If I really need caching around code that reads cookies, there's 'use cache: private', which is per user and not stored on the server."

What’s happening
  1. Read outside: const currency = (await cookies()).get("currency")?.value ?? "EUR" in a component under Suspense.
  2. Pass in: await getPrices(currency), where getPrices has 'use cache'.
  3. Cardinality: pass low-cardinality values. A user id or a raw search string makes one entry per request: a cache that never hits and fills memory.
  4. Rule follows the call stack: a helper inside the cached function that reads a cookie fails the same way, and on a per-request route that can surface only at runtime.

Learn more: Runtime data: cookies, headers and searchParams.

When do you use 'use cache: private' and when 'use cache: remote'?
Show answer

private for per-user content you want prefetchable and reusable in the browser; remote for shared results computed at request time that should be shared across server instances. "'use cache: private' can read cookies and headers, but in production nothing is stored on the server across requests: it exists so personal content can be included in prefetches and reused on the client. It is not a per-user server cache. 'use cache: remote' picks a named cache handler, typically Redis, so a slow or rate-limited backend sees one request per key per lifetime instead of one per instance; without a cacheHandlers.remote entry it's just an in-memory LRU per process. For anything in the static shell plain 'use cache' is enough, because that output is stored with the prerender."

What’s happening
  1. Private: per user, not shared, not stored server-side; chapter 5's picks were recomputed on every request.
  2. Remote: the directive names a handler; the handler decides where entries live.
  3. Keys: remote caches should be keyed on low-cardinality values (currency, locale), never user id.
  4. Self-hosting with several replicas: 'use cache' entries and tag invalidations are per process unless you configure shared handlers.

Learn more: 'use cache: private' and 'use cache: remote' and Caching when self-hosting.

Which of these GET Route Handlers are prerendered with Cache Components on? And the same constant handler with Cache Components off?

Cache Components on (rq-handlers):

lib/likes.ts
let likes = 0;
export const getLikes = () => likes;
export const addLike = () => ++likes;
app/api/count/route.ts
import { getLikes } from "@/lib/likes";

export async function GET() {
  return Response.json({ likes: getLikes() });
}
app/api/likes/route.ts
import { addLike, getLikes } from "@/lib/likes";

export async function GET() {
  return Response.json({ likes: getLikes() });
}

export async function POST() {
  return Response.json({ likes: addLike() });
}
app/api/config/route.ts
export async function GET() {
  return Response.json({ theme: "dark", builtAt: new Date().toISOString() });
}
app/api/echo/route.ts
export async function GET(request: Request) {
  return Response.json({ agent: request.headers.get("user-agent") });
}

Cache Components off (the rq-legacy app from the first group):

app/api/hello/route.ts
export async function GET() {
  return Response.json({ hello: "world" });
}
app/api/time/route.ts
export async function GET() {
  return Response.json({ now: new Date().toISOString() });
}
Show answer

With Cache Components only /api/count is prerendered, and it serves {"likes":0} forever. Without Cache Components even the constant /api/hello is dynamic. "With Cache Components a GET handler follows the page rules: if it reads nothing from the request, no clock and no I/O, it's run at build time and stored, which is a trap for in-memory or constant data. Reading the clock or the request makes it per request, and a file that also exports POST was dynamic as a whole. In the previous model, 15 made GET handlers dynamic by default, so you opt in with dynamic = 'force-static' or revalidate; in 13–14 they were static by default. Other methods are never cached."

next build
Route (app)
┌ ○ /_not-found
├ ƒ /api/config
├ ○ /api/count
├ ƒ /api/echo
└ ƒ /api/likes


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
curl on next start (rq-handlers)
$ for i in 1 2 3; do curl -s -X POST localhost:4514/api/likes; done
{"likes":1}{"likes":2}{"likes":3}
$ curl -s localhost:4514/api/likes
{"likes":3}
$ curl -s -D - localhost:4514/api/count
x-nextjs-cache: HIT
cache-control: s-maxage=31536000
{"likes":0}
$ curl -s localhost:4514/api/config   (twice, a second apart)
{"theme":"dark","builtAt":"2026-10-25T19:05:07.285Z"}
{"theme":"dark","builtAt":"2026-10-25T19:05:08.316Z"}
What’s happening
  1. /api/count reads a module variable synchronously: from the build's point of view a constant. It ran once during next build and its {"likes":0} is served as a HIT whatever happens later.
  2. /api/likes has the identical GET, but the file also exports POST, and the route was ƒ; its GET read the live counter, 3.
  3. /api/config reads new Date(). In a page that would be a prerender error; in a Route Handler it simply made the route dynamic, and each request got a new timestamp.
  4. /api/echo reads request.headers: per request, ƒ.
  5. In the rq-legacy table above, both /api/hello (a constant) and /api/time are ƒ: that's 15's default for GET handlers, which 16 keeps without Cache Components. The honest fixes for the trap are a real store (I/O), await connection(), or 'use cache' with cacheTag and a revalidateTag after each write.

Learn more: Caching and Route Handlers.

Previous model: a dynamic page reads cookies(), then calls fetch(url) and fetch(url, { cache: "force-cache" }). Over three requests, which responses repeat?
Show answer

The plain fetch gives a new response every time; the force-cache one gives the same response every time. "Reading cookies() makes the route dynamic, so it renders per request. On 15 and later, a fetch without options on a dynamic route isn't cached, so it runs every time. force-cache stores the response in the Data Cache, keyed by the request, and reuses it across requests and routes until it's revalidated by time or tag. On 13–14 the plain fetch would have defaulted to force-cache too, except for fetches after cookies() or headers(), which is exactly why reading an older app starts with its version."

What’s happening
  1. Dynamic route: no prerendered file, every request renders.
  2. fetch(url): no cache on 15+, three different responses (chapter 5 used a UUID endpoint).
  3. fetch(url, { cache: "force-cache" }): one Data Cache entry, shared even by other routes making the same request.
  4. Other previous-model knobs: next: { revalidate: 60 } and next: { tags: [...] } on fetch, unstable_cache for non-fetch functions, export const revalidate and dynamic per route.
  5. The trap in the other direction: on a static route, fetch(url) with no options runs only at build time, so "uncached by default" still shows the same value forever.

Learn more: The previous model: fetch cache, revalidate and unstable_cache.

A page shows data that is hours old. How do you find out why?
Show answer

Find out which model and which layer kept the copy: check the version and cacheComponents, then the route table, then the data's cache settings and invalidations, then the browser. "With Cache Components: is the data in a 'use cache' function with a long cacheLife, and is anything calling updateTag or revalidateTag for its tags? Without: is the route ○, so the HTML was generated at build or last revalidation, or ƒ, in which case look at the fetch options (force-cache, next.revalidate) or unstable_cache. Then multiple instances: each has its own cache unless there's a shared handler. Then the CDN, which may honour a long s-maxage. Then the client router cache after a mutation done outside a Server Action. The fix depends on intent: a lifetime or tag invalidation for 'may be a little stale', streaming per request for 'must be live'."

What’s happening
  1. Version and flags first: the same code can be cached on 14 and uncached on 15.
  2. next build: ○ with Revalidate/Expire columns tells you the route's lifetime.
  3. Response headers on production: x-nextjs-cache and Cache-Control say whether the server answered from a prerender.
  4. Infrastructure: per-instance caches and CDNs are the "only some users see old data" cases.
  5. Memoization is never the culprit across requests; it only lasts one render.

Learn more: The previous model, Caching layers at a glance and Caching when self-hosting.

Walk me through the caching layers in Next.js, old names and new.
Show answer

Four copies: request memoization per render, cached data on the server, the prerendered route output, and the client router cache in the browser. "Memoization dedupes identical fetch calls and React.cache functions within one render. Cached data was the Data Cache for fetch and unstable_cache in 13–15; in 16 it's 'use cache' entries with cacheLife and cacheTag, in memory by default or in a cache handler. Route output was the Full Route Cache, a whole static page; in 16 it's the static shell every route has, with holes streamed per request. And the client router cache keeps prefetched and visited payloads; with Cache Components its lifetime is the stale value, at least 30 seconds. Invalidation goes from the inside out: invalidate the data, the next request regenerates the route output, and a Server Action that revalidates also refreshes the client."

What’s happening
  1. Inside a render: memoization, gone after the render.
  2. Across requests: cached data, cleared by tags, paths or time.
  3. Route output: rebuilt when its data is revalidated or on the next deploy.
  4. Browser: cleared by router.refresh(), a revalidating Server Action or a reload.
  5. The table at the top of this group is the version to draw on a whiteboard.

Learn more: Caching layers at a glance.

#Mutations, security and deployment questions

Added

This group is about code that changes things and code that guards things, and the mental model is one sentence: every entry point is public. A Server Action is a POST endpoint anyone can call with any arguments, from any path. A Route Handler is a URL. A page's payload is in View Source. proxy.ts can be skipped (it was, in CVE-2025-29927). So authentication and authorisation live next to the data, in a server-only data access layer that every page, action and handler goes through, and everything in front of it (proxy redirects, hidden buttons, layout checks) is user experience, not security.

The second half covers what happens after the code is written: errors and monitoring, configuration that's frozen at build time, and running more than one server. The recurring theme there is per-process state: caches, ISR pages, tag invalidations and module variables live in one Node.js process unless you make them shared.

Server Actions

What is a Server Action, really?
Show answer

An async function marked 'use server' that runs on the server; the client gets a reference (an action ID), and calling it sends a POST to the current URL with a Next-Action header and the serialised arguments. "The server looks up the ID, runs the function and streams back the return value in React's Flight format, plus the re-rendered page if the action revalidated, refreshed or redirected. So it's RPC built into React: one round trip for the write and the new UI, and forms work before JavaScript loads. The flip side is that every action is a public POST endpoint, so I validate input and check authentication and authorisation inside each one."

What’s happening
  1. On the wire: the notes app in the caching group sent POST / with next-action=00e516c3… when its form was submitted.
  2. Routing doesn't protect it: chapter 7 replayed an action to /does-not-exist, got 404 Not Found, and the action ran anyway (the like count went up).
  3. IDs: unguessable, recalculated between builds, and exports no client code uses get no endpoint (since 15), but anything in the page is discoverable.
  4. Closures: values an inline action captures are encrypted in the page; .bind arguments and hidden inputs are plain user input.

Learn more: Server Actions.

How do forms work with Server Actions, and what does progressive enhancement mean here?
Show answer

Pass the action to <form action>; React calls it with a FormData, and Next.js also renders the form as real HTML that posts to the current URL, so it works before hydration. "Before JavaScript loads, the browser submits natively and the server answers with a page or a 303 redirect. After hydration React sends the same fields with fetch in a transition and applies the response without a reload. Extra arguments go in with .bind. For GET-style search forms I use next/form, which turns them into client navigations. And I test with JavaScript off, because a non-redirecting action on a partially prerendered page can come back differently without JavaScript; redirecting after a write avoids surprises."

What’s happening
  1. HTML: the form gets a hidden $ACTION_ID_… field, which is all a POST needs.
  2. Without JS: native POST, server response; redirect() becomes 303 so the browser follows with a GET.
  3. With JS: transition, pending state, response merged into the tree.
  4. Old knowledge: experimental.serverActions: true was needed only in 13 (stable in 14), and useFormState from react-dom is pre-15 (React 19 renamed it useActionState); the Pages Router can't use Server Actions at all.

Learn more: Forms with Server Actions.

How do you handle pending state, validation errors and unexpected errors in a Server Action form?
Show answer

useActionState for the result, useFormStatus or the hook's pending flag for the button, expected failures returned as data, unexpected ones thrown to error.tsx. "With useActionState the action's signature becomes (prevState, formData) and its return value becomes the form's state. I validate with a schema such as Zod's safeParse and return field errors plus the values to refill. The pending flag disables the button. If the action throws, production clients get only a digest, the message stays in the server log, and the nearest error boundary replaces the form, which is right for an outage and wrong for a typo. Without JavaScript the previous state round-trips through a hidden field, so prevState is client input too."

What’s happening
  1. While pending, state is still the previous result: old errors stay on screen until the new response (chapter 7 sampled it at 300 ms).
  2. Return { errors: { email: [...] } } for expected failures; React serialises it exactly.
  3. Throw for unexpected failures; never return String(err), because returned data isn't redacted.
  4. useFormStatus must be called in a component inside the form, such as a SubmitButton.

Learn more: Pending, errors and validation and Errors in Server Actions and Route Handlers.

How do you do optimistic updates with Server Actions, and why does the optimistic item sometimes vanish?
Show answer

useOptimistic overlays pending changes on the server-provided value; the item vanishes when the action doesn't revalidate, because the real data never changes. "In the form action I call the optimistic setter first, then await the Server Action. The action writes, then calls refresh() or updateTag(), so its response includes the re-rendered page with the real data. When the transition ends React drops the overlay and shows the new props in the same commit; if the write failed, the real data never changed and the overlay simply goes away, so there's no manual rollback. Without the revalidation, the overlay ends and the old list comes back, though the write succeeded."

What’s happening
  1. Click: optimistic item appears at once (chapter 7: 3 ms).
  2. Action: write, then refresh()/updateTag().
  3. Response: new props arrive with the action result; overlay replaced by real data.
  4. Without revalidation: chapter 7 saw the item disappear at about 1 s and come back only after a reload.

Learn more: Optimistic updates with useOptimistic.

An action runs await insertPost(title); redirect("/"); updateTag("posts");. Does the user see their post on /?
Show answer

No, and nobody else does either until the cached list is revalidated some other way: redirect() throws, so updateTag never runs. "redirect(), notFound(), forbidden() and unauthorized() work by throwing a special error that Next.js catches higher up. Anything after them in the action is dead code. The order is: write, invalidate, then redirect, and the redirect must not be inside a try/catch that swallows it."

What’s happening
  1. The insert succeeds.
  2. redirect("/") throws NEXT_REDIRECT; the action stops.
  3. / renders with the cached posts list, still without the new post (chapter 7 saw "1 posts" next to an uncached "Database has 2 posts").
  4. Swapping the two lines fixed it: "2 posts".

Learn more: Revalidating and redirecting after a mutation.

try { await save(); redirect("/thanks"); } catch (err) { return "Failed"; }. What happens?
Show answer

The user stays on the page and sees "Failed": the catch caught the redirect. "redirect() throws an error whose message is NEXT_REDIRECT; a broad catch treats it like any failure, so the action returns normally with no redirect instruction. Either call redirect() after the try/catch, once the work has succeeded, or call unstable_rethrow(err) as the first line of the catch, which rethrows Next.js's control-flow errors (redirect, notFound and friends) and lets real errors through to your handling."

What’s happening
  1. save() succeeds, redirect("/thanks") throws.
  2. Your catch runs; with useActionState the state becomes "Failed" (chapter 11 returned caught: NEXT_REDIRECT and stayed on /).
  3. unstable_rethrow(err) checks for the internal errors and rethrows them; the fixed version navigated to /thanks.
  4. The usual shape: try the mutation, return errors as data, redirect outside the try.

Learn more: Errors in Server Actions and Route Handlers.

How do you secure Server Actions? The admin page redirects non-admins; is its delete action safe?
Show answer

Treat each action as a public POST endpoint and check inside it; and no, a page-level redirect doesn't protect the actions it renders. "Anyone with the action ID, which is in the page's HTML, can call it with any arguments, from any path, without ever rendering the page. So inside every action, usually through the data access layer, I authenticate from the session cookie, authorise against the specific resource to avoid IDOR, validate all arguments with a schema because TypeScript types don't exist at runtime, and return only what the UI needs. Next.js adds an Origin/Host check against CSRF, a 1 MB body limit by default, encrypted closure variables and unguessable IDs, but none of that knows my users."

What’s happening
  1. Chapter 7: a signed-out user with an old copy of the admin HTML posted the action and "2 notes in total" became "0 notes in total".
  2. The page's redirect never ran, because an action request doesn't render that page.
  3. The Origin check only stops browsers on other sites acting for a victim; curl sends no Origin or the right one.
  4. Behind a proxy or with several zones, list the public origin in experimental.serverActions.allowedOrigins in next.config.ts.

Learn more: Securing Server Actions and Authorisation close to the data.

When would you use a Route Handler instead of a Server Action? And how long does Promise.all of three 500 ms actions take?
Show answer

Route Handlers for anything that needs a real HTTP API; Server Actions for mutations from your own React UI. And about 1.5 seconds: a client's actions are sent one at a time. "Actions give me progressive enhancement, pending and error state through useActionState, and the re-rendered page in the same response. Route Handlers are for webhooks with signature verification, mobile or partner clients, cacheable GETs, file and streaming responses, and client-side fetching with SWR or TanStack Query. I don't fetch data with actions: they're POSTs, uncached, unprefetchable and queued behind each other. To parallelise writes, I make one action that does them with Promise.all on the server."

What’s happening
  1. Promise.all calls the three stubs at once, but Next.js's client dispatcher sends the second POST only after the first response is handled.
  2. Chapter 7 measured 1,516–1,522 ms for three 500 ms actions, and about 507 ms for three fetch calls to a Route Handler.
  3. Webhooks need the raw body for signature checks, which is a Route Handler job (next subsection).
  4. Rule of thumb: own UI and mutation → action; anything else, or a read → handler or a Server Component.

Learn more: Server Actions vs Route Handlers.

Route Handlers, proxy and after()

What is a Route Handler, and how is it different from a Pages Router API Route?
Show answer

A route.ts file that exports functions named after HTTP methods, each taking a web Request and returning a Response; API Routes are one Node-style (req, res) handler in pages/api. "The folder path is the URL, dynamic segments arrive as params, a Promise since 15, and Next.js answers unsupported methods with 405 and handles HEAD and OPTIONS. Because it's the Fetch API, I read the body myself with request.json() or request.text(). I use them for endpoints other clients call: mobile apps, webhooks, OAuth callbacks, downloads. For my own UI I call the data layer from a Server Component or use a Server Action."

What’s happening
  1. export async function GET(request: Request, { params }) per method; NextRequest adds cookies and nextUrl.
  2. Can't coexist with a page.tsx in the same folder: both would answer the same URL.
  3. A GET that reads nothing is prerendered with Cache Components (the rq-handlers question above).
  4. An API Route that forgets to respond hangs the request; a Route Handler must return a Response, so it can't make that mistake.

Learn more: Route Handlers and API Routes.

What is proxy.ts, why was middleware renamed, and what shouldn't you do in it?
Show answer

One function at the project root that runs before routing for every request its matcher selects; it was renamed in 16 because people treated it like Express middleware and put application logic in it. "It gets a NextRequest and can continue (NextResponse.next(), optionally setting request or response headers and cookies), rewrite to another route while keeping the URL, redirect, or respond itself. It's for cheap request-based decisions: optimistic auth redirects, A/B bucketing, locale detection, headers. It always runs on Node.js in 16; a middleware.ts still runs on Edge with a deprecation warning. I keep the matcher tight, never make it the only authorisation check, and use next.config redirects and headers when the rule doesn't depend on the request."

What’s happening
  1. Order: next.config headers, then redirects, then proxy, then rewrites and routes. A config redirect for /old-pricing answered 308 before proxy ever saw it (chapter 8's log had no proxy line).
  2. Matcher: "/((?!api|_next/static|_next/image|favicon.ico).*)" still matches public/ files like /next.svg and prefetch requests; it also skips /apiary, because the lookahead is a prefix match.
  3. Don't: database queries per request, heavy auth, the only permission check.
  4. Rename: npx @next/codemod@canary middleware-to-proxy ..

Learn more: proxy.ts (formerly middleware).

Redirect or rewrite, and where should each live?
Show answer

A redirect changes the URL (a 307/308 and a new request); a rewrite serves a different route for the same URL. Static rules go in next.config, request-dependent ones in proxy. "I redirect when the user should see the new URL: login, canonical locale, moved pages. I rewrite for A/B tests and multi-tenant hostnames. next.config redirects and rewrites need no code and run before proxy; anything that depends on a cookie, header or geo goes in proxy.ts. A nice property of proxy rewrites is that pages stay static: proxy picks which static variant to serve, so personalisation doesn't make the route dynamic. The caveats: a CDN may cache the rewritten response under the original URL, and variant URLs are public unless proxy blocks them."

What’s happening
  1. Status codes: 307 temporary, 308 permanent (they keep the method; 301/302 historically didn't).
  2. Passing data from proxy to the app: set request headers with NextResponse.next({ request: { headers } }), overwriting anything the client sent.
  3. Unknown values: validate the locale or tenant in proxy and return a real 404 there, or the page's notFound() may stream a 200.
  4. A rewritten static page carries s-maxage and possibly set-cookie, with no Vary: Cookie; check what your CDN does with that.

Learn more: Redirects, rewrites and headers in proxy.

How do you verify a webhook signature in a Route Handler?
Show answer

Read the raw body with request.text(), recompute the provider's HMAC over those exact bytes, compare in constant time, check the timestamp, then parse. "Never parse first and re-stringify: JSON.stringify won't reproduce the provider's whitespace, key order or escapes, so correctly signed pretty-printed deliveries fail. Compare with timingSafeEqual after checking lengths, reject stale timestamps to stop replays, dedupe on the event id because delivery is at-least-once, and return 2xx quickly, moving slow work to after() or a queue so the provider doesn't time out and retry."

What’s happening
  1. const raw = await request.text(); the signature header carries t and v1.
  2. createHmac("sha256", secret).update(`${t}.${raw}`), then timingSafeEqual.
  3. Chapter 8: a compact body passed the parse-first handler, a pretty-printed one got 401, and the raw-text handler accepted both.
  4. After verifying: record the event id, respond, process later.

Learn more: Streaming responses and webhooks.

You call after(() => log("viewed")) in a static page. When does it run?
Show answer

During next build, not per request: after() callbacks run when a render finishes, and a static page only renders at build time. "after() from next/server schedules work for after the response, in Route Handlers, Server Actions, Server Components and proxy, even if the response was an error or a redirect, and it doesn't make the route dynamic. On serverless it's handed to waitUntil so the instance isn't frozen; on next start the server waits for pending callbacks on SIGTERM. For per-view logging I need something that runs per request: a dynamic component inside Suspense, a Route Handler, after() in proxy, or client analytics. It's best-effort; anything that must happen goes to a queue."

What’s happening
  1. Build: the page is ○; chapter 8's [after] line appeared twice in the build output.
  2. Requests: every response was a cache HIT, nothing rendered, no callback.
  3. Request APIs inside the callback: read cookies()/headers() during render and pass the values in.
  4. Compared with a floating promise: chapter 8's server stayed up 1.5 s to finish after() work, but exited in 55 ms and lost a void promise().

Learn more: after(): work after the response.

Authentication and security

Session cookie or JWT?
Show answer

They aren't opposites: a JWT is a token format, a cookie is a transport. The real choice is stateless versus database sessions. "A signed or encrypted cookie needs no lookup, which is fast and works in proxy, but can't be revoked before it expires. A database session costs a lookup but lets me log users out everywhere, list devices and ban instantly. Either way it goes in an HttpOnly, Secure, SameSite=Lax cookie, not localStorage; it's set in a Server Action or Route Handler; and in production I use a library (Auth.js, Better Auth, Clerk, iron-session) rather than hand-rolled crypto. Signed is not encrypted: the payload is readable, so it holds ids and roles, not personal data."

What’s happening
  1. Stateless: data in the cookie, verified by signature; revocation needs short lifetimes or a deny-list.
  2. Database: random id in the cookie, data on the server; every request looks it up (memoize the lookup with cache()).
  3. Cookie flags: HttpOnly (no JS access), Secure, SameSite=Lax (most CSRF), a sensible Max-Age.
  4. Where to read it: the data access layer; proxy only for optimistic redirects.

Learn more: Authentication patterns in the App Router.

Where do auth checks belong? An admin layout returns "Admins only." for non-admins; is the page's data hidden?
Show answer

In the data access layer, as close to the data as possible; and no, the page's output still reaches the browser in the payload. "Every DAL function verifies the session, checks that this user may touch this resource, and returns a DTO. Pages, actions and Route Handlers all go through it, because each is its own entry point. Proxy does an optimistic cookie check to redirect early. Layouts are the wrong place: the router renders the page segment itself and hands it to the layout as children, so a layout that doesn't render children hides it visually but the payload still contains it, and layouts don't re-render on navigation between their pages."

What’s happening
  1. Chapter 9's layout-gate app: the visible HTML said "Admins only.", and the confidential figure appeared twice inside self.__next_f.push scripts.
  2. The page was static, so it was rendered at build time regardless of who asks.
  3. Fix: check in the page or, better, in the DAL function it calls (if (!isAdmin) forbidden() before reading).
  4. Hidden inputs and .bind arguments are user input; re-check ownership in the action.

Learn more: Authorisation close to the data.

401 vs 403: how does Next.js do them, and what status does the browser get?
Show answer

401 is "not authenticated", 403 is "authenticated but not allowed"; unauthorized() and forbidden() render unauthorized.tsx and forbidden.tsx, but inside a streamed page the status is already 200. "They arrived in 15.1 behind the experimental authInterrupts flag, and they're still marked experimental in 16.4's docs. Like notFound() they throw, render the nearest file and add noindex. With Cache Components a page-level check gives the right UI but not the right status, because the shell went out first. For real statuses I decide before rendering, in proxy, or use a Route Handler for JSON; and sometimes I return 404 instead of 403 so I don't reveal that a resource exists."

What’s happening
  1. Before streaming (proxy, Route Handler, a blocking route): real 401/403.
  2. After the shell: 200 plus the right UI and noindex.
  3. Monitoring and API clients look at status codes, so they'd count these as successes.
  4. 404 for "exists but not yours" is a common privacy choice.

Learn more: forbidden(), unauthorized() and error statuses.

CI builds one Docker image with NEXT_PUBLIC_API_URL=https://api.staging.example; production runs it with -e NEXT_PUBLIC_API_URL=https://api.example. Which API does the browser call?
Show answer

Staging: NEXT_PUBLIC_ values are pasted into the client bundle at build time, and the container's environment only affects server code that reads process.env at runtime. "On the server, process.env is read when the code runs, which is build time for static or cached output and request time after connection(), cookies() or headers(). For a promotable image I keep the URL server-side: a Server Component reads process.env.API_URL per request and passes it down, or the browser calls a same-origin Route Handler that proxies to the right API. .env.local and the real environment override .env, and nothing without the prefix is inlined."

What’s happening
  1. Build: process.env.NEXT_PUBLIC_API_URL in client code → the literal "https://api.staging.example".
  2. Run: -e sets the Node process's variable; the bundle already contains the string.
  3. Fixes: one image per environment (not promotable), or server-side config passed down.
  4. next.config is evaluated at build and again at start; feed it the same environment both times.

Learn more: Environment variables and secrets and Build and runtime configuration.

How would you add a strict Content Security Policy with nonces, and what does it cost?
Show answer

Generate a nonce per request in proxy and send it in the Content-Security-Policy header on both the forwarded request and the response; Next.js stamps it on its own scripts. The cost: those pages must render per request. "The policy is script-src 'self' 'nonce-…' 'strict-dynamic', and I read the nonce from headers() for third-party <Script> tags. Because every response needs a fresh nonce, nonce pages lose static generation, ISR and Partial Prerendering. If that's too expensive I use a static policy from next.config headers, or the experimental SRI hashes. And I test interactivity after adding it: the failure mode is a page that renders but runs no JavaScript."

What’s happening
  1. Proxy: crypto.randomUUID() → base64 nonce, header on request and response.
  2. Render: Next.js reads the header and adds nonce to framework scripts.
  3. Static HTML can't carry a per-request nonce, hence dynamic rendering.
  4. Check the console for CSP violations and click something; screenshots look fine either way.

Learn more: Content Security Policy with nonces.

Tell me about the Next.js middleware vulnerability.
Show answer

CVE-2025-29927, March 2025, CVSS 9.1: sending an internal header, x-middleware-subrequest, with the right value made Next.js skip middleware entirely, along with any auth check in it. "Next.js used that header to mark requests middleware had already handled, to avoid recursion, and trusted it from clients. It affected self-hosted apps from 11.1.4 through 15.2.2 and was fixed in 12.3.5, 13.5.9, 14.2.25 and 15.2.3; the workaround was stripping the header at the edge. The lesson is the one the docs now spell out: middleware, now proxy, is for optimistic checks; real authorisation lives in the data layer and every action, so a bypass degrades the user experience instead of exposing data."

What’s happening
  1. Mechanism: a recursion guard trusted from outside.
  2. Impact: any app whose only protection was middleware.
  3. Vercel-hosted apps weren't affected because of how its routing works; that changes which CVEs apply, not whether you need DAL checks.
  4. Related checklist items: action exposure, broad remotePatterns (image optimiser abuse), and patching promptly: "React2Shell" (December 2025, CVSS 10.0) let a crafted request to the RSC protocol run code on the server.

Learn more: Security checklist for a Next.js app.

Errors and observability

A page streams from a ◐ shell and the dynamic part throws. What status code does the browser get, and what does the user see?
Show answer

200. The user sees the shell's fallback first, then the nearest error.tsx once React handles the errored boundary. "The shell was sent with the status line before anything failed, so the server can only stream the error marker for that boundary. In production the client receives a digest, not the message; the full error is in the server log. That's why I alert on logged errors (onRequestError), not just 5xx rates, and why tests assert on content. error.tsx is a Client Component boundary around the segment's page, loading UI and nested layouts, not its own layout; global-error.tsx replaces the root layout. retry() (stable in 16.3) refreshes the route from the server and then re-renders the boundary; reset() only re-renders on the client, so it can't fix a server error."

What’s happening
  1. Headers: x-nextjs-prerender: 1, x-nextjs-postponed: 1, 200 (chapter 11).
  2. Stream: an error marker with the digest and a script that marks the Suspense boundary as errored.
  3. Client: the boundary rethrows on hydration and the nearest error.tsx renders.
  4. Layout errors go to the parent segment's boundary; global-error.tsx needs its own <html> and <body>.

Learn more: Error handling with error.js.

How would you add monitoring to a Next.js app, and what doesn't reach onRequestError?
Show answer

instrumentation.ts on the server (register() for OpenTelemetry, onRequestError for errors), instrumentation-client.ts in the browser; browser errors never reach the server hook, and in 16.4.3 a throw in proxy.ts didn't either. "register() starts the OpenTelemetry SDK, usually @vercel/otel with an OTLP exporter, so I get Next.js's spans for requests, renders and fetches plus my own. onRequestError reports every server render, Route Handler and Server Action error with its route context and digest; I await the report. In the browser, instrumentation-client.ts sets up the client tracker before hydration. The digest ties a user's error screen to the server log. I check coverage by failing each kind of code path once."

What’s happening
  1. Chapter 11's five failures: render, Route Handler and action errors called onRequestError; a click handler didn't (browser); a proxy throw returned 500 and logged a stack but didn't call it.
  2. File location: project root or src/, not inside app/.
  3. register() runs in every runtime; guard Node-only SDKs with process.env.NEXT_RUNTIME === "nodejs".
  4. Proxy code should catch and report its own errors.

Learn more: Instrumentation and OpenTelemetry.

Testing and deployment

What can Vitest with React Testing Library render, and what needs end-to-end tests?
Show answer

Client Components and synchronous Server Components, yes; async Server Components and anything that reads cookies() or headers(), no. "react-dom/client can't render an async component, and request APIs throw outside a request scope, so pages that fetch data, auth flows, Server Actions with revalidation, and anything about caching or streaming are covered by Playwright against next build && next start. Unit tests cover pure logic and data mapping; Route Handlers and actions can be called as functions for their validation logic with next/cache mocked; next/navigation gets mocked for Client Components. And I don't test against next dev, which compiles on demand and caches differently."

What’s happening
  1. Client Component with useRouter: mock next/navigation.
  2. export default function About() returning JSX: renders as is.
  3. async function Page(): "is an async Client Component" in jsdom, and render(await Page()) fails at the next async child.
  4. headers() in a test: throws "called outside a request scope".

Learn more: Testing a Next.js app.

Vercel or self-hosting? What does self-hosting have to provide?
Show answer

Functionally the same: a single next start supports every feature. The difference is who operates the CDN, the shared cache, revalidation across instances, image optimisation, skew protection and scaling. "Self-hosted, I'd run the standalone build in containers behind a load balancer that streams, add shared cache handlers with tag coordination (Redis), pin the Server Actions encryption key and a deployment ID across instances, and put a CDN in front that respects s-maxage, stale-while-revalidate and Vary, purged after revalidation. I'd self-host for cost at scale, data residency or an existing Kubernetes platform; otherwise the managed option saves real work."

What’s happening
  1. One instance: everything works, nothing is shared because there's only one process.
  2. Several instances: caches, ISR pages and tag invalidations are per process unless shared.
  3. Encryption key and deployment ID: identical across instances so actions and client navigations work after a deploy.
  4. A static export is not self-hosting a server: no actions, no ISR, no proxy.

Learn more: Deploying: Vercel vs self-hosting.

You deploy .next/standalone and the home page is unstyled with dead buttons, and the server logs nothing. Why?
Show answer

.next/static and public/ weren't copied into the standalone folder, so every CSS and JS chunk is a 404. "Standalone output traces only the server's files and writes server.js; static assets are deliberately left out so you can serve them from a CDN. The HTML comes from server.js fine, but /_next/static/chunks/… requests fail, so there's no CSS and React never hydrates. A 404 for a missing file isn't an error, so nothing is logged. Copy .next/static to .next/standalone/.next/static and public to .next/standalone/public, or serve them with assetPrefix. In Docker also set HOSTNAME=0.0.0.0, because Docker sets HOSTNAME to the container name and server.js binds to it."

What’s happening
  1. Network tab: the document 200, the chunks 404.
  2. No CSS → unstyled; no JS → no hydration → onClick does nothing.
  3. Multi-stage Dockerfile: deps, build, slim runtime copying standalone, static and public; non-root user.
  4. Chapter 12's image: 400 MB versus 1.25 GB for the copy-everything version.

Learn more: Self-hosting with output: standalone and Docker.

Three replicas, and users see different prices for a minute after an update. Why?
Show answer

Next.js's caches are per process by default: 'use cache' entries in memory, ISR pages on each pod's disk, tag invalidations in memory. revalidateTag on the pod that received the webhook clears only that pod. "The fix is shared cache handlers: cacheHandlers for 'use cache' and cacheHandler for ISR and route responses, backed by Redis, with tag invalidation times stored centrally so every pod checks them before serving. If there's a CDN in front, the same webhook purges it, because Next.js can't."

What’s happening
  1. Pod A gets revalidateTag; B and C keep their copies until their own timers expire.
  2. Visitors are load-balanced across pods, so they see different versions.
  3. Shared handler: one store for entries, plus a "noticeboard" of tag expiry times read on each request (refreshTags).
  4. Chapter 12 ran two instances on one shared folder: after a revalidation on A, B recomputed and A then served B's entry.

Learn more: Caching when self-hosting.

How would you upgrade a Next.js 14 app to 16?
Show answer

One major at a time, each on its own branch with the tests green, comparing the route tables before and after. "14 to 15 first: npx @next/codemod@canary upgrade bumps Next.js and React 19 and runs the codemods, especially the async request APIs one; then I review caching, because fetch and GET handlers stopped being cached by default, so I diff next build output for routes that changed. Then 15 to 16: Node.js 20.9, Turbopack by default (migrate or drop custom webpack config), rename middleware to proxy unless it needs Edge, replace next lint, move runtime config to environment variables, add default.tsx to parallel slots, and give revalidateTag its second argument. I'd turn on Cache Components as a separate project, using instant = false per route to adopt it gradually."

What’s happening
  1. Codemods make it compile; they don't make it right. An await added at the top of a layout can block the whole subtree.
  2. Diff the route tables (○ vs ƒ) and run E2E against the production build.
  3. Check response headers for pages a CDN should cache.
  4. Cache Components changes the model (no revalidate/dynamic exports), so it deserves its own migration.

Learn more: Multi-zones, monorepos and upgrading.

#Pages Router and migration questions

Added

Plenty of production Next.js is still Pages Router code, and interviewers who learned Next.js before 2023 will ask about it in its own terms. The mental model is simpler than the App Router's: one page, one data function, one JSON blob of props. Every file in pages/ is a route whose component is a normal React component, rendered on the server and then hydrated in full. Its data comes from at most one page-level function: getStaticProps (build time, optionally regenerated with revalidate), getServerSideProps (every request), or none (prerendered automatically). The props are serialised into __NEXT_DATA__ for hydration, and client navigations fetch the same props as JSON from /_next/data/<buildId>/….

Migration questions are about mapping that model onto the App Router's: data moves from page functions into components, _app and _document become the root layout, revalidate becomes cacheLife (or the previous model's export const revalidate), and next/router becomes next/navigation. Both routers run in one app, so migrations are incremental; the traps are the defaults that changed between versions and status codes inside streamed pages.

How does the Pages Router work, in a minute?
Show answer

Every file in pages/ is a route exporting a React component; all components are server-rendered and then hydrated; data comes from one page-level function. "getStaticProps runs at build time, optionally with revalidate for ISR, plus getStaticPaths for dynamic routes; getServerSideProps runs on every request. The props are serialised into __NEXT_DATA__ for hydration, and on client navigations Next.js fetches the same props as JSON from /_next/data/<buildId>/…. Pages without a data function are prerendered automatically. _app wraps every page and persists across navigations, _document is the server-only HTML shell, and pages/api holds API endpoints. Compared with the App Router: no nested layouts, no Server Components, data only at the page level, and the whole page's JavaScript ships."

What’s happening
  1. Routing: file per route, [id].tsx and [...slug].tsx for dynamic segments, next/link and next/router for navigation.
  2. Rendering: the full component tree hydrates; there is no server-only component.
  3. Data: one function per page, props as JSON, no data fetching in nested components except on the client.
  4. Shell: _app for providers, global CSS and persistent layout; _document for <html>/<body> attributes and tags shared by every page.

Learn more: How the Pages Router works.

Predict the route table for these Pages Router pages

/ has no data function, /blog uses getStaticProps with revalidate: 60, /blog/[slug] lists one path with fallback: "blocking", /orders uses getServerSideProps, and there's one API Route.

pages/index.tsx
export default function Home() {
  return <h1>Home</h1>;
}
pages/blog/index.tsx
export async function getStaticProps() {
  return { props: { builtAt: new Date().toISOString() }, revalidate: 60 };
}

export default function Blog({ builtAt }: { builtAt: string }) {
  return <h1>Blog, generated {builtAt}</h1>;
}
pages/blog/[slug].tsx
import type { GetStaticPaths, GetStaticProps } from "next";

export const getStaticPaths: GetStaticPaths = async () => ({
  paths: [{ params: { slug: "hello" } }],
  fallback: "blocking",
});

export const getStaticProps: GetStaticProps<{ slug: string }> = async ({ params }) => {
  const slug = String(params?.slug);
  if (slug === "deleted") return { notFound: true };
  return { props: { slug } };
};

export default function Post({ slug }: { slug: string }) {
  return <h1>Post: {slug}</h1>;
}
pages/orders.tsx
import type { GetServerSideProps } from "next";

export const getServerSideProps: GetServerSideProps<{ user: string }> = async ({ req }) => {
  return { props: { user: req.cookies.user ?? "guest" } };
};

export default function Orders({ user }: { user: string }) {
  return <h1>Orders for {user}</h1>;
}
pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from "next";

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  res.status(200).json({ method: req.method });
}
Show answer

/ ○, /blog ● with 1m / 1y, /blog/hello ●, /orders ƒ, /api/hello ƒ. "A page with no data function gets Automatic Static Optimization: ○. getStaticProps pages are generated at build time, ●, and revalidate: 60 shows in the Revalidate column. getStaticPaths lists which dynamic paths to build. getServerSideProps and API Routes run per request: ƒ. The Route (app) part at the top is the template's own not-found page, because this app also has an app/ folder, as a real migration would."

next build
Route (app)
─ ○ /_not-found

Route (pages)      Revalidate  Expire
┌ ○ /
├ ƒ /api/hello
├ ● /blog                  1m      1y
├   /blog/[slug]
│ └ ● /blog/hello
└ ƒ /orders

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand
What’s happening
  1. ○ /: no blocking data requirement, so it's rendered once at build time. Adding getInitialProps to _app would turn it, and every page without getStaticProps, into ƒ (chapter 13's quiz).
  2. ● /blog 1m 1y: generated at build; after 60 s the next request gets the old page and triggers a regeneration. The Expire column printed 1y, since nothing set an expiry.
  3. ● /blog/hello: the one listed path. Other slugs aren't in the table; with fallback: "blocking" they're rendered on first request (next question).
  4. ƒ /orders and ƒ /api/hello: per request. The legend's wording "(uses generateStaticParams)" is the same text the App Router uses; here it means getStaticProps/getStaticPaths.

Learn more: How the Pages Router works and getStaticProps and getStaticPaths.

In that app, what do /blog/new-post (twice), /blog/deleted and a client navigation to /orders return?
Show answer

/blog/new-post: rendered on the first request (MISS), stored and served as a HIT after that. /blog/deleted: a real 404. The navigation to /orders: a JSON request to /_next/data/<buildId>/orders.json that runs getServerSideProps and returns only the props. "With fallback: 'blocking' an unknown path is server-rendered on its first request, like getServerSideProps, then cached like a built page; nothing streams, so notFound: true can still send a real 404. On client navigations the Pages Router never fetches HTML: it already has the page's JavaScript (or loads its chunk) and asks the server for props as JSON."

curl on next start -p 4514 (status, cache headers, the <h1>)
/blog/new-post   200  x-nextjs-cache: MISS  Cache-Control: s-maxage=31536000   <h1>Post: new-post</h1>
/blog/new-post   200  x-nextjs-cache: HIT   Cache-Control: s-maxage=31536000   <h1>Post: new-post</h1>
/blog/deleted    404 Not Found
/orders          200  Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate   <h1>Orders for rohit</h1>

$ curl -s -b user=rohit localhost:4514/_next/data/$(cat .next/BUILD_ID)/orders.json
{"pageProps":{"user":"rohit"},"__N_SSP":true}
What’s happening
  1. First new-post: not in getStaticPaths, so getStaticProps runs during the request and the browser waits for complete HTML (MISS).
  2. Second: served from the stored page (HIT). Without revalidate it stays until the next deploy or a res.revalidate("/blog/new-post").
  3. deleted: getStaticProps returned { notFound: true } before anything was sent, so the status is a real 404. With fallback: true the first response would have been the 200 loading shell instead, and only the browser's JSON request would discover the 404.
  4. /orders as a document: per request, no-store, and the same props inside <script id="__NEXT_DATA__">.
  5. /orders via <Link>: the router requests the data URL (the build id is part of the path), getServerSideProps runs again, and _app stays mounted while the page re-renders with the new props.

Learn more: getServerSideProps and getStaticProps and getStaticPaths.

getServerSideProps vs getStaticProps: when do you use each?
Show answer

getServerSideProps runs on every request and has the request, for personalised or real-time pages; getStaticProps runs at build time or in the background with revalidate, without the request, for public content a CDN can serve. "With getServerSideProps every request waits for it and nothing is cached by default. Both strip their code from the client bundle, both send their props to the browser as JSON, and on client navigations both are served through /_next/data: per request for one, from the cache for the other. A page can't export both; the build stops with an error. In the App Router the same choice is made per component: uncached data and cookies() render per request, 'use cache' makes it static."

What’s happening
  1. Request access: context.req, res, query and cookies only in getServerSideProps.
  2. Caching: getStaticProps output is HTML plus JSON files; revalidate makes it ISR.
  3. Props must be JSON: a Date errors in next dev and, under next start, silently arrives as a string, causing a hydration mismatch (chapter 13).
  4. getInitialProps: the legacy API that runs on both server and client; on _app it disables static optimisation for every page without getStaticProps.

Learn more: getServerSideProps.

Explain the three fallback modes of getStaticPaths.
Show answer

false: unlisted paths are 404s. 'blocking': an unlisted path is server-rendered on its first request and cached. true: the first request gets a loading shell immediately, and the browser fills it in from JSON. "true means the component must handle router.isFallback, and the shell is a 200 even for ids that don't exist, so a curl-based health check never sees the 404 that a browser ends up showing. Crawlers always get blocking behaviour. In the App Router the equivalents are generateStaticParams plus, in the previous model, dynamicParams, and with Cache Components and Partial Prefetching (the 16.4 defaults) the first visit to an unlisted param gets the route's App Shell with the content streamed. That's closest to true, including its weakness: an unknown id is a 200 with noindex on that first response (bots get a blocking render; chapter 10 saw 404 for Twitterbot)."

What’s happening
  1. false: the build output is the complete set of pages; good for small, known sets.
  2. 'blocking': SSR-like first request, then static; real status codes (the deleted 404 above).
  3. true: instant shell, then /_next/data/…json; chapter 13's curl kept getting the shell for a deleted product while a browser showed the 404.
  4. Choosing: 'blocking' unless an instant skeleton matters more than correct statuses for non-JS clients.

Learn more: getStaticProps and getStaticPaths.

A Pages Router page with no data function renders Hello {router.query.name ?? "stranger"}. What does /hello?name=Rohit show?
Show answer

The HTML says "Hello stranger"; the user sees "Hello stranger" briefly, then "Hello Rohit". "With no data function the page was prerendered once at build time, when there was no query string, so router.query was empty and every visitor gets that same file. The first client render must match it, so it also uses the empty query; after hydration the router reads the real URL, sets query and isReady, and the page re-renders. To have the name in the HTML the page needs getServerSideProps; otherwise show a neutral placeholder until router.isReady."

What’s happening
  1. Build: Automatic Static Optimization, router.query is {}.
  2. Hydration: same empty query, no mismatch.
  3. After hydration: query filled in, isReady true, re-render.
  4. Client fetching in the Pages Router has the same shape: nothing starts until hydration, so data for the first paint belongs in a data function.

Learn more: Data fetching on the client in the Pages Router.

API Routes vs Route Handlers?
Show answer

Both are server-only endpoints defined by files: API Routes are one Node-style (req, res) handler in pages/api with Express-like helpers and body parsing; Route Handlers are route.ts files in app/ with one Web Request/Response function per method. "With an API Route I branch on req.method and must end every path with res.json, res.send, res.end or res.redirect, or the request hangs (only next dev warns). Route Handlers return a Response, get a 405 for missing methods, and can be prerendered. API Routes keep working in 16, so a migration can move them last. And much of what they were used for, feeding your own pages and handling your own forms, is done by Server Components and Server Actions in the App Router."

What’s happening
  1. Body: parsed for you in API Routes (req.body); await request.json() in a Route Handler.
  2. Methods: one function and req.method vs GET/POST exports.
  3. Responses: mutate res vs return a Response; res.revalidate(path) is the Pages Router's on-demand ISR.
  4. Don't call your own API Routes from getStaticProps: no server is running during the build; import the shared function instead.

Learn more: API Routes and Route Handlers.

Map each Pages Router concept to its App Router equivalent.

getServerSideProps, getStaticProps with revalidate, getStaticPaths, _app, _document, next/head, next/router, pages/api, res.revalidate().

Show answer

Data functions become data reads inside Server Components; _app and _document become the root layout; next/head becomes the Metadata API; next/router becomes next/navigation; API Routes become Route Handlers (or Server Actions for your own forms). "In more detail: getServerSideProps becomes an async Server Component that reads request data (inside <Suspense> with Cache Components); getStaticProps with revalidate becomes 'use cache' with cacheLife, or export const revalidate in the previous model; getStaticPaths becomes generateStaticParams; res.revalidate() becomes revalidateTag or revalidatePath."

Pages RouterApp Router
getServerSidePropsAn async Server Component reading cookies(), headers(), searchParams or uncached data
getStaticProps + revalidate'use cache' + cacheLife (previous model: export const revalidate)
getStaticPaths + fallbackgenerateStaticParams (previous model: plus dynamicParams)
_appRoot app/layout.tsx, with providers in a 'use client' component
_documentThe root layout's <html> and <body>
next/headmetadata / generateMetadata
next/router (query, asPath, events)next/navigation (useRouter, usePathname, useSearchParams, useParams)
pages/api/*app/**/route.ts, or Server Actions for mutations from your own UI
res.revalidate(path)revalidateTag(tag, 'max'), updateTag(tag) in actions, revalidatePath(path)
What’s happening
  1. Data moves down: from one page function to the components that render it, with memoization instead of prop drilling.
  2. Shell: _app and _document merge into the root layout; global CSS is imported there.
  3. Navigation hooks change package and shape: next/navigation's useRouter has no query, asPath, isReady or events.
  4. Revalidation: by tag, closer to the data, instead of by path.

Learn more: Migrating from Pages to App Router.

How would you migrate a large Pages Router app to the App Router?
Show answer

Incrementally, by section, because both routers run in one app. "First upgrade Next.js and React on the Pages Router and get everything green. Then add a root layout in app/ with the global CSS, fonts and providers from _app and _document, and move one simple route to prove the pipeline. Then migrate by section, not by page, because navigating between the two routers is a full page load. Per page, I start by wrapping the existing component as a Client Component rendered by a server page.tsx that does what the data function did, so behaviour is unchanged; then move data into Server Components, 'use cache' instead of revalidate, generateStaticParams instead of getStaticPaths, metadata instead of next/head. API Routes can stay until the end."

What’s happening
  1. A URL can exist in only one router: chapter 13 added app/legacy/page.tsx next to pages/legacy.tsx and the build failed with "App Router and Pages Router both match path: /legacy".
  2. Cross-router navigation is a hard navigation: section-by-section keeps most clicks soft.
  3. Wrap-first keeps each step small and reviewable.
  4. Watch status codes: notFound() and redirect() inside streamed content can't set the HTTP status, so redirects that matter go to proxy.ts or next.config.
  5. Watch defaults: code and blog posts from 13–14 assume cached fetch; check the caching docs for your version (chapter 13 found the bundled migration guide still describing the 13–14 default).

Learn more: Migrating from Pages to App Router.

Name the mistakes that show up in real Pages-to-App migrations.
Show answer

useRouter from the wrong package, hooks in Server Components, providers left in _app, old caching assumptions, status codes in streamed pages, module state assumed shared, and generateStaticParams returning []. "next/router's useRouter doesn't work in app/, and next/navigation's has no query, so a search-and-replace compiles and breaks at runtime. Every Pages component could use state; in app/ it needs 'use client'. Providers move into a client component in the root layout. 13–14 code assumes fetch is cached; from 15 it isn't. A notFound() below a Suspense boundary is a 200. In-memory stores aren't shared between the two routers' bundles. And with Cache Components, generateStaticParams must return at least one param."

What’s happening
  1. Router hooks: replace router.query with useParams()/useSearchParams(), router.events with effects on usePathname().
  2. Directives: add 'use client' to the interactive leaves, not to every migrated page.
  3. Caching: diff the route tables before and after; a page that was ● may become ƒ or ◐.
  4. State: module variables are per bundle and per process; use a real store.
  5. Server Actions can't be used from Pages Router pages; forms there keep posting to API Routes.

Learn more: Migrating from Pages to App Router and Forms with Server Actions.

Written fresh for interviews (there was no Google Doc for this one). Examples target Next.js 16.4.3 and React 19.3 and were built with next build by scripts/check-next.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.