#Routing and rendering questions
AddedThis 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.)
| Behaviour | 13–14 | 15, and 16 without CC | 16 with CC |
|---|---|---|---|
fetch() with no options | Cached (force-cache), with exceptions after cookies()/headers() | Not cached; but on a static route it still runs only at build time | Not cached; must sit inside <Suspense> or a 'use cache' scope, or the build fails |
params, searchParams, cookies(), headers() | Synchronous | Promises (15 still allowed sync access with a warning; 16 removed it) | Promises; reading them is request-time data |
A GET Route Handler that reads nothing | Static by default | Dynamic by default (dynamic = 'force-static' to opt in) | Prerendered like a page |
| Page segments in the client router cache | Reused for 30 s (dynamic) or 5 min (static) | Dynamic pages not reused (staleTimes.dynamic 0); static still 5 min | Reused for the stale time from cacheLife (at least 30 s); visited routes kept hidden with <Activity> |
| Request interception file | middleware.ts, Edge runtime | middleware.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 build | webpack (Turbopack dev beta) | Turbopack dev stable, webpack build | Turbopack for both (--webpack to opt out) |
| Partial Prerendering | 14: experimental preview | 15: experimental.ppr in canaries; 16 removed the flag, so none without CC | The 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
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."
- 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.
- 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).
- Correct the common misconception that it means server rendering on every request: a
○route in the build output renders once, duringnext build, and is served as a file. - 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.
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."
app/files:page.tsx,layout.tsx,loading.tsx,route.ts,'use client'directives,asynccomponents. That's the App Router (stable since 13.4).pages/files:_app.tsx,_document.tsx,pages/api/*,getStaticProps/getServerSideProps. That's the Pages Router, still supported in 16.- 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.
- Then the version and config:
next15+ means async request APIs;middleware.tsvsproxy.tstells you pre- or post-16 naming;cacheComponents: truetells you which caching chapter applies.
Learn more: App Router vs Pages Router and Project structure and special files.
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."
- 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.
- 15's changes are the ones that silently change behaviour in old code: a
fetchthat was cached in 14 runs again in 15, unless the route is static (next question). - 16's are the ones that break builds or behaviour: sync
paramsaccess is gone (that one still builds, but readsundefined),revalidateTagneeds a second argument, parallel slots needdefault.tsx,next lintis gone. - 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.
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:
import type { NextConfig } from "next";
// The previous model: no Cache Components (and so no partialPrefetching, which requires it).
const nextConfig: NextConfig = {};
export default nextConfig;// 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);
}import { getStock } from "@/lib/db";
export default async function Stock() {
return <h1>{await getStock()} left</h1>;
}import { getStock } from "@/lib/db";
export const revalidate = 60;
export default async function Fresh() {
return <h1>{await getStock()} left</h1>;
}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."
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/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/stockuses no request API, so the previous model calls it static (○).getStockran during the build and the HTML with75was stored; both requests were cacheHITs with the same number, and it stays75until the next deploy.export const revalidate = 60keeps the route static but gives it a lifetime: the table shows1mand the responses-maxage=60, stale-while-revalidate=…. After a minute, the next visitor still gets the old copy and triggers a regeneration (ISR).await cookies()makes the whole route dynamic (ƒ):no-store, and a new number on each request.- Versions: 14 behaves the same for this non-
fetchquery; with afetchinstead, 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". - 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'pluscacheLife('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.
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."
- In 13–14,
paramswas a plain object:params.slugwas the string. - In 15,
paramsis a Promise that also exposes the properties for compatibility, with a dev warning. - In 16, it's only a Promise. Chapter 1 built exactly this page:
next buildpassed and production rendered<h1>Post: </h1>. - The fix is
const { slug } = await params(oruse(params)in a Client Component), andPageProps<"/blog/[slug]">, which knowsparamsis a Promise of{ slug: string }.
Learn more: Next.js versions that changed the rules and Dynamic routes.
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."
- Server first:
next.configredirects and headers, thenproxy.ts, then route matching. - 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.
- 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. - Browser: paint, download the client chunks, hydrate. Server Component code is never downloaded.
- Navigation:
<Link>prefetches when it enters the viewport; a click uses the prefetched shell or sends one GET with anrsc: 1header, 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
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."
/shop/shirtsto/shop/hats: root matches,shopmatches,shirtsvshatsdiffers, soShopLayoutis the deepest shared layout.- Everything at and above
ShopLayoutis reused: no server render, no payload for those segments, no client re-render. A cart counter in it keeps its count. - Only the
hatspage segment is rendered (or taken from the prefetch cache if it's static). - 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.
/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."
- The counter's
useStatelives in a Client Component inside the root layout; after three clicks it holds3. <Link>intercepts the click, uses the prefetched payload (or fetches it) and keeps every shared layout mounted:3.<a>isn't intercepted: new document, new hydration,useState(0)runs again:0. Chapter 1 checked both in headless Chrome.- Use
<Link>orrouter.pushinside 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.
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."
- Placement: layout → error boundary →
loadingas Suspense → page. So the layout renders without waiting for the page. - First load: the response starts with
200, layout and fallback, and stays open until the page's HTML streams in. - Navigation: the fallback was prefetched, so the URL and UI change immediately while the page's data loads.
- Granularity: three independent widgets under one
loading.tsxall 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.
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.
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;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*"] };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>
);
}import { notFound } from "next/navigation";
export default function Retired() {
notFound();
}import { redirect } from "next/navigation";
export default function Moved() {
redirect("/blog/hello");
}export default function Account() {
return <h1>Your account</h1>;
}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."
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(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)- (a)
next.configredirects are checked before proxy and before routes.permanent: truegives 308, not 301: 307 and 308 keep the request method, so a redirectedPOSTstays aPOST. - (b) The proxy's matcher covers
/account; nosessioncookie, soNextResponse.redirectanswers 307 before the page is touched. With-b session=1the same URL returned200. (This is an optimistic check for a nicer redirect, not authorisation.) - (c) and (d): both pages are
○. TheirnotFound()andredirect()ran duringnext build, and Next.js stored the outcome: a 404 withnoindex, and a 307 withlocation, both served as cacheHITs. A static page can still return a real error or redirect status. - (e)
/blog/nopeisn't ingenerateStaticParams, so it's served from the◐shell:200and the "Blog" heading are flushed first, thenPostcallsnotFound(). The status can't change any more, so Next.js renders the not-found UI into the stream withnoindex. - (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 aMISS, still404). Every new unknown slug repeats (e) once. Monitoring that counts 404s by status code under-counts these; search engines are fine because ofnoindex.
Learn more: not-found, redirects and status codes and Redirects, rewrites and headers in proxy.
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."
app/dashboard/@teamand@analyticsbecometeamandanalyticsprops ofdashboard/layout.tsx. The@folders don't appear in the URL.- Click from
/dashboardto/dashboard/settingswhere only@teamhas asettingspage:teamswitches,childrenandanalyticskeep their old pages. - Refresh: the server renders
/dashboard/settingsfrom scratch, with no memory of what the other slots showed, so they renderdefault.tsx. - That difference between soft and hard navigation is the whole reason
default.tsxexists. 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 ordefault.tsxin every sibling slot for each URL one slot matches (chapter 2).
Learn more: Parallel routes.
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."
(.)intercepts a segment at the same level,(..)one level up,(...)from the root. They count route segments, so@modaland(group)folders don't count.- Soft navigation from the gallery: the client sends the current URL (
next-urlheader), the server matches the intercepting route, modal over gallery. - Refresh: no "previous page" for this request, no interception.
childrenrenders the photo page,@modalrenders its default (null). - 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.
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."
- Layout: one instance for the section, state kept across
/feedback/a→/feedback/b. - Template: new key per child segment, so
a→bmounts a new template and a new page. - Search params don't change the key:
?sort=new→?sort=oldkeeps both mounted. Key a component on the value if it must reset. - Chapter 2 measured the Cache Components difference: after
a → b → a, the counters read2 · 2 · 2with Cache Components and2 · 0 · 0without.
Learn more: Templates vs layouts.
<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."
- Viewport-triggered: links prefetch only after hydration and only in production builds.
- Default link to a static page: the whole page is in the client cache, so the click makes no request.
- Default link to a page that reads
params: the route's shared App Shell renders at once, the param-dependent part needs a request. 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.- 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 forprefetch={false}.
Learn more: Prefetching and instant navigation.
<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."
- The dashboard layout is shared and stays mounted; only the page segment is needed.
- 15+: the router has the prefetched shell (the
loading.tsxfallback) 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. - 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. - This is one of the 15 default changes interviewers ask about, with
fetchandGEThandlers no longer being cached.
Learn more: Loading UI and streaming with loading.js and Caching layers at a glance.
Rendering and the build output
next build prints for each routeCache 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.
export default function Home() {
return <h1>Coffee shop</h1>;
}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>;
}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>
);
}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>
);
}export function generateStaticParams() {
return [{ slug: "hello" }];
}
export default async function Post({ params }: PageProps<"/posts/[slug]">) {
const { slug } = await params;
return <h1>Post: {slug}</h1>;
}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: ƒ."
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/: nothing to wait for, so the whole page is HTML from the build./menu: the'use cache'result can be computed at build time and stored, so the page is still○; thehoursprofile shows as1h(revalidate) and1d(expire)./account: the heading is in the shell,Greetingreads a cookie, so it's a hole withLoading…as its prerendered fallback:◐./ticker:secondshas anexpireunder five minutes, so it isn't prerendered even though it's cached. It becomes a hole, also◐./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 (therq-legacytable above has● /posts/hello)./blocking: readingcookies()at the top without Suspense would normally fail the build.instant = falseallows the route to block, so it'sƒ.
Learn more: The development and build workflow, Partial Prerendering and Static and dynamic rendering.
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."
/ 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○routes are served from the stored prerender (HIT)./menu's headers translate itshoursprofile:s-maxage=3600(revalidate after an hour) andstale-while-revalidate=82800(up to a day in total, the expire time).◐routes send the stored shell, then render the holes for this request; anything personal may be in the response, henceno-store./blockingisƒin the table, yet its headers includex-nextjs-postponed: 1too. I didn't find this documented; it's what 16.4.3 sent.- Every response also carried
x-nextjs-prerender: 1and aVaryon 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.
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."
- Request-time inputs:
cookies(),headers(),searchParams, unknownparams,connection(), and uncached I/O.<Suspense>itself never makes anything dynamic; it only gives a hole a place to stream. - 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
ƒ. - Cache Components: the shell is everything outside the boundaries that wait; the build error forces you to choose cache or stream for each read.
- 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:
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."
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- Build:
generateStaticParamsreturnedhello, so/posts/hellois a file; every other slug uses the◐ /posts/[slug]shell. - First request for
other: the shell goes out andparamsresolves at request time, so the response is per request (no-store, postponed). - In the background Next.js stores the full page for
other, so the second and third requests areHITs with a year-longs-maxage. - The previous model (the
rq-legacyapp, Cache Components off) does the same with a blocking first render:/posts/othercame backx-nextjs-cache: MISS, thenHIT.dynamicParams = falsethere turns unlisted values into 404s.
Learn more: generateStaticParams and dynamicParams.
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."
- Previous model:
export const revalidate = 60(orrevalidate: 60fromgetStaticPropsin the Pages Router). Cache Components:cacheLifeon the'use cache'inside the route. - An hour of no traffic: nothing ran. The 61st-minute visitor gets the hour-old page (
x-nextjs-cache: STALE) and triggers the render. - The next visitor gets the regenerated page (
HIT). expireis the ceiling: chapter 5's quiz had a request two hours after the build withexpire: 3600, and it blocked for a fresh render (REVALIDATED) instead of serving the stale copy.- 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.
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."
- 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. - 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).
- Status:
200goes with the shell. Inside a boundary,notFound()becomes not-found UI plusnoindex,redirect()a client-side redirect, an error theerror.tsxUI. - 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.
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."
- Build: render, postpone at each request-time read, store shell plus postponed state.
- Request: send the shell immediately (
200), resume rendering only the postponed parts, stream them. - 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:…">incurloutput. - 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.
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."
- Default: Node.js for pages, Route Handlers, Server Actions and proxy.
- Edge: deprecated (
runtime = 'edge'is flagged in the docs), and not allowed with Cache Components. - Proxy: Node.js, can't be configured; the upgrade guide says to keep
middleware.tsif you need Edge. - 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).
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."
- Output: one HTML file per route plus
_next/static; the host needs rewrites for clean URLs. - 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. next/imageneeds a custom loader orunoptimized, because the optimiser is part of the server.- Good path for teams: start with an export, move to
next startor a platform when the first request-time need appears.
Learn more: Static exports and single-page apps.
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."
- Shared and slow-changing: cache the data, not the route, so one product change revalidates every page that shows it.
- Personal: never in a shared cache; stream it, or use
'use cache: private'for per-user content you want prefetchable. - 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. - Verify:
○/◐in the table where you expect them,HITands-maxageheaders on shared pages.
Learn more: Choosing a strategy per route.
Performance and SEO
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."
- 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 (750wfor a 375-pixel slot at 2×, as chapter 10 observed). - A missing
sizeson a responsive image gives a fixed1x/2xsrcset from the intrinsic width: a 2400-pixel file for a 300-pixel slot. - Security: a broad
remotePatterns(**or a shared bucket host) lets anyone use your server to fetch and resize their images. - 16 changes:
prioritybecamepreload, and the defaults forqualities,imageSizes,minimumCacheTTLand redirects changed (maximumRedirects3).
Learn more: next/image and Core Web Vitals in Next.js.
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."
- Build: the font loader call is evaluated at build time, so options must be literals and the result assigned to a
const. - Page: a
<link rel="preload">for the subset the page uses, plus the generated classes. - Swap: fallback text already has nearly the right metrics, so CLS from fonts is close to zero.
- Trap from chapter 10: the class on
<html>lost to the template'sbody { font-family: … }rule; the font downloaded but Chrome reported Arial.
Learn more: next/font.
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."
beforeInteractive: injected into the initial HTML, runs before hydration, only works on full document loads.afterInteractive: after hydration, the default.lazyOnload: during browser idle time.- 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.
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."
- Order: root layout, nested layouts, page; each contributes an object.
- Merge: per top-level key.
openGraphfrom the page replacesopenGraphfrom the layout, includingsiteName. titletemplates and other top-level keys still apply, which is why the bug is easy to miss.- Fix:
openGraph: { ...sharedOpenGraph, type: "article", title }. Chapter 10 checked the rendered<head>.
Learn more: Metadata and SEO.
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."
- Server first: a Markdown renderer or a date library used only in Server Components costs the browser nothing.
- Boundaries: one
'use client'high in the tree makes its whole import graph client code. - Splitting:
next/dynamicisReact.lazyplus a loading state; call it at module level. - 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.
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."
- Identify the element (DevTools Performance panel or the web-vitals attribution).
- Image path: lazy-loading the LCP image is the classic regression;
preloadadds the<link rel="preload">. - HTML path: check TTFB and the route table; an LCP element inside a Suspense boundary waits for its data.
- JS path: client-only rendering of the hero delays LCP until hydration.
- Field data beats Lighthouse: one simulated phone isn't your users.
Learn more: Core Web Vitals in Next.js and Streaming SSR in practice.