Master the job description
Study Guide
Every requirement a senior Next.js / React role commonly asks for — explained at senior depth. Read the concept, then the “how to say it” line so you can deliver a crisp, correct answer out loud.
01 · The App Router mental model
What they want: you understand that the App Router flips the default. Every component under app/ is a Server Component unless it opts into "use client". That's a deliberate inversion from the Pages Router, where everything shipped to the browser by default.
Why RSC exists: three wins. Less JavaScript ships — a Server Component's logic and its dependencies never reach the client bundle. Secrets stay server-side — you can read a database or an API key directly in a component body with no risk of it leaking into a script tag. And data gets fetched close to the source — no client-side waterfall of "mount, then fetch," no loading spinner tax for every leaf that needs data.
The wire format is the RSC Payload: not HTML, a compact serialized tree describing the rendered Server Component output, with placeholders for where Client Components go plus the serializable props they need. On first load, the server also renders that tree to HTML for fast paint. The sequence is: HTML arrives and paints immediately → the RSC payload reconciles the tree (React matches it up) → client JS hydrates only the Client Component islands, wiring up event handlers and state.
02 · File-system routing & project structure
Core: the app/ directory is the router — folders define URL segments, and specially-named files inside a folder define behavior for that segment. page.tsx makes a segment publicly reachable and renders the UI. layout.tsx wraps a segment and its children, preserves state across navigation (it doesn't remount), and nests automatically with parent layouts.
template.tsx looks like a layout but does remount on every navigation — useful for enter/exit animations or resetting state per-visit. loading.tsx auto-wraps the segment in a <Suspense> boundary and shows while the segment's data resolves. error.tsx is a Client Component error boundary scoped to that segment (must be a Client Component because error boundaries use component state). not-found.tsx renders when notFound() is thrown or a route can't be matched. default.tsx is the fallback UI for a parallel-route slot when Next can't recover the active state on a hard navigation.
Organize without changing the URL: route groups (marketing) wrap segments in parentheses so the folder name is stripped from the path — handy for giving a section its own layout without adding a URL segment. Private folders _components (leading underscore) opt a folder out of routing entirely, so you can colocate helpers, tests, and non-route files next to the routes that use them.
03 · Server Components vs Client Components
Default to Server Components for anything that fetches data, reads secrets, or doesn't need interactivity — which in most apps is most of the tree. Reach for a Client Component ("use client") only for the leaf that actually needs useState, useEffect, event handlers, or browser-only APIs.
The directive is a module-graph boundary, not a per-component flag. "use client" at the top of a file marks that file as the entry point into the client bundle — everything that file imports and directly renders also ships to the client, even if those modules have no directive of their own. It does not mean "only this component is a Client Component"; it means "this is where server and client split."
Interleaving is the trick that keeps Server Components server-rendered even inside a Client Component tree: pass a Server Component as children or another prop to a Client Component, rather than importing and rendering it directly. <Modal><Cart/></Modal> — Modal is a Client Component providing the open/close interactivity, but Cart is composed in from a Server Component parent and stays server-rendered; Modal just receives its already-rendered output as a slot.
React Context needs a Client wrapper because context depends on createContext/useContext, which require client-side reactivity — a Server Component can't subscribe to anything. The convention is a small "use client" provider file that wraps children, so everything below it can still be a Server Component by default.
server-only and client-only are guard packages: importing server-only into a module throws a build error if that module is ever pulled into a client bundle (and vice versa) — cheap insurance against a secret-reading utility accidentally leaking into the browser.
"use client" to a top-level layout or page "just to fix an error" drags everything it imports into the client bundle. Push the directive as far down the tree as possible — onto the specific interactive leaf, not its ancestors.04 · Rendering strategies
Core: the App Router collapses the old SSG/ISR/SSR taxonomy into two axes — static and dynamic rendering — decided per route at build/request time rather than declared up front. Static rendering runs at build time (or once, then cached), producing HTML that's reused for every visitor — this is what SSG and ISR effectively become. Dynamic rendering runs on every request, because the route needs something that can only be known at request time.
How the router decides: a route becomes dynamic when it touches a request-time API — reading cookies(), headers(), an uncached searchParams, or calling an uncached fetch. In the previous (pre–Cache Components) model, this was often made explicit with the route segment config export const dynamic = 'force-dynamic' | 'force-static', which pins the behavior instead of letting Next infer it from API usage. ISR in this framing is just static rendering with a revalidate window — serve the cached HTML, and regenerate it in the background after the window expires.
05 · Cache Components & Partial Prerendering
Core: Cache Components is the new caching model, opted into with cacheComponents: true in next.config.ts. It replaces "is this route static or dynamic" with a more granular question: which pieces of a route are cacheable, marked explicitly rather than inferred.
The "use cache" directive marks a function, component, or entire file as cacheable. Put it at the top of an async function and its return value gets cached; put it at the top of a file and every exported function in that file is cacheable. The cache key is derived automatically from the function's arguments and any values captured from its closure — you don't hand-roll a key.
cacheLife(profile) and cacheTag(tag), both imported from next/cache, are now stable — no more unstable_ prefix. cacheLife sets how long a cache entry is considered fresh (built-in profiles like 'seconds', 'minutes', 'hours', 'days', or a custom object); cacheTag attaches an invalidation label you can later target with revalidateTag/updateTag.
Partial Prerendering is the delivery mechanism: a route's static shell — everything that isn't wrapped in dynamic data access — prerenders at build time, and a <Suspense> boundary around a dynamic piece creates a "hole" in that shell that streams in at request time. The static shell serves instantly from the CDN edge; the dynamic hole fills in a beat later.
Under Cache Components, accessing a request-time API (like cookies()) or an uncached data source outside of a <Suspense> boundary or a "use cache" function is a build/dev-time error — the framework refuses to guess whether that piece should be static or dynamic and forces you to be explicit. connection() is the escape hatch for non-deterministic calls (Math.random(), Date.now()) that aren't request-time APIs but still shouldn't be baked into the static shell — awaiting it defers the rest of the function to request time.
next.config.ts for cacheComponents: true before reasoning about caching behavior.06 · The previous caching model
Core: unless a route opts into Cache Components, it runs on the original App Router caching model — and that model is important to know because it's still the default. This is not a layer underneath Cache Components; they're two different mental models for the same problem, and mixing their vocabulary in an interview answer is a tell that you don't actually know which one a given project is running.
In this model, fetch() is uncached by default in the App Router (a deliberate change from the Pages Router, where data fetching had no built-in cache at all). You opt in per call: fetch(url, { cache: 'force-cache' }) caches indefinitely until manually invalidated, and fetch(url, { next: { revalidate: 60, tags: ['posts'] } }) caches with a time-based revalidation window and/or a tag you can invalidate on demand.
For data access that isn't a fetch call — an ORM query, a direct database client — unstable_cache() gives the same caching behavior (TTL, tags) for an arbitrary async function. Route segment config still applies here: export const dynamic, export const revalidate, and export const fetchCache at the top of a page.tsx/layout.tsx pin the whole route's caching behavior rather than deciding it per-fetch.
React's cache() function is a different tool entirely — it doesn't persist data across requests, it deduplicates calls to the same function within a single request, so if three components on a page all call getUser(id), the underlying work runs once.
07 · Streaming & Suspense
Core: wrapping a slow piece of UI in <Suspense fallback={...}> lets the rest of the route's HTML shell stream to the browser immediately, with the fallback shown in place of the slow part, then the real content streams in and swaps the fallback out once it resolves — no need to block the entire page behind the slowest data dependency.
The subtlety worth stating out loud: being wrapped in Suspense is not the same as being dynamic. If the component inside the boundary is synchronous and doesn't touch any request-time API, it still fully resolves at build time — the Suspense boundary is just structurally present in the JSX, but there's nothing for it to actually suspend on, so it contributes nothing to a dynamic hole under Partial Prerendering. Suspense only creates useful streaming behavior when something inside it is genuinely asynchronous and slow (a real fetch, a real request-time API).
08 · Data fetching patterns
Core: Server Components fetch data directly in the component body with await — no useEffect, no loading state boilerplate for the common case. The pattern you choose controls whether independent fetches run in parallel or accidentally serialize.
Parallel: start multiple fetch/query calls without awaiting each one immediately, then await them together — either by awaiting two promises close together or explicitly with Promise.all([getUser(id), getPosts(id)]). Both requests fire at the same time instead of one waiting on the other.
Sequential (a waterfall): sometimes unavoidable and sometimes intentional — you need the result of one fetch (a user's team id) before you can make the next (that team's members). The skill isn't "never waterfall," it's recognizing which dependencies are real and which are accidental (e.g., two components independently awaiting inside nested JSX when they could have started together higher up the tree).
Preloading: a common idiom is a preload() utility — a non-awaited call to a cache()-wrapped data function, fired early (often at the top of a parent) so the request is already in flight by the time a child component actually awaits it deeper in the tree. Because cache() dedupes by arguments within the request, the child's later await getUser(id) just resolves the already-started promise instead of firing a second request.
use() is the API for the opposite direction — piping a promise from a Server Component into a Client Component and reading it there. The Server Component starts the fetch and passes the promise itself (not the awaited value) as a prop; the Client Component calls use(promise) inside a <Suspense> boundary to suspend until it resolves, which lets the server-rendered shell stream immediately while that specific value streams in afterward.
09 · Server Functions & Server Actions
Core: the "use server" directive marks a function as a Server Function — code that's defined wherever you write it but always executes on the server, callable from Client Components as if it were a local async function. Put it at the top of the function body for a one-off, or at the top of a file to mark every export in that file. When a Server Function is passed to a form, it's conventionally called a Server Action.
The idiomatic invocation is <form action={myServerFunction}>, or formAction on an individual submit button when a form needs to support multiple actions. Under the hood, Next generates a stable reference to the function and turns the form submission into a POST.
The security-critical fact: every Server Function is always a POST endpoint and always directly reachable from outside your UI — anyone who has the request signature can call it with curl, bypassing your form, your button's disabled state, your client-side checks entirely. This means you must verify authentication and authorization inside the function itself, every time, never relying on "the button is only rendered for admins" as a security boundary. The client-side gating is UX, not defense.
Multiple Server Function calls dispatched from the client are sent sequentially, one at a time — not in parallel — which matters if you're firing several actions in response to one interaction and expect them to race.
useActionState wires a Server Function to pending/result state for a form (replacing the old pattern of manually tracking a loading boolean and a result). useOptimistic lets you render the expected outcome of an action immediately, before the server confirms it, then reconciles once the real response comes back.
10 · Mutations & revalidation
Core: after a mutation, something has to tell the cache its data is stale. revalidatePath(path) invalidates everything cached for a specific route path — coarse but simple. revalidateTag(tag, profile) invalidates every cache entry carrying that tag, wherever it lives in the app — finer-grained, since one tag can span many routes.
| API | Behavior |
|---|---|
revalidatePath | Invalidate by route path |
revalidateTag(tag, profile) | Invalidate by tag — SWR-style: next visitor may still get stale data briefly while it regenerates |
updateTag(tag) | Invalidate by tag, Server-Actions-only — read-your-writes, refreshes immediately, no stale window |
refresh() | Server-Actions-only — refreshes uncached/dynamic UI on screen without touching the cache at all |
Next 16 breaking change: revalidateTag now takes a second, required cacheLife-profile argument, because it behaves with stale-while-revalidate semantics — the profile tells it how to treat the transition window. This is different from updateTag (new in 16, Server-Actions-only), which gives you immediate, read-your-writes freshness with no stale window, and refresh() (also new in 16, also Server-Actions-only), which refreshes only the uncached parts of the current UI without invalidating anything in the shared cache.
redirect() works by throwing a special control-flow exception that Next catches internally — this means code after a redirect() call never runs, and any revalidation you need must happen before you call it, not after.
11 · Dynamic routing
Core: square brackets in a folder name create a dynamic segment. [slug] matches exactly one path segment (/blog/hello-world → slug: 'hello-world'). [...catchAll] matches one or more segments and captures them as an array (/docs/a/b/c → ['a','b','c']) — but /docs alone won't match. [[...optionalCatchAll]] is the same, but also matches the base route with zero segments, making the whole array optional.
generateStaticParams is an async function exported from a dynamic route's page.tsx that returns the list of param values to prerender at build time — the App Router's replacement for getStaticPaths. Params not returned by it either 404 or render dynamically on first request, depending on config.
params (and searchParams) are Promises that must be awaited before use — this applies to every dynamic segment and every page/layout that reads them.
12 · Route groups, parallel routes & intercepting routes
Route groups (marketing) organize files and can define multiple root layouts — put two route groups directly under app/, each with its own layout.tsx containing <html>/<body>, and you get, say, a completely different shell for a marketing site versus a dashboard, sharing nothing at the root.
Parallel routes use a @slot folder to render more than one independent page into the same layout simultaneously — a dashboard with a @analytics slot and a @team slot rendered side by side, each with its own loading/error state and its own subnavigation. The layout receives each slot as a prop and places it in JSX like any other child.
default.js — previously Next would fall back gracefully in some cases, but now a slot without one causes a build failure. Add a default.tsx to every @slot folder, even if it just renders null.Intercepting routes let a route render in the context of the current layout while still updating the URL — the classic use case is a photo grid where clicking a thumbnail opens it as a modal (intercepted), but a hard refresh or direct link to that URL renders the full standalone page instead. The convention is relative-path-style prefixes on the folder name: (.) intercepts a segment at the same level, (..) one level above, (..)(..) two levels above, and (...) from the root.
13 · proxy.ts (formerly middleware.ts)
Core: Next 16 renamed the file and export — middleware.ts is now proxy.ts, and the exported function middleware is now named proxy. The rename is intentional: "middleware" implied it could sit anywhere in the request/response cycle doing arbitrary work, when in practice this layer is specifically a network boundary that runs before a request reaches your routes — proxy names that accurately.
The functional change alongside the rename: proxy.ts runs only on the Node.js runtime now — there's no edge runtime option for it. The deprecated middleware.ts path still exists and still supports edge, kept around for projects that specifically need edge execution, but new code should use proxy.ts and accept the Node.js runtime.
Common uses: gating routes behind auth (checking a session cookie before allowing a request through and redirecting to /login if it's missing), A/B testing (rewriting a request to a variant path based on a cookie or header), and header rewriting/injection (adding a request id, a CSP nonce, or normalizing a header before it hits a route handler).
14 · Route Handlers
Core: a route.ts file inside app/ exports HTTP-verb-named functions — GET, POST, PUT, DELETE, etc. — turning that segment into an API endpoint instead of a page. A folder can have page.tsx or route.ts for a given segment, not both.
When to reach for which: a Server Action / Server Function is the right tool when the caller is your own UI and you're performing a mutation triggered by user interaction — it gives you direct function-call ergonomics with no manual fetch/JSON wiring. A Route Handler is the right tool when you need a real HTTP endpoint — a webhook target for a third-party service, an API consumed by a non-Next client (a mobile app, another service), or when you need explicit control over headers/status codes/streaming responses. A plain Server Component fetch/data call is right when you're just reading data to render — no handler needed at all, since the component itself can talk to the database or an internal service directly.
Caching for GET handlers under Cache Components: a GET handler's output is not cached implicitly the way it might have been under some conventions in the old model — you opt in explicitly, the same way you would inside any other function: wrap the data-producing logic in "use cache" (with cacheLife/cacheTag as needed) if you want the response cached, rather than relying on framework-level route-config caching.
15 · Metadata & SEO
Core: export a static metadata object from a page.tsx/layout.tsx when the title/description/OG tags don't depend on data — Next merges metadata down the layout tree, with child segments overriding parent fields. When metadata depends on fetched data (a blog post's title, a product's price in the description), export an async generateMetadata() function instead, which receives the route's params and can await whatever it needs.
next/font self-hosts font files at build time — Google Fonts or local files get downloaded once during the build and served from your own domain, so there's no external network request to a font CDN at runtime (better privacy, one less DNS/TLS round trip) and no layout shift from a font swapping in late, since the font's metrics are known and reserved ahead of time.
Open Graph and Twitter card images can be static files (opengraph-image.png in a route folder) or generated dynamically with an opengraph-image.tsx file using ImageResponse. sitemap.ts and robots.ts are special files that generate /sitemap.xml and /robots.txt programmatically, so they can be derived from your actual route data (e.g., every blog post) instead of hand-maintained.
16 · Image & font optimization
Core: next/image replaces a plain <img> with a component that generates a responsive srcset automatically (so the browser downloads a size appropriate to the viewport, not always the largest version), lazy-loads offscreen images by default, and prevents layout shift by requiring width/height (or fill) up front so the browser reserves space before the image loads.
Remote images must be allow-listed via images.remotePatterns in next.config.ts — a structured pattern (protocol, hostname, port, pathname) rather than the older, coarser images.domains (deprecated), which just allow-listed an entire hostname with no path/protocol granularity.
Next 16 default changes worth knowing cold: minimumCacheTTL (how long an optimized image variant is cached) moved from 60 seconds to 4 hours — a much more sensible default for images that rarely change. And the qualities option narrowed to just [75] by default, reducing the number of distinct quality variants generated (and cached) per image unless you explicitly configure more.
next/font (covered in the metadata section) applies here too — it's the font half of the same "optimize static assets at build time, serve from your own domain" philosophy that next/image applies to images.
images.remotePatterns for a new external image host is one of the most common "why is my image broken in production" bugs — the dev server sometimes masks it depending on config, but a strict production build will refuse to optimize an unlisted host.17 · Async Request APIs
Core: cookies(), headers(), draftMode(), and every route's params and searchParams are all Promises — this used to be a gradual migration (sync access was deprecated with a warning for a while), but as of Next 16 sync access has been fully removed. Every one of these must be awaited before you can read from it, in both Server Components and Route Handlers.
The reasoning connects back to Cache Components: making these APIs async, rather than syncing them off some ambient request object, is what lets the framework reason about a component as "this awaits something request-dependent" versus "this doesn't" — which is exactly the signal Partial Prerendering needs to decide what belongs in the static shell versus a dynamic hole.
next typegen generates the helper types for a project's actual routes — PageProps, LayoutProps, and RouteContext — parameterized by the literal route pattern (like PageProps<'/blog/[slug]'> above), so params/searchParams come back correctly typed for that specific route instead of a generic Record<string,string>.
params.slug synchronously will throw or silently misbehave on Next 16 — always await params first.18 · Authentication & authorization patterns
Core: the common shape is session- or cookie-based — on login, set an HTTP-only cookie (a session id or a signed/encrypted token) that the server reads on subsequent requests to identify the user. HTTP-only keeps it out of reach of client-side JavaScript, which matters for XSS resistance.
Defense in depth across two layers: checking the session in proxy.ts is about UX and routing — redirect an unauthenticated visitor to /login before they even see a protected page's shell, or rewrite based on role. But checking auth again inside the actual Server Component, Route Handler, or Server Action is what provides real security — because, per the Server Functions section, those are directly, independently reachable regardless of whatever proxy logic sits in front of the UI. Treat proxy.ts-level checks as a nice-to-have redirect, never as the only gate.
In practice, most production apps don't hand-roll session management — they reach for Auth.js (formerly NextAuth.js) for OAuth provider integration, session/JWT handling, and adapter-based persistence, because getting token rotation, CSRF protection, and provider quirks right from scratch is a lot of surface area to own.
19 · Performance & Turbopack
Core: Turbopack is now stable and the default bundler for both next dev and next build in Next 16 — no flag needed to opt in. It's a from-scratch, Rust-based bundler built for incremental computation, and the headline numbers are roughly 2–5x faster production builds and up to 10x faster Fast Refresh compared to webpack on large apps. You can still opt back out to webpack with --webpack if a project depends on a webpack-specific plugin that hasn't been ported.
React Compiler is stable and opt-in via reactCompiler: true in next.config.ts. It statically analyzes component code at build time and inserts memoization automatically — the useMemo/useCallback/React.memo you'd have hand-written to avoid unnecessary re-renders get generated for you, which both reduces boilerplate and catches memoization opportunities a developer would likely miss or get subtly wrong.
Core Web Vitals — LCP (loading), INP (interactivity, replaced FID), CLS (visual stability) — remain the standard framing for real-user performance, and most of what's covered elsewhere in this guide (Partial Prerendering for fast shells, next/image for CLS, streaming for perceived load time) is in service of them.
Next 16 removed the "First Load JS" metric from the build output — it was a rough proxy for bundle size that didn't account well for streaming, code-splitting nuance, or Cache Components. The recommended replacement is measuring what users actually experience: Lighthouse for lab data, Vercel Analytics (or another RUM tool) for field data.
Two additional navigation-performance mechanisms worth naming: layout deduplication means a shared layout isn't re-fetched/re-rendered on every navigation between sibling routes — the router recognizes it's unchanged and reuses it. Incremental prefetching means the router prefetches only what's needed to render the next likely navigation (not the whole page tree eagerly), keeping prefetch traffic proportional to what the user is likely to click.
20 · Testing & deployment
Core: Client Components test the way any React component does — Jest or Vitest with React Testing Library, rendering the component, interacting with it, and asserting on the DOM. They're isolated units with no server dependency, so this layer is fast and cheap.
Server Components can't be unit-tested the same way — there's no simple "render it in jsdom" story for a component that's meant to run in a server environment, await data, and produce an RSC payload. The practical answer is to push Server Component coverage up a level, into integration or end-to-end tests (Playwright is the common choice) that actually run the app — real routing, real data fetching (against a test database or mocked network layer) — and assert on the rendered page, rather than trying to mount a Server Component in isolation.
Deployment shape is a real architectural choice, not an afterthought: output: "export" produces a fully static export — plain HTML/CSS/JS with no Node.js server required, deployable to any static host or CDN — but it forfeits anything that needs a live server: Server Actions, Route Handlers that do real server work, ISR/on-demand revalidation, and the async request APIs that depend on a real request. The alternative is deploying to a Node.js or Edge-capable platform (Vercel, or a self-hosted Node server) that can run the full feature set.
Environment variables: anything that needs to reach the browser must be prefixed NEXT_PUBLIC_ — Next inlines those at build time into the client bundle. Everything else stays server-only by default and is never exposed to the client. The old serverRuntimeConfig/publicRuntimeConfig mechanism from the Pages Router era is gone — environment variables are the one supported mechanism now.
Security basics: Next performs an Origin header check on Server Action requests as CSRF protection — a request whose Origin doesn't match the deployment's expected origin is rejected before your action code even runs. The server-only package (from the Server/Client Components section) guards against a server-only module — one reading secrets or talking to a database — accidentally ending up in a client bundle. And the general discipline is: never pass a secret as a prop into a Client Component, never log a secret where client-visible tooling could pick it up, and treat NEXT_PUBLIC_ as a one-way door — anything given that prefix should be treated as public forever.