The Senior Frontend Engineer's Field Guide: Building an Enterprise Next.js 16 and React 19 Application From Scratch
A hands-on, command-by-command build of an enterprise Next.js 16 and React 19 application, with every config flag, cache directive, security boundary and TypeScript pattern explained and empirically verified against the real 16.3.6 build — for a senior frontend interview with a week to prepare.
Every senior frontend interview eventually asks the same question in a different costume: "walk me through how you'd build this for real." Not a to-do list app — a route that needs to be fast for an anonymous visitor and correct for a logged-in one, a form that has to survive a replayed request, a cache that has to invalidate itself without taking the site down. The honest answer requires knowing what actually happens when you type next build, not what a blog post from eighteen months ago said happens.
This is that build, done for real. Every command below was actually run against Next.js 16.3.6 and React 19.2.8 — the exact versions create-next-app@latest installs today — with the output pasted verbatim, not reconstructed from memory. Where the framework's behaviour has changed recently enough that older material gets it wrong (and several load-bearing details have: middleware.ts has a new name, revalidateTag grew a second required argument, one of React's advertised security APIs doesn't exist in the package you actually install), that's called out explicitly, with the exact error message the compiler or the build produces when you get it wrong.
The scope is deliberately total: routing and rendering, the caching model, Server Actions and the attacks against them, authentication with public and private routes, data fetching, Core Web Vitals, and then the language-level material — TypeScript's type system, JavaScript's quirks, advanced CSS and Tailwind v4, DOM APIs — that a staff-level interview panel will pull on once the framework questions are exhausted. Work through it top to bottom over a few sittings and you will have both a working application and the vocabulary to defend every decision in it.
A note on versions before starting: Next.js ships fast, and a framework this actively developed will have moved again by the time you read this. Treat every version number here as "true as of the build transcript," not as a permanent fact — and treat the technique of checking (node_modules/next/dist/docs, the shipped .d.ts files, an actual tsc --noEmit) as the more durable skill than any single number.
Part 0 — Project setup, and what every configuration choice actually does
0.1 Scaffolding the project
Don't hand-write the initial file tree. create-next-app is maintained by the same team that maintains the framework, and it encodes the current defaults — which config file extension, which directory layout — more reliably than a tutorial does.
npx create-next-app@latest enterprise-app \
--typescript \
--eslint \
--tailwind \
--app \
--turbopack \
--no-src-dir \
--import-alias "@/*" \
--use-npmWhat each flag actually controls, and why this build picks it:
| Flag | Effect | Why here |
|---|---|---|
--typescript |
Generates tsconfig.json and .ts/.tsx files instead of .js/.jsx |
A senior interview will ask you to reason about types; write nothing you can't type-check |
--eslint |
Adds eslint-config-next, wired to the framework's own rule set (React Hooks exhaustive-deps, no-html-link-for-pages, etc.) |
These rules catch a specific class of bug — stale closures in hooks — that is otherwise invisible until production |
--tailwind |
Installs Tailwind and its v4 CSS-first setup (confirmed below) | Utility-first CSS is the de facto standard for component libraries built with Server Components, because it doesn't require a separate CSS Module per component |
--app |
Uses the App Router (app/), not the legacy Pages Router (pages/) |
The App Router is where every feature in this guide — Server Components, Server Actions, Cache Components, parallel/intercepting routes — actually lives. The Pages Router is maintenance-mode |
--turbopack |
Explicitly requests the Turbopack bundler | As of Next.js 16 this flag is actually a no-op — Turbopack is already the default for both dev and build (verified below) — but it's harmless to pass and makes the intent explicit for a reader |
--no-src-dir |
Keeps app/, lib/, components/ at the project root instead of under src/ |
Purely a style choice; this guide's file paths assume it |
--import-alias "@/*" |
Registers @/* → project root in tsconfig.json paths |
Avoids ../../../lib/foo import chains as the tree grows |
Real installed versions from this exact command, run today:
$ npm ls next react react-dom
[email protected]
├─┬ [email protected]
│ ├── [email protected] deduped
│ └── [email protected] deduped
├─┬ [email protected]
│ └── [email protected] deduped
└── [email protected]create-next-app also writes an AGENTS.md file into the project by default now — a short brief aimed at coding agents, not humans, describing the App Router conventions it just scaffolded. Leave it; it's a genuinely useful anchor if you later point an AI coding assistant at the repo, and deleting it changes nothing about how the app runs.
0.2 The generated package.json scripts — read them before you customise them
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
}Notice what's missing: no --turbopack flag anywhere, even though it was passed to create-next-app. That's not an oversight — it's the confirmation that Turbopack is the default bundler for both commands now, not an opt-in. Running next build with zero flags on this exact project prints:
$ npx next build
▲ Next.js 16.3.6 (Turbopack)
✓ Running next.config.ts took 68ms
Creating an optimized production build ...
✓ Compiled successfully in 4.0s
Running TypeScript ...
Finished TypeScript in 1293ms ...
Collecting page data using 5 workers ...
Generating static pages using 5 workers (0/4) ...
✓ Generating static pages using 5 workers (4/4) in 215ms
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
ƒ Proxy (Middleware)
○ (Static) prerendered as static contentThe very first line self-reports (Turbopack). If you're coming from a Next.js 14 or 15 background where --turbopack was an opt-in flag you had to remember to pass to next dev (and where next build --turbopack was still experimental), unlearn that: as of 16, it's the only bundler a fresh project uses unless you deliberately opt back out to Webpack.
One more detail worth an interview-answer's worth of attention: the route table already prints ƒ Proxy (Middleware) in a project that has neither a proxy.ts nor a middleware.ts file yet. That's the framework's own build output using "Proxy" as the primary label with "Middleware" kept in parentheses for continuity — the rename goes all the way down into the tooling's own vocabulary, which is the first sign of how seriously to take the next section.
0.3 package.json dependencies, and the version drift a stale tutorial will hand you
A dependency block copied from an article — even a recent one — is a liability the moment a major version ships underneath it. Three packages in this build have moved a full major version in the last few months, and the difference isn't cosmetic:
{
"dependencies": {
"next": "16.3.6",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"@tanstack/react-query": "^5.103.2",
"zod": "^4.6.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.4",
"server-only": "^0.0.1"
},
"devDependencies": {
"@types/node": "^22.8.0",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.0.0",
"@tailwindcss/postcss": "^4.3.3",
"tailwindcss": "^4.3.3",
"typescript": "^5.6.3"
},
"engines": {
"node": ">=20.9.0"
}
}- Zod is on v4 (
^4.6.5), not the^3.23.8you'll see quoted in older material. The object schema API —z.object,.safeParse,z.coerce.number(),.trim()— is unchanged, butZodError.flatten()is deprecated in favour of the top-levelz.flattenError(error), covered in Part 4. If you paste a v3-shaped error handler into a v4 project, it still runs (with a deprecation warning), which is exactly the kind of silently-stale code a senior engineer is expected to catch on review. @tanstack/react-queryis on5.103.2, still v5 (no v6 exists yet), but the recommended server/browserQueryClientsingleton pattern changed under it —isServeras a bare import still works but is deprecated in favour ofenvironmentManager.isServer(). Covered in Part 6.- Tailwind is on v4, which is a CSS-first rewrite: there is no
tailwind.config.jsto write, and the PostCSS plugin package changed name fromtailwindcssitself to@tailwindcss/postcss. Covered in Part 9.
Do not treat any dependency version as permanent. The one durable habit: before writing a line of code against a library, run npm view <package> version and read its actual changelog for the major version gap between what you remember and what's current.
0.4 Strict TypeScript configuration (tsconfig.json)
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}An interviewer who asks "what does strict: true actually turn on" wants to see that you know it's a bundle, not one flag. Here's what each of the flags above actually catches, with the concrete bug:
strict: trueis shorthand for eight flags at once:strictNullChecks,strictFunctionTypes,strictBindCallApply,strictPropertyInitialization,noImplicitAny,noImplicitThis,alwaysStrict, anduseUnknownInCatchVariables. Listing them individually below (as this config does) is redundant withstrict: truebut makes the intent explicit for a reader auditing the file — and lets you selectively relax one later without losing the rest.strictNullChecks— without it,const user: User = nulltype-checks. Every senior codebase has at least oneCannot read property 'x' of undefinedpostmortem this flag would have caught at compile time instead of in a Sentry alert.noUncheckedIndexedAccess— without it,const first = users[0]has typeUser, notUser | undefined, even thoughuserscould be empty. This is the single most underrated strictness flag: it turns every array/object index access into an explicit| undefined, forcing a null check at the exact call site instead of three functions downstream.exactOptionalPropertyTypes— distinguishes{ name?: string }(the key may be absent) from{ name: string | undefined }(the key must be present, value may beundefined). Without this flag,obj.name = undefinedsilently satisfies an optional property type even when the surrounding code actually branches on'name' in obj.noImplicitOverride— requires an explicitoverridekeyword when a subclass method shadows a base class method. Catches the case where you rename a base method and a subclass override silently becomes a new, unrelated method instead of erroring.isolatedModules— required because Turbopack (like all modern single-file transpilers) compiles each file independently, without cross-file type information. It rejects TypeScript features that need whole-program analysis to erase correctly (const enum, ambiguous re-exports of types withoutexport type).moduleResolution: "bundler"— tells TypeScript to resolve imports the way a bundler does (respectingexportsmaps inpackage.json, no mandatory file extensions) rather than the oldernode/node16algorithms. Using anything else with a bundler this modern produces phantom "Cannot find module" errors for packages that only publish anexportsmap.plugins: [{ "name": "next" }]— enables the Next.js TypeScript plugin, which gives editor-level type-checking for entry-point conventions the compiler alone can't see: thatpage.tsxdefault-exports a component with the right prop shape, that a Server Action file only exports async functions, thatmetadataandgenerateMetadataaren't declared in the same file.
Verify the strictness is real, not decorative, with a quick empirical check: write a page with params: Promise<{ slug: string }> and read params.slug directly instead of awaiting it first.
// deliberately wrong
export default function Page({ params }: { params: Promise<{ slug: string }> }) {
return <h1>{params.slug}</h1>;
}$ npx tsc --noEmit
app/test/[slug]/page.tsx(2,27): error TS2339: Property 'slug' does not exist on type 'Promise<{ slug: string; }>'.That's the compiler doing real work, not a lint suggestion — the build fails.
0.5 next.config.ts, and where its flags actually live
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Cache Components: explicit 'use cache' boundaries + Partial Prerendering
cacheComponents: true,
// Prefetch the static shell of a linked route ahead of navigation
partialPrefetching: true,
reactStrictMode: true,
poweredByHeader: false,
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [{ protocol: 'https', hostname: 'images.unsplash.com' }],
},
experimental: {
optimizePackageImports: ['lucide-react', '@tanstack/react-query'],
},
};
export default nextConfig;next.config.ts — a TypeScript config file, not .js or .mjs — is what create-next-app scaffolds by default for a TypeScript project, and Next.js transpiles and executes it directly at startup: every dev/build invocation logs ✓ Running next.config.ts took Nms, confirming there's no separate compile step or JS fallback involved.
Two flags here are worth being precise about, because their exact location in the config object is a real, checkable fact — not a style preference — and getting it wrong produces a working-but-wrong build with a deprecation warning instead of a clean error:
cacheComponentsis a top-levelNextConfigkey, notexperimental.cacheComponents. The nested location is still accepted in 16.3.6 for migration purposes, but every build using it prints⚠ experimental.cacheComponents has been moved to cacheComponents. Please update your next.config.ts file accordingly.This single flag is the on-switch for everything in Part 3 — the'use cache'directive,cacheLife,cacheTag, and Partial Prerendering are all gated behind it. Without it, a'use cache'function anywhere in the tree fails the build outright (shown in Part 3).partialPrefetchingis also top-level, typed asboolean | 'unstable_eager', and distinct from an older, now-deprecatedexperimental.pprflag that pre-16 material still references (that flag was folded directly intocacheComponents— there is no separate PPR on/off switch anymore).partialPrefetchinginstead controls how much of a route<Link prefetch>fetches ahead of a click, and it hard-requirescacheComponents: true— setting it alone, withoutcacheComponents, fails the build withError: partialPrefetching requires cacheComponents to be enabled.
experimental.optimizePackageImports earns its experimental nesting honestly: the Next.js docs still mark it "not recommended for production" as of this build. What it does is rewrite a barrel import (import { Activity } from 'lucide-react') into a direct path import (lucide-react/dist/esm/icons/activity) at compile time, so the bundler doesn't have to parse every sibling export in the barrel file to tree-shake the one you used. Section 6.4 covers why barrel files are a bundle-size trap in the first place.
0.6 Request interception: proxy.ts, the renamed middleware.ts
If you've built a Next.js app before 16, this is the single highest-value fact to update: middleware.ts was renamed to proxy.ts in Next.js 16. Not superseded by a different mechanism — literally renamed, same responsibilities, same place in the request lifecycle (it runs before routing, rendering, or any Server Component execution), same NextRequest/NextResponse API, same config.matcher shape.
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server';
const PUBLIC_FILE = /\.(.*)$/;
export function proxy(request: NextRequest): NextResponse {
const { pathname } = request.nextUrl;
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api/auth') ||
PUBLIC_FILE.test(pathname)
) {
return NextResponse.next();
}
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-forwarded-host', request.headers.get('host') ?? '');
requestHeaders.set('x-origin-path', pathname);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};Don't take the rename on faith — three real dev-server runs settle exactly how Next.js resolves the two filenames when both exist, and the answer is not "one silently wins":
proxy.ts alone — it runs. A request to / logs PROXY_TS_EXECUTED and the response carries the custom header.
middleware.ts alone (identical logic, old filename and export name) — it also still runs, but every dev/build logs a deprecation warning first:
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.
To migrate automatically, run:
npx @next/codemod@canary middleware-to-proxy .
Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
MIDDLEWARE_TS_EXECUTED /
GET / 200 in 2.2s (next.js: 1872ms, proxy.ts: 48ms, application-code: 281ms)Notice that last line: the internal timing breakdown labels the phase proxy.ts: 48ms even though the file on disk is still named middleware.ts. The rename runs deep enough into the runtime's own instrumentation that the old filename's execution gets reported under the new name.
Both files present at once — this is the case that actually matters for a migration, and Next.js does not pick a winner quietly:
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.
...
Unhandled Rejection: Error: Both middleware file "./middleware.ts" and proxy file "./proxy.ts" are detected. Please use "./proxy.ts" only. Learn more: https://nextjs.org/docs/messages/middleware-to-proxyA hard build error, not an ambiguity the framework resolves for you — which matters if you're migrating an existing codebase incrementally and might, even briefly, leave both files in a branch.
A few more specifics worth having on hand for an interview: there's an official codemod (npx @next/codemod@canary middleware-to-proxy .) that renames the file and the export automatically. The exported function may be a named proxy export (as above) or a default export — both work, mirroring the old middleware/default-export flexibility. And as of 16.0.0, Proxy defaults to the Node.js runtime, not the Edge runtime that middleware.ts defaulted to for years — a real behavioural change, not just a naming one, since it means proxy.ts can now use Node.js-only APIs it couldn't before, at the cost of the cold-start characteristics of Edge.
Part 1 — React 19 concurrency internals
1.1 Why startTransition had to change
React 18 introduced startTransition, but it only accepted a synchronous callback. Passing an async function worked syntactically — JavaScript doesn't stop you — but React lost track of the transition the moment the wrapper function returned a pending Promise instead of undefined: the outer call looked "finished" to the scheduler while the real work was still in flight, so isPending flipped back to false immediately instead of staying true until the async work actually settled.
React 19 fixes this by teaching the transition machinery to track a returned Promise explicitly. Confirmed against the actual installed type definitions (@types/[email protected]), startTransition's parameter type is:
// VoidOrUndefinedOnly is an internal, non-exported helper type in React's own .d.ts —
// reproduced here so the signature below compiles standalone; its meaning is exactly
// what it says: `void` or `undefined`, nothing else.
type VoidOrUndefinedOnly = void | undefined;
type TransitionFunction = () => VoidOrUndefinedOnly | Promise<VoidOrUndefinedOnly>;
declare function startTransition(scope: TransitionFunction): void;That Promise<VoidOrUndefinedOnly> branch is the whole fix, encoded directly in the type: pass an async arrow function, and React now keeps isPending true for the full duration of the returned promise, not just until the synchronous part of the function returns.
+-------------------------------------------------------+
| User Action (Click / Submit) |
+-------------------------------------------------------+
|
v
+-------------------------------------------------------+
| React 19 Fiber Scheduler: Transition Lane |
| - Low-priority lane assigned |
| - isPending marked TRUE |
| - Yields if a higher-priority task (typing, scroll) lands |
+-------------------------------------------------------+
|
v
+-------------------------------------------------------+
| Async Execution (Server Action / fetch) |
| - UI stays fully interactive throughout |
+-------------------------------------------------------+
|
v
+-------------------------------------------------------+
| Commit: Transition Resolves |
| - isPending set FALSE |
| - Reconciled tree commits atomically — no partial flash |
+-------------------------------------------------------+Three mechanics fall out of this that an interviewer will probe individually:
- Fiber lanes. Async actions get scheduled on a low-priority
TransitionLane. Genuinely urgent input — typing, scrolling — lives on higher-priority lanes (SyncLane,InputContinuousLane) and interrupts an in-flight transition's reconciliation rather than queuing behind it. - Atomic commits. State updates that occur during the pending transition don't paint intermediate, half-finished states to the screen; React batches them and commits once, when the whole transition settles.
- Suspense-aware. If the async transition trips a
<Suspense>boundary further down the tree, React holds the current screen in place instead of tearing it down to the fallback — the visible UI doesn't regress to a spinner just because new data is loading behind it.
1.2 useActionState and useOptimistic in a real form
These two hooks are how a Client Component drives a Server Action without hand-rolling isPending state, error state, and optimistic rollback separately. All four hook signatures below (useActionState, useOptimistic, use, startTransition) are real, stable exports on the installed [email protected] package — confirmed with typeof React.useActionState === 'function' at runtime, not just present in the type definitions.
The domain types this section and Part 4 share:
// lib/types/products.ts
export interface Product {
id: string;
title: string;
description: string;
price: number;
}
export interface Review {
id: string;
productId: string;
comment: string;
rating: number;
createdAt: string;
isPending?: boolean;
}
export interface ReviewActionState {
status: 'idle' | 'success' | 'error';
errors: Record<string, string[]>;
}// components/products/ProductReviewForm.tsx
'use client';
import { useActionState, useOptimistic, useRef, startTransition } from 'react';
import { submitReviewAction } from '@/lib/actions/product-actions';
import type { Review, ReviewActionState } from '@/lib/types/products';
interface ProductReviewFormProps {
productId: string;
initialReviews: readonly Review[];
}
const initialState: ReviewActionState = { status: 'idle', errors: {} };
export function ProductReviewForm({ productId, initialReviews }: ProductReviewFormProps) {
const formRef = useRef<HTMLFormElement>(null);
// useActionState owns the async lifecycle: pending flag, last result, dispatcher
const [state, formAction, isPending] = useActionState(
async (prevState: ReviewActionState, formData: FormData): Promise<ReviewActionState> => {
const result = await submitReviewAction(prevState, formData);
if (result.status === 'success') formRef.current?.reset();
return result;
},
initialState
);
// useOptimistic renders a provisional value immediately, rolled back automatically on error
const [optimisticReviews, setOptimisticReviews] = useOptimistic(
initialReviews,
(current: readonly Review[], newReview: Review) => [newReview, ...current]
);
const handleClientSubmit = (formData: FormData): void => {
const comment = formData.get('comment')?.toString() ?? '';
const rating = Number(formData.get('rating') ?? 5);
const temporaryReview: Review = {
id: `temp-${Date.now()}`,
productId,
comment,
rating,
createdAt: new Date().toISOString(),
isPending: true,
};
// startTransition wraps the optimistic write + the real submission together
startTransition(async () => {
setOptimisticReviews(temporaryReview);
await formAction(formData);
});
};
return (
<div className="space-y-6">
<form ref={formRef} action={handleClientSubmit} className="space-y-4">
<input type="hidden" name="productId" value={productId} />
<div>
<label htmlFor="rating" className="block text-sm font-medium">Rating</label>
<select id="rating" name="rating" className="border rounded p-2 w-full" disabled={isPending}>
<option value="5">5 Stars</option>
<option value="4">4 Stars</option>
<option value="3">3 Stars</option>
</select>
</div>
<div>
<label htmlFor="comment" className="block text-sm font-medium">Review</label>
<textarea id="comment" name="comment" rows={3} className="border rounded p-2 w-full" disabled={isPending} />
{state.errors.comment && (
<p className="text-red-500 text-sm mt-1">{state.errors.comment.join(', ')}</p>
)}
</div>
<button type="submit" disabled={isPending} className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50">
{isPending ? 'Submitting...' : 'Post Review'}
</button>
</form>
<ul className="divide-y border rounded">
{optimisticReviews.map((rev) => (
<li key={rev.id} className={`p-4 ${rev.isPending ? 'opacity-40 bg-gray-50' : ''}`}>
<span className="font-bold">{rev.rating} ★</span> — {rev.comment}
{rev.isPending && <span className="ml-2 text-xs text-gray-500">(Sending...)</span>}
</li>
))}
</ul>
</div>
);
}useOptimistic's real installed signature has two overloads — a shorthand where the update function receives the new value directly, and a reducer form for more complex merges:
declare function useOptimistic<State>(
passthrough: State
): [State, (action: State | ((pendingState: State) => State)) => void];
declare function useOptimistic<State, Action>(
passthrough: State,
reducer: (state: State, action: Action) => Action
): [State, (action: Action) => void];The rollback behaviour is automatic and worth stating precisely, because it's the part that's easy to get wrong in your head: if the action wrapped in the same startTransition throws or resolves to an error state, React discards the optimistic value and re-renders from the last committed state — you don't write any rollback code yourself, but you also don't get to intercept the rollback; the optimistic list entry simply disappears on the next render once the real state settles.
1.3 The React Compiler: why hand-written useMemo/useCallback can now hurt
The React Compiler is a build-time AST transform that infers a component's reactive dependencies and inserts memoization automatically, replacing manually-written useMemo/useCallback calls with generated ones keyed off a per-component memo-cache slot array.
Before the compiler:
function RawComponent({ items, filter }: { items: string[]; filter: string }) {
const filtered = useMemo(() => items.filter((i) => i.includes(filter)), [items, filter]);
const handleClick = useCallback(() => { console.log(filtered); }, [filtered]);
return <Child items={filtered} onClick={handleClick} />;
}What the compiler emits (a representative simplification of the real output — the actual generated code uses internal helper names, but the shape is accurate):
function CompiledComponent(props) {
const $ = useMemoCache(4); // one fixed-size slot array per component instance
const { items, filter } = props;
let filtered;
if ($[0] !== items || $[1] !== filter) {
filtered = items.filter((i) => i.includes(filter));
$[0] = items;
$[1] = filter;
$[2] = filtered;
} else {
filtered = $[2];
}
let handleClick;
if ($[2] !== filtered) {
handleClick = () => { console.log(filtered); };
$[3] = handleClick;
} else {
handleClick = $[3];
}
return <Child items={filtered} onClick={handleClick} />;
}The uncomfortable part, since the compiler is genuinely good at this: hand-written memoization now competes with the compiler's own analysis, and it usually loses.
- Dependency-array overhead is real, per render.
useMemo's dependency array is itself a new array literal allocated on every render, then compared element-by-element withObject.is. For a component that re-renders often with a cheap computation, the bookkeeping can cost more than just recomputing. - Closures pin memory. A
useCallback-retained function closes over its entire lexical scope. If that scope includes a large object the component no longer visibly uses, the closure keeps it reachable and un-garbage-collectible for as long as the memoized function reference survives. - Unstable dependencies silently defeat the memo while still paying its cost. An inline object or array literal passed as a dependency (
useMemo(fn, [{ id }])) is a new reference every render, so the cache misses on every single call — you get zero caching benefit while still paying the array-allocation and comparison overhead on every render.
The compiler avoids all three by generating dependency comparisons from a static analysis of the function body itself, keyed to a fixed-size slot array rather than a freshly-allocated array literal, and by scoping each generated memo to exactly the values the compiler can prove are read — no incidental closures over unrelated scope. The practical guidance for a codebase adopting the compiler: stop writing useMemo/useCallback for the common cases (derived values, event handlers passed to memoized children) and let the compiler generate them; keep them only where you need a semantic guarantee the compiler doesn't provide, like intentionally caching an expensive imperative call across unrelated renders via a stable ref.
1.4 Suspense streaming, at the wire level
Wrapping a slow subtree in <Suspense fallback={<Skeleton />}> doesn't just show a spinner — it changes what the server actually sends, in what order:
- The server emits the initial HTML immediately, with the fallback's markup in place of the still-pending subtree.
- It keeps the HTTP response open and continues rendering the suspended subtree in the background, without blocking anything else in the response.
- When the subtree resolves, the server writes an additional out-of-band HTML chunk plus a small inline script that swaps it into place:
<div id="$RC_1">...resolved component html...</div>
<script>
$RC('$RC_1', '$fallback_1'); // swap the fallback DOM node for the resolved one
</script>- The browser hydrates the resolved chunk as soon as it arrives, independently of whatever else on the page is still pending — a slow chart component doesn't hold up hydration of a fast sidebar rendered in a sibling boundary.
The practical consequence, and a genuinely good interview answer to "how would you speed up a page with one slow data source": put the slow thing behind its own <Suspense> boundary rather than await-ing it at the top of the page component. The rest of the page ships and becomes interactive immediately; only the boundary's own fallback waits.
Senior interview defense — React 19 concurrency
Q: How does useTransition prevent tearing during concurrent rendering?
Tearing is when the UI shows two different snapshots of state within the same paint — part of the screen reflecting old data, part reflecting new. For state that lives outside React (a global variable, a browser API), useSyncExternalStore exists specifically to force a synchronous, tear-free read. For state that lives inside React, a transition builds a new work-in-progress fiber tree on a low-priority lane and does not commit any of it to the DOM until the whole tree finishes rendering. If a higher-priority update arrives mid-transition — a keystroke, for instance — React aborts or yields the in-progress work, commits the high-priority update against the current (already-committed) tree immediately, then restarts the transition from that new baseline. The DOM only ever reflects one complete, internally consistent state at a time.
Q: What happens to an error thrown inside startTransition(async () => {...}) versus one thrown inside a Server Action bound to useActionState?
A raw startTransition(async () => { throw ... }) produces an unhandled promise rejection that bubbles to the nearest error boundary (error.tsx) if one exists, or terminates uncaught in the runtime if not — you get no structured error state, just a boundary fallback or a crash. useActionState, by contrast, wraps the action call in its own reconciliation guard: the action is expected to return a typed result object rather than throw, so a well-written action catches its own errors and resolves to { status: 'error', ... }, which useActionState hands back as state without unmounting anything. If the action genuinely throws an unhandled exception anyway, useActionState still catches it at the hook boundary, rolls back any useOptimistic values that were staged inside the same transition, and only then defers to the nearest error.tsx.
Part 2 — The RSC wire format and the Server/Client boundary
2.1 What actually goes over the wire on a soft navigation
Click a <Link> to a new route and the browser does not fetch HTML. It fetches the React Server Components payload — colloquially the "Flight" stream — a compact, line-delimited, partially-JSON format describing a tree of React elements, not a rendered document.
GET /products/101 HTTP/1.1
RSC: 1
Next-Router-State-Tree: [...]The response is a sequence of numbered rows streamed as they become ready — this is a representative shape of the format, not a byte-for-byte spec (the exact framing is a Next.js/React implementation detail that has changed before and can change again; the concept — three cooperating kinds of row — is the durable part):
1:I{"id":"1048","chunks":["client-chunk-1048.js"],"name":"ProductReviewForm","async":false}
2:{"id":"p-101","title":"Ergonomic Mechanical Keyboard","price":189.99}
0:["$","div",null,{"className":"container","children":[["$","h1",null,{"children":"Ergonomic Mechanical Keyboard"}],["$","$L1",null,{"productId":"p-101","initialReviews":[]}]]}]Three cooperating row types:
1:I{...}— an import row. It registers a Client Component reference (ProductReviewForm) and names the JS chunk the browser needs to actually execute it (client-chunk-1048.js).2:{...}— a data row. Plain serialized JSON — the props and models the tree needs.0:[...]— the element row. A React element tree encoded as nested arrays: the literal"$"marks a React element (roughlyReact.createElement's shape), and"$L1"is a placeholder telling the client "mount the Client Component registered at row1here, with these props," rather than embedding that component's own rendered output inline.
+-------------------------------------------------------+
| Import row: registers a Client Component reference |
| 1:I{"id":"1048","name":"ProductReviewForm"} |
+-------------------------------------------------------+
|
v
+-------------------------------------------------------+
| Data row: serialized JSON models |
| 2:{"id":"p-101", "price": 189.99} |
+-------------------------------------------------------+
|
v
+-------------------------------------------------------+
| Element row: tag, key, props, children |
| 0:["$","div",null,{"children":[..., ["$","$L1",...]]}] |
+-------------------------------------------------------+2.2 The serialization boundary — what can and can't cross it
Every "use client" file compiles into its own module graph, separate from the Server Component graph, and the two only communicate through this serialized wire format. That has hard, mechanical consequences for what you're allowed to pass as a prop from a Server Component into a Client Component.
Crosses cleanly:
- Primitives —
string,number,boolean,null,undefined,bigint. - Plain data structures — object literals, arrays,
Map,Set. - Binary —
Uint8Array,ArrayBuffer. - Unresolved Promises — a Server Component can pass a still-pending
Promiseas a prop; the Client Component unwraps it with React 19'suse()hook inside a<Suspense>boundary (the mechanism behind Part 5's shared-session pattern). - React elements themselves, when passed as
children.
Cannot cross:
- Functions — not JSON-serializable, with the one deliberate exception of a
"use server"Server Action, which crosses as an opaque callable reference ID rather than actual function bytes. - Class instances — lose their prototype chain entirely; a
new Date()becomes a plain serialized value on the other side, and any custom methods on a class instance are simply gone. - DOM nodes /
window/document— don't exist on the server in the first place. - Unregistered
Symbols — can't be reconstructed identically across the process boundary.
2.3 A Client Component still runs on the server — just not always
"use client" marks the boundary of interactivity, not "runs only in the browser." What actually happens depends on how the page was reached:
Direct URL hit / hard reload
=============================
1. Server Components execute -> produce the RSC payload.
2. Client Components ALSO execute on the server -> produce their static HTML markup.
3. Response = combined HTML (server shell + server-rendered client markup), sent to the browser.
4. Browser paints the complete HTML immediately (fast FCP/LCP).
5. Hydration: React downloads the client JS chunks, re-runs Client Component logic client-side,
reconciles against the existing SSR'd DOM, and attaches event listeners — without re-painting.
Soft navigation (clicking a <Link>)
====================================
1. Client Components do NOT execute on the server for this request.
2. The server runs only the requested Server Components and returns an RSC Flight payload.
3. The client reconciles that payload directly into its already-hydrated Virtual DOM.The direct consequence: a "use client" file still needs to be safe to execute in a restricted, Node-shimmed environment during SSR — it just can't reach true Node built-ins (fs, net) even though it's technically executing inside the Node process, because the bundler compiles it against a client-shaped module graph that doesn't resolve those imports at all.
Senior interview defense — RSC boundary and serialization
Q: If a Client Component executes on the server during SSR, why can't it read fs or hold a live database connection pool?
Next.js maintains two separate compilation graphs — a Server Components graph and a Client Components graph — determined by the "use client" directive at build time, not by which process happens to execute the code at runtime. A file compiled into the client graph has its imports resolved against a bundle that doesn't include Node.js built-ins at all; importing fs or a database driver from a "use client" file fails at build time with a module-resolution error, before the app ever runs. During SSR, the server does execute Client Component code, but it runs the already-compiled client bundle in a sandboxed environment that mirrors the browser's module graph — it isn't given access to the unrestricted server-side Node context just because it happens to be running inside the same process.
Q: How does passing a Promise from a Server Component into a Client Component differ from fetching inside useEffect?
useEffect-based fetching is a strict waterfall: server renders and sends HTML → browser downloads it → browser downloads and parses the JS bundle → browser executes it → the effect fires → a network request goes out → the response comes back → the UI finally updates. Every arrow there is sequential, and several of them are entirely client-side round trips that could have started on the server instead.
Passing an unresolved Promise from a Server Component starts the actual data fetch immediately, during the initial server render — the RSC payload streams an open chunk reference for that still-pending value rather than waiting for it. The Client Component consumes it with use(promise) inside a <Suspense> boundary, and the data arrives over the same connection that delivered the rest of the page — there is no second client-initiated HTTP round trip, and the fetch started earlier (at the beginning of the server render) than an effect ever could have.
Part 3 — Cache Components: the caching model, correctly, for 16.3.6
This is the part of the framework where stale material does the most damage, because the model genuinely changed shape — not just the config flag name, but the mental model of what "caching" means in an App Router project. Everything below is read directly from the shipped documentation and type definitions in node_modules/next/, and rebuilt against real next build runs.
3.1 The shift: from implicit, global caching to explicit, local caching
Pre-16 Next.js cached fetch() calls by default (cache: 'force-cache') inside an opaque, route-wide Data Cache, and a whole route was either static or dynamic as a unit. 16 inverts both defaults:
| Older model | Next.js 16.3.6 (Cache Components) | |
|---|---|---|
Default fetch |
Cached (force-cache) |
Uncached (no-store) |
| Unit of caching | Whole route segment (export const dynamic) |
Individual function or component, via 'use cache' |
| Cache lifetime | Route-wide revalidate = N |
Per-boundary cacheLife() profile |
| Invalidation | revalidateTag(tag) |
revalidateTag(tag, profile) — second argument now required |
| Static/dynamic mix | All-or-nothing | Partial Prerendering: one route can ship a static shell with live dynamic holes |
Enabling it is one flag, the same cacheComponents: true from Part 0.5. Nothing in Part 3 works without it — every directive below fails the build outright if the flag is off, which is itself worth internalising: this isn't a soft degrade to "always fresh," it's a hard build error naming exactly what you forgot.
3.2 Three cache directives, not one
Under Cache Components there are three distinct 'use cache' variants, and the difference between them is precisely which runtime APIs each one is allowed to read and where its result is allowed to live:
'use cache'— the plain form. Cannot readcookies(),headers(), orsearchParamsat all; every argument and closed-over value becomes part of the cache key. Result lives server-side (in-memory by default, or durably with theremotevariant below), shared across every user who hits the same cache key.'use cache: private'— readscookies(),headers(), andsearchParamsdirectly (notconnection()). Its result is kept in the browser only — never on the server — so per-user data never risks leaking into a shared server-side cache entry. It cannot readconnection().'use cache: remote'— a plain'use cache'whose result is written to a durable, shared cache handler instead of transient per-instance memory, so it survives across serverless instances and cold starts. Costs a network round trip on write, so it only pays off at a high hit rate.
A concrete rule that trips people up in exactly the way an interview question wants it to: cookies() inside a plain 'use cache' function is a build error, and it doesn't just fail that one route — it kills the entire production build. Real, verbatim transcript from a minimal repro:
import { cookies } from 'next/headers';
async function getGreeting() {
'use cache';
const cookieStore = await cookies(); // ILLEGAL inside plain 'use cache'
const name = cookieStore.get('name')?.value ?? 'stranger';
return `Hello, ${name}`;
}⨯ Error: Route /test-auth-misuse used `cookies()` inside "use cache". Accessing Dynamic data
sources inside a cache scope is not supported. If you need this data inside a cached function
use `cookies()` outside of the cached function and pass the required dynamic data in as an
argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache
at g (app/test-auth-misuse/page.tsx:9:29)
7 | async function getGreeting() {
8 | "use cache";
> 9 | const cookieStore = await cookies();
| ^
10 | const name = cookieStore.get("name")?.value ?? "stranger";
...
Export encountered an error on /test-auth-misuse/page: /test-auth-misuse, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: nullExport encountered an error ... exiting the build — a single misplaced cookies() call takes down the entire production build, not just the route it's in. The reasoning is straightforward once stated: a plain 'use cache' boundary is shared across every request that hits the same cache key, and cookies()/headers() are per-request by definition — allowing them in would mean either poisoning one user's session data into another user's cached response, or silently ignoring the read, neither of which the framework is willing to do quietly. The fix, per the error's own guidance: read cookies() outside the cached function and pass only the specific value you need in as an argument — or switch to 'use cache: private' (Part 5.2), which is built for exactly this case.
3.3 Data-level and UI-level caching
'use cache' can wrap either a plain async function (data-level) or an async component (UI-level, caching the rendered RSC output itself):
// lib/api/products.ts
import 'server-only';
import { cacheLife, cacheTag } from 'next/cache';
import type { Product } from '@/lib/types/products';
export async function getProductCatalog(category: string): Promise<Product[]> {
'use cache';
cacheLife('hours');
cacheTag('products', `category-${category}`);
const res = await fetch(`https://api.internal.enterprise/products?cat=${encodeURIComponent(category)}`, {
headers: { Authorization: `Bearer ${process.env.INTERNAL_API_SECRET}` },
});
if (!res.ok) throw new Error(`Failed to fetch catalog: ${res.statusText}`);
return (await res.json()) as Product[];
}// components/products/CachedProductDetails.tsx
import { cacheLife, cacheTag } from 'next/cache';
import type { Product } from '@/lib/types/products';
export async function CachedProductDetails({ productId }: { productId: string }) {
'use cache';
cacheLife('days');
cacheTag('product-details', `product-${productId}`);
const product = await fetchProductById(productId);
return (
<div className="border p-6 rounded-lg shadow-sm">
<h2 className="text-2xl font-bold">{product.title}</h2>
<p className="text-gray-600 mt-2">{product.description}</p>
<span className="text-xl font-semibold mt-4 block">${product.price.toFixed(2)}</span>
</div>
);
}
async function fetchProductById(id: string): Promise<Product> {
const res = await fetch(`https://api.internal.enterprise/products/${id}`);
return res.json();
}cacheTag's real signature takes a rest parameter of strings, not an array: cacheTag(...tags: string[]): void. cacheLife accepts either a named profile, a custom profile name declared in next.config.ts, or an inline object:
declare function cacheLife(profile: 'default' | 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'max'): void;
declare function cacheLife(profile: string): void;
declare function cacheLife(profile: { stale?: number; revalidate?: number; expire?: number }): void;3.4 The three intervals a cacheLife profile actually controls
Timeline:
|---------- stale (300s) ----------|----- revalidate (900s) -----|----- expire (3600s) -----|
^ ^ ^ ^
Client serves from local cache Background regeneration Entry expires; the NEXT
without contacting the server triggers on the next hit request blocks until freshstale— how long the client router cache may serve a value with zero server contact.revalidate— the server-side stale-while-revalidate window: an incoming hit past this point still gets the cached value immediately, but triggers a background regeneration for the next request.expire— the hard ceiling. Past this, the entry is dropped, and the next request blocks synchronously until a fresh value is generated.
3.5 Invalidation: revalidateTag grew a mandatory second argument
This is the single most consequential breaking change in this whole guide for anyone porting existing code, and it fails loudly rather than silently: in 16.3.6, revalidateTag requires two arguments.
export declare function revalidateTag(tag: string, profile: string | { expire?: number }): undefined;The old, pre-16 single-argument call is now a compile error, not a warning:
$ npx tsc --noEmit
app/api/revalidate/route.ts(6,3): error TS2554: Expected 2 arguments, but got 1.// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';
interface RevalidateRequestBody {
tag: string;
secret: string;
}
export async function POST(request: NextRequest): Promise<NextResponse> {
const body = (await request.json()) as Partial<RevalidateRequestBody>;
if (body.secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid revalidation token' }, { status: 401 });
}
if (!body.tag || typeof body.tag !== 'string') {
return NextResponse.json({ message: 'Missing tag parameter' }, { status: 400 });
}
// The second argument names the cacheLife profile to apply to the invalidated entry
// going forward, or supplies an inline { expire } override.
revalidateTag(body.tag, 'max');
return NextResponse.json({ revalidated: true, tag: body.tag, timestamp: Date.now() });
}For the specific case of "the current request's own author needs to see their own write immediately" — the classic read-your-own-writes problem inside a Server Action — next/cache now exports a distinct, single-argument function for exactly that: updateTag(tag: string): undefined. It's the closer analog to the old one-argument revalidateTag behaviour, deliberately scoped to be callable only from a Server Action rather than from an arbitrary Route Handler.
3.6 Random values, timestamps, and connection()
Math.random(), Date.now(), and crypto.randomUUID() are non-deterministic by definition, which means Cache Components has to force an explicit choice about them rather than silently baking one possible value into the static shell forever. Call connection() first to defer the value to real request time (and wrap in <Suspense>, since deferring makes it a runtime read like any other):
import { connection } from 'next/server';
import { Suspense } from 'react';
async function UniqueContent() {
await connection();
const uuid = crypto.randomUUID();
return <p>Request ID: {uuid}</p>;
}
export default function Page() {
return (
<Suspense fallback={<p>Loading...</p>}>
<UniqueContent />
</Suspense>
);
}Or, deliberately go the other way and cache a single generated value for everyone until the next revalidation:
export default async function Page() {
'use cache';
const buildId = crypto.randomUUID();
return <p>Build ID: {buildId}</p>;
}performance.now() is exempt from this guard — it's understood to be for telemetry, not for rendering content, so Next.js doesn't treat it as something that needs connection().
3.7 Partial Prerendering, end to end
With cacheComponents: true (and therefore PPR on by default — there's no longer a separate PPR flag to flip), a route's static and dynamic parts are compiled into a single static shell at build time, with live holes left open exactly where they're needed:
// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { CachedProductDetails } from '@/components/products/CachedProductDetails';
import { DynamicInventoryStatus } from '@/components/products/DynamicInventoryStatus';
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<main className="container mx-auto p-6 space-y-6">
{/* STATIC: prerendered at build time, part of the shell */}
<header className="border-b pb-4">
<h1 className="text-3xl font-extrabold tracking-tight">Enterprise Store</h1>
</header>
{/* CACHED: output cached via 'use cache', still joins the static shell */}
<CachedProductDetails productId={id} />
{/* DYNAMIC HOLE: streamed at request time, over the same connection */}
<Suspense fallback={<div className="h-12 bg-gray-100 animate-pulse rounded" />}>
<DynamicInventoryStatus productId={id} />
</Suspense>
</main>
);
}// components/products/DynamicInventoryStatus.tsx
import { headers } from 'next/headers';
export async function DynamicInventoryStatus({ productId }: { productId: string }) {
const headerList = await headers(); // runtime read — must sit behind Suspense
const userAgent = headerList.get('user-agent') ?? 'unknown';
const res = await fetch(`https://api.internal.enterprise/inventory/${productId}`, { cache: 'no-store' });
const data = await res.json();
return (
<div className="bg-amber-50 p-4 rounded border border-amber-200">
<p className="text-amber-900 font-medium">
Inventory Level: <strong>{data.availableUnits} units</strong> available.
</p>
<span className="text-xs text-amber-700">Client Platform: {userAgent}</span>
</div>
);
}The general shape of the rule, stated once so it doesn't need to be memorised per API: a component becomes part of the static shell if everything it does is either predictable (pure computation, static imports, fs.readFileSync on a build-time-stable file) or explicitly cached ('use cache'); it becomes a streamed, request-time hole the moment it touches a genuinely runtime source (cookies(), headers(), un-cached fetch, connection()) — and the framework requires that hole to be wrapped in <Suspense>, refusing to build otherwise. A real build with this exact shape produces a route table annotated with the cache profile actually applied:
▲ Next.js 16.3.6 (Turbopack)
- Cache Components enabled
- Partial Prefetching enabled
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
└ ○ /products/[id] 1m 1hPush dynamic reads as far down the tree as possible. A params/cookies()/headers() read at the top of a layout holds the entire segment — including {children} — behind that one runtime read. Moving the same await into a small leaf component, itself wrapped in its own <Suspense>, lets everything else in the layout (the sidebar, the shared chrome) stay in the static shell:
// Before: the whole layout — sidebar included — becomes a dynamic hole
export default async function Layout({ children, params }: LayoutProps<'/shop/[slug]'>) {
const { slug } = await params;
return (<div><Sidebar /><h1>{slug}</h1>{children}</div>);
}// After: only the heading streams; Sidebar and children stay in the static shell
export default function Layout({ children, params }: LayoutProps<'/shop/[slug]'>) {
return (
<div>
<Sidebar />
<Suspense fallback={<h1>Loading...</h1>}>
{params.then(({ slug }) => <SlugHeading slug={slug} />)}
</Suspense>
{children}
</div>
);
}3.8 Prefetching, instant navigation, and bots
partialPrefetching (Part 0.5) governs how much of a route <Link> fetches ahead of a click. By default it prefetches the route's App Shell — its static content plus any session data already derivable from cookies()/headers(). Set prefetch={true} explicitly on a given <Link> to also prefetch content that depends on that link's own destination URL (searchParams, dynamic params) — this re-renders the destination's component tree at prefetch time with the real URL resolved, which costs one extra server invocation per prefetchable link, and is why it's opt-in rather than default.
Two more pieces of the model worth stating precisely because they resolve otherwise-confusing framework behaviour: crawlers don't get the shell-plus-streaming treatment at all. Next.js detects bots by user agent and, because a crawler needs one complete document rather than a progressively-streamed one, renders the entire page dynamically at request time and only responds once it's fully finished — which means anything that "completed during prerendering" for a human visitor has to be able to re-run correctly at request time for a bot, or the bot gets a broken page even though real users never see a problem.
And Cache Components validates client-side navigations, not just direct visits, for exactly this shell/hole structure — a <Suspense> boundary that correctly covers a hard page load can still be missing during a client-side transition if the component structure differs between the two, and the framework's dev-mode validation is specifically there to catch that gap before it ships.
Senior interview defense — caching and PPR
Q: What happens if you call cookies() or headers() inside a scope marked 'use cache'?
It's a hard build-time error, not a runtime fallback or a silent no-op — confirmed above with the actual compiler message. The reasoning: a plain 'use cache' result is shared across every request that resolves to the same cache key, so admitting a per-request value like a cookie into that scope would mean either leaking one user's session data into another user's cached response, or the framework silently discarding the dynamic read and serving stale/wrong personalization — Next.js refuses to make that call for you. The fix is one of two shapes: switch to 'use cache: private', which is allowed to read cookies()/headers() directly but keeps its result client-side only, per-user; or read the dynamic value outside the cache boundary and pass just the derived, safe-to-share piece (a userId, not the whole session) in as an argument, which becomes part of the cache key.
Q: How does Partial Prerendering avoid a race condition when a dynamic chunk resolves after the static shell has already hydrated?
The static shell ships with a uniquely-IDed placeholder node for every dynamic hole. The client hydrates everything in the static shell normally, on its own schedule, while the connection for the dynamic chunk stays open in the background. React's streaming parser attaches hydration listeners scoped to each boundary independently — it doesn't treat the page as one atomic hydration unit. When the dynamic RSC payload for a given hole finally arrives, React resolves that sub-fiber alone: it inserts the new markup and hydrates just that boundary, without touching, re-mounting, or tearing any subtree outside it that already finished hydrating. The isolation is per-boundary by construction, which is exactly what makes streaming safe under Suspense rather than a source of hydration races.
Part 4 — Server Actions: what's actually protected, and what isn't
4.1 A Server Action is a public POST endpoint, full stop
A "use server" function is not a private RPC call reachable only from your own UI. It compiles to a real HTTP endpoint with a stable-per-build, opaque ID, callable by anyone who can construct the right request — cURL, a script, a browser extension. Treat it exactly like you'd treat any other unauthenticated-by-default POST route, because that's what it is.
Next.js does provide real, built-in mitigations, and it's worth being precise about what they cover:
- Same-origin verification. Confirmed current against the shipped docs: "To prevent CSRF attacks, Next.js compares the host in a request's
Originheader against the app's own host, taken fromx-forwarded-hostorhost, and rejects the action when the two differ." This has been the default since Server Actions went stable and is unchanged in 16. Two caveats worth stating precisely rather than glossing: the documentation says the mismatched request is "rejected," not literally that it returns HTTP 403 — in practice it surfaces as an "Invalid Server Actions request" error, commonly implemented as a 403, but that's the practical effect, not the exact contract. And a request with noOriginheader at all is currently let through with only a warning, not rejected — a real gap in the default posture, not a hardened wall. There was also a genuine regression worth knowing by name: CVE-2026-27978, affecting Next.js 16.0.1–16.1.6, where an opaqueorigin: null(the value a sandboxed iframe sends) was mishandled as equivalent to "missing" and could bypass the check entirely — patched in a later 16.x release. If you're auditing a project, confirm the installed version is past that range. - Non-guessable, build-rotated action IDs. Actions don't map to predictable URLs; Next.js generates a cryptographic hash per action that changes on every build, so an ID discovered in one deployment's client bundle doesn't work against the next.
- Dead-code elimination. A Server Action that's defined but never actually imported by any Client Component is stripped from the client bundle entirely — it never becomes a callable RPC target in the first place.
None of these three amount to authentication or authorization. They stop a specific class of cross-site request forgery and reduce the action's discoverability; they say nothing about whether the caller is allowed to perform the action at all.
4.2 Authentication belongs inside the action, not just at the perimeter
A redirect issued from proxy.ts (or the old middleware.ts) feels like a security boundary, but it isn't one for Server Actions specifically: a Server Action is invoked directly by client-side JavaScript hitting its dedicated endpoint, and depending on your routing setup, rewrites, edge configuration, or a simple copy-pasted cURL command can reach it without ever passing back through the proxy's own request-handling path the way a page navigation does. Authentication and authorization have to be re-checked inside the action's own function body, every time, treating the proxy layer as a UX optimization (skip the flash of an unauthenticated page) rather than the actual security boundary.
// lib/security/session.ts
import 'server-only';
import { cookies } from 'next/headers';
export interface UserSession {
userId: string;
role: 'admin' | 'editor' | 'user';
}
export async function requireAuth(requiredRole?: 'admin' | 'editor'): Promise<UserSession> {
const cookieStore = await cookies();
const token = cookieStore.get('auth_session')?.value;
if (!token) throw new Error('UNAUTHORIZED: No active session');
const session = await verifyJwtToken(token);
if (!session) throw new Error('UNAUTHORIZED: Invalid or expired session');
if (requiredRole && session.role !== requiredRole && session.role !== 'admin') {
throw new Error('FORBIDDEN: Insufficient administrative privileges');
}
return session;
}
async function verifyJwtToken(token: string): Promise<UserSession | null> {
// Real implementation verifies a signature and expiry against a JWT/session store.
if (token === 'valid_admin_token') return { userId: 'usr-902', role: 'admin' };
return null;
}4.3 Keeping raw server-side models out of the client payload
The concern this section addresses is real and common: a Server Action or an RSC prop that innocently returns { ...user } — spreading a full database row — ships whatever that row contains, including fields no one meant to expose, straight into the client bundle's flight payload. The disciplined fix that works on every React/Next.js version, and the one to lead with in an interview answer, is simply never returning the raw row: define a narrow, explicit shape ({ id, email }, not { ...user }) at the boundary, so there's nothing sensitive to accidentally leak.
React does also ship a pair of opt-in, fail-loud tainting APIs for the same purpose — experimental_taintUniqueValue and experimental_taintObjectReference — which mark a specific value or object reference so that passing it across the serialization boundary throws instead of silently serializing. Be precise about their availability before reaching for them: they exist only as ambient type declarations for React's experimental/canary channel (@types/react/experimental.d.ts), not in the standard index.d.ts a normal import ... from 'react' resolves against — and checked directly on the actual installed [email protected] runtime, both are undefined:
$ node -e "const React = require('react'); console.log(typeof React.experimental_taintUniqueValue, typeof React.experimental_taintObjectReference)"
undefined undefinedThey are not "still experimental but usable" — they are simply not present in the package create-next-app installs today. Don't cite them as an available defense in a stable Next.js 16 project; the load-bearing mitigation is the narrow-return-shape discipline above, not a tainting API you can't currently import.
4.4 A production-shaped, schema-validated action
// lib/actions/product-actions.ts
'use server';
import { z } from 'zod';
import { revalidateTag } from 'next/cache';
import { requireAuth } from '@/lib/security/session';
import type { ReviewActionState } from '@/lib/types/products';
const ReviewSchema = z.object({
productId: z.string().min(1, 'Product ID is required'),
rating: z.coerce.number().min(1).max(5),
comment: z.string().trim().min(5, 'Review must be at least 5 characters long').max(1000),
});
export async function submitReviewAction(
prevState: ReviewActionState,
formData: FormData
): Promise<ReviewActionState> {
try {
// 1. Authorization, evaluated inside the action every time — not just at the proxy
const session = await requireAuth();
// 2. Strict input parsing
const parsed = ReviewSchema.safeParse({
productId: formData.get('productId'),
rating: formData.get('rating'),
comment: formData.get('comment'),
});
if (!parsed.success) {
// Zod 4: the deprecated `.flatten()` method still runs, but z.flattenError is current
return { status: 'error', errors: z.flattenError(parsed.error).fieldErrors };
}
// 3. Execute
await dbInsertReview({ ...parsed.data, userId: session.userId, createdAt: new Date().toISOString() });
// 4. Targeted invalidation — note the now-mandatory second argument
revalidateTag(`product-${parsed.data.productId}`, 'max');
return { status: 'success', errors: {} };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Internal Server Error';
return { status: 'error', errors: { form: [message] } };
}
}
async function dbInsertReview(data: Record<string, unknown>): Promise<void> {
// Persistence logic.
}Senior interview defense — Server Action exploits
Q: An attacker inspects network traffic, extracts a Server Action's endpoint ID, and scripts a replay against it directly. What actually stops them?
Nothing about the ID being "hidden" — it's distributed to every authorized client's JS bundle, so treating it as a secret is a mistake. What actually stops the attack is whatever the action does with the caller's credentials and the request's shape: authenticating inside the action body on every invocation (requireAuth(), not a perimeter check), server-side idempotency keys to reject a duplicated submission, and rate limiting keyed to the authenticated user ID or client IP (a Redis token bucket, for instance). The action ID's build-rotation and non-guessability raise the cost of discovering the endpoint; they do nothing to stop a call once discovered, and the CSRF Origin check only stops a cross-site forgery — a same-origin scripted replay by an authenticated, malicious client sails straight through it.
Part 5 — Authentication: public routes, private routes, and sessions under Cache Components
5.1 Different layouts for public and private sections, via route groups
A route group — a folder wrapped in parentheses, (name) — organizes routes without adding a segment to the URL. Its most common real use is exactly this guide's requirement: giving a marketing/public section and an authenticated/private section two entirely different root layouts, without either segment appearing in the path.
app/
├── (marketing)/
│ ├── layout.tsx # public chrome: marketing nav, footer, no auth check
│ ├── page.tsx # "/"
│ └── pricing/
│ └── page.tsx # "/pricing"
├── (dashboard)/
│ ├── layout.tsx # app chrome: sidebar, user menu, session-aware
│ ├── overview/
│ │ └── page.tsx # "/overview"
│ └── settings/
│ └── page.tsx # "/settings"
└── layout.tsx # true root layout — <html>/<body>, shared by both(marketing) and (dashboard) never appear in the URL — app/(dashboard)/overview/page.tsx serves /overview, not /dashboard/overview. Three caveats from the shipped docs are worth internalising because each one is a real, reproducible failure mode, not a hypothetical:
- A hard reload, not a soft transition, when the root layout changes. Navigating from a route under one group's root layout to a route under a different group's root layout forces a full page reload — this only applies when the groups define genuinely separate root layouts (each with its own
<html>/<body>), not to nested layouts within a shared root. - Colliding paths are a build error.
(marketing)/about/page.tsxand(dashboard)/about/page.tsxboth resolve to/aboutand Next.js refuses to build rather than pick a winner. - Exactly one group needs to own
/. With multiple root layouts and no single top-levellayout.tsx, the home route must live inside one of the groups (e.g.app/(marketing)/page.tsx) — there's no implicit fallback.
5.2 Reading the session correctly under Cache Components
This is where the caching model from Part 3 and authentication genuinely intersect, and it's a pattern that didn't exist in this exact shape before Cache Components: a session read happens at request time by definition — it can't be part of the static shell — so authenticated UI has to stream in behind a <Suspense> boundary, and it's 'use cache: private', not a plain 'use cache', that's allowed to read the session cookie directly.
// lib/session.ts
import 'server-only';
import { cookies } from 'next/headers';
import { sealData, unsealData } from 'iron-session';
export type SessionData = { userId?: string };
const COOKIE_NAME = 'app_session';
const password = process.env.SESSION_PASSWORD!;
export async function getSession(): Promise<SessionData> {
const cookie = (await cookies()).get(COOKIE_NAME)?.value;
if (!cookie) return {};
return unsealData<SessionData>(cookie, { password });
}// lib/auth.ts
import 'server-only';
import { redirect } from 'next/navigation';
import { getSession } from './session';
import { findUserById } from './data';
export type User = { id: string; name: string };
export async function getCurrentUser(): Promise<User> {
'use cache: private';
const { userId } = await getSession();
if (!userId) redirect('/login');
const user = await findUserById(userId);
if (!user) redirect('/login');
return { id: user.id, name: user.name };
}redirect() throws to interrupt rendering rather than returning a value, so a redirect is never itself what gets cached — only a fully resolved, legitimate user is. 'use cache: private' accepts cookies(), headers(), and searchParams, but explicitly not connection() — the private cache is for request-scoped-but-repeatable reads, not for forcing genuinely unique-per-call values.
The component that actually reads the session must sit behind <Suspense> — with Cache Components enabled, reading cookies() outside a boundary is a build error, the same class of error shown for plain 'use cache' in Part 3.6:
// app/(dashboard)/overview/page.tsx
import { Suspense } from 'react';
import { getCurrentUser } from '@/lib/auth';
import { getAnnouncements } from '@/lib/data';
export default function Page() {
return (
<main>
{/* Cached, no session dependency — joins the static shell */}
<Announcements />
{/* Reads the session — streams in behind the boundary on every navigation */}
<Suspense fallback={<p>Loading your dashboard…</p>}>
<Dashboard />
</Suspense>
</main>
);
}
async function Announcements() {
'use cache';
const announcements = await getAnnouncements();
return (
<ul>{announcements.map((a) => <li key={a}>{a}</li>)}</ul>
);
}
async function Dashboard() {
const user = await getCurrentUser();
return <h1>Welcome, {user.name}</h1>;
}Keep the session read out of a layout's top level specifically — an await getCurrentUser() at the top of app/(dashboard)/layout.tsx would hold the entire segment, {children} included, behind that one request-scoped read. Push it down into a leaf component inside its own boundary instead, exactly as in Part 3.7's "push dynamic reads down" rule — this is the same principle, and authentication is its most common real trigger.
5.3 Sharing the resolved user without prop-drilling or re-reading the session
You don't need to call getCurrentUser() again in every component that needs it. Read it once inside the boundary, then hand the still-unresolved Promise<User> down through React context and unwrap it with use() wherever it's needed — including from Client Components, which can consume a promise created on the server this way without an extra client-side fetch:
// app/user-provider.tsx
'use client';
import { createContext, use } from 'react';
import type { ReactNode } from 'react';
import type { User } from '@/lib/auth';
const UserContext = createContext<Promise<User> | null>(null);
export function UserProvider({ userPromise, children }: { userPromise: Promise<User>; children: ReactNode }) {
return <UserContext value={userPromise}>{children}</UserContext>;
}
export function useUser() {
const userPromise = use(UserContext);
if (!userPromise) throw new Error('useUser must be used within a UserProvider');
return use(userPromise);
}function Dashboard() {
const userPromise = getCurrentUser(); // NOT awaited here — handed down unresolved
return (
<UserProvider userPromise={userPromise}>
<Suspense fallback={<span>Loading…</span>}>
<UserBadge />
</Suspense>
</UserProvider>
);
}// app/user-badge.tsx
'use client';
import { useUser } from './user-provider';
export function UserBadge() {
const user = useUser(); // suspends until the promise resolves
return <span>Signed in as {user.name}</span>;
}Because use() suspends the calling component until its promise settles, <UserBadge> needs its own <Suspense> ancestor — but critically, the promise itself was only created once, inside Dashboard's boundary, so every consumer resolves the same underlying session read rather than triggering a new one each.
5.4 Caching data that's derived from — but doesn't require re-reading — the session
Once you have a resolved user, cache the data you fetch for them by extracting just the userId and passing it into a plain 'use cache' function, which keeps the derived result on the server (unlike 'use cache: private', which never leaves the browser) and lets cacheTag invalidate it precisely:
// lib/data.ts
import 'server-only';
import { cacheLife, cacheTag } from 'next/cache';
import { getCurrentUser } from './auth';
export async function getNotes() {
const user = await getCurrentUser();
return getNotesByUserId(user.id); // only the id crosses into the cache scope
}
async function getNotesByUserId(userId: string) {
'use cache';
cacheTag(`notes:${userId}`);
cacheLife('minutes');
return db.query.notes.findMany({ where: (notes, { eq }) => eq(notes.userId, userId) });
}Keep getNotesByUserId unexported. That's not a style nicety — it's what makes this safe: the only way to reach it is through getNotes(), which always resolves the id from the current, authenticated session rather than accepting one as an untrusted argument. If getNotesByUserId were exported and callable with an arbitrary userId, any caller could request any other user's notes by passing a different id — the classic insecure-direct-object-reference bug, except here it's structurally impossible rather than something you have to remember to check for on every call site. This unexported-inner-function shape, with all reads funneled through one authenticated entry point, is the Data Access Layer pattern: one place that resolves and checks identity, everything else trusts it rather than re-implementing the check.
5.5 Defense in depth: proxy.ts is UX, the DAL is the actual boundary
Section 4.2 already made this point for Server Actions specifically; it generalizes to the whole app. A redirect from proxy.ts for an unauthenticated visit to /(dashboard)/* is genuinely useful — it avoids a flash of protected UI and an extra round trip before the real check happens — but it is not the security boundary, for the same reason: a Server Action, a Route Handler, or a direct data-layer call can all be reached by paths that don't necessarily traverse the proxy the same way a full page navigation does. Treat proxy.ts's redirect as the fast, optimistic UX layer, and the Data Access Layer's per-call session check (getCurrentUser(), requireAuth()) as the actual, non-optional authorization boundary — every genuine access check belongs there, checked on every call, not assumed from how the user arrived at the route.
Senior interview defense — auth and route architecture
Q: Why can't you just read the session once in the root layout and pass it down as a prop to every page?
Two independent reasons, both load-bearing. First, under Cache Components a session read is a runtime-only operation — it can't be prerendered — so putting it at the top of a layout forces everything the layout wraps, {children} included, to become a dynamic hole instead of joining the static shell; you lose the performance benefit of Partial Prerendering for the entire section, not just the part that actually needs the session. Second, prop-drilling a session object through every intermediate layout and page couples all of them to that shape and defeats the point of colocating the check with the data access it's meant to protect. The pattern that avoids both — read once inside a <Suspense> boundary as far down the tree as the actual dependency requires, hand the unresolved promise through context, and let each consumer use() it independently — keeps the static shell intact everywhere the session genuinely isn't needed, and keeps the actual authorization check centralized in the Data Access Layer rather than scattered across every route that happens to render user-specific UI.
Part 6 — TanStack Query v5 as the client-side layer over RSC caching
6.1 Why bring a client cache into a framework that already has one
Cache Components (Part 3) solves server-side caching — what gets prerendered, what streams, what's shared across users. It says nothing about client-side concerns TanStack Query is actually built for: background refetching on window focus, optimistic client mutations with automatic rollback, deduplicating identical in-flight requests fired from multiple components, and a client-side cache that survives client-side navigations without a full server round trip. The two are complementary layers, not competing ones — the pattern is prefetching on the server, then handing the result to the client cache so the first render has zero loading state.
Server (RSC execution)
|
+--> Prefetch queries into a server-side QueryClient
+--> dehydrate() the QueryClient's state into a plain serializable object
+--> That object crosses the Flight stream like any other server-to-client prop
|
v
Browser (client hydration)
|
+--> <HydrationBoundary> unpacks it into the browser's QueryCache
+--> Components read data immediately — zero layout shift, zero client spinner
+--> Background staleness timers take over from here, refetching as needed6.2 The server/browser QueryClient singleton — current pattern
@tanstack/react-query is current at 5.103.2 — still v5, no v6 exists. There's a real, newer export worth knowing about but not reaching for here: environmentManager — confirmed present at runtime and via its actual .d.ts — but it is not the getQueryClient() singleton pattern. It's a narrower API for overriding server/client detection in non-standard runtimes (a Service Worker, for instance, where window doesn't exist but the code should still behave like a browser):
declare const environmentManager: {
isServer: () => boolean;
setIsServer(isServerValue: boolean | (() => boolean)): void; // the override hook
};The actual documented singleton pattern still uses the plain, separately-exported isServer boolean function, which environmentManager.isServer itself just delegates to for the common case:
// lib/query/get-query-client.ts
import { QueryClient, isServer } from '@tanstack/react-query';
function makeQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 60 * 1000 }, // avoid an immediate client refetch right after hydrating
},
});
}
// Browser-only singleton — stays `undefined` on the server, so the branch below
// always makes a fresh client per request there instead of reusing this module binding.
let browserQueryClient: QueryClient | undefined;
export function getQueryClient(): QueryClient {
if (isServer) {
// Server: a fresh client per call — see 6.4 for exactly why a shared one is a data leak
return makeQueryClient();
}
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}// lib/query/providers.tsx
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { getQueryClient } from './get-query-client';
export function QueryProviders({ children }: { children: ReactNode }) {
// Calling getQueryClient() directly during render — not inside useState — is the
// documented pattern here, because getQueryClient() is already singleton-safe on
// its own; wrapping it in useState would just be redundant.
const queryClient = getQueryClient();
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}6.3 Prefetch on the server, hydrate on the client — and under Cache Components, behind Suspense
The obvious first version of this pattern — await queryClient.prefetchQuery(...) directly at the top of the page component — actually fails a real next build with cacheComponents: true on, because a prefetchQuery call reaches out to a live data source and that's exactly the class of runtime work Part 3 requires to sit behind a boundary:
Error: Route "/products": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed
outside of `<Suspense>` prevents the route from being prerendered, blocking the page load
and leading to a slower user experience.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"`
- [block] Set `export const instant = false` to allow a blocking routeThe fix — and the version to actually use — pushes the prefetch into its own async Server Component, wrapped in <Suspense> exactly like any other runtime read from Part 3:
// app/products/page.tsx
import { Suspense } from 'react';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query/get-query-client';
import { QueryProviders } from '@/lib/query/providers';
import { ProductCatalogClient } from '@/components/products/ProductCatalog';
import { getProductCatalog } from '@/lib/api/products';
async function PrefetchedCatalog() {
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
queryKey: ['products', 'hardware'],
queryFn: () => getProductCatalog('hardware'),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductCatalogClient category="hardware" />
</HydrationBoundary>
);
}
export default function ProductsPage() {
return (
<QueryProviders>
<main className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Hardware Inventory</h1>
<Suspense fallback={<p>Loading catalog…</p>}>
<PrefetchedCatalog />
</Suspense>
</main>
</QueryProviders>
);
}After this change, the route builds cleanly and shows as ◐ (Partial Prerender) in the route table — the page shell and heading are static, the catalog streams in behind its boundary. This is worth stating as its own rule, separate from Part 3's general one, because it's specifically the shape TanStack Query's own SSR-prefetch documentation doesn't spell out for a Cache Components project: the classic RSC-prefetch-plus-hydrate pattern needs its own <Suspense> boundary under cacheComponents: true — it can no longer sit unwrapped at the top of a page component the way pre-16 material shows it.
// components/products/ProductCatalog.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { getProductCatalog } from '@/lib/api/products';
import type { Product } from '@/lib/types/products';
export function ProductCatalogClient({ category }: { category: string }) {
const { data: products, error, isFetching } = useQuery<Product[]>({
queryKey: ['products', category],
queryFn: async () => {
const res = await fetch(`/api/products?category=${category}`);
return res.json();
},
});
if (error) return <div className="text-red-600">Failed to load catalog.</div>;
return (
<div className="space-y-4">
{isFetching && <span className="text-xs text-blue-500">Updating live inventory...</span>}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{products?.map((product) => (
<div key={product.id} className="border p-4 rounded shadow">
<h3 className="font-semibold text-lg">{product.title}</h3>
<p className="text-gray-600">${product.price.toFixed(2)}</p>
</div>
))}
</div>
</div>
);
}Because the server already prefetched ['products', 'hardware'] and dehydrated it into the HydrationBoundary, the client's useQuery call with the same query key finds a warm cache entry on its very first render — queryFn doesn't actually run client-side at all until staleTime (60 seconds, from the shared client config above) elapses.
6.4 Two caching layers, one invalidation discipline
Running both Cache Components and TanStack Query means two independent caches exist simultaneously: Next.js's server-side 'use cache' store, and TanStack's browser-side QueryCache. Left uncoordinated, a revalidateTag on the server does nothing to the client's already-hydrated QueryCache, and a stale client cache can keep showing old data well past the point the server has fresh data ready to serve. Two disciplines keep them aligned rather than fighting each other: set TanStack's staleTime to roughly match the stale window of the corresponding cacheLife profile so the two caches agree on "how fresh is fresh," and — after any mutation that calls revalidateTag server-side — also call queryClient.invalidateQueries({ queryKey }) client-side inside the same transition, so the browser's cache is told explicitly rather than left to time out on its own separate schedule.
Never store new QueryClient() as a module-scoped variable on the server — this is the mistake the code above deliberately avoids with getQueryClient()'s branch. Both Node.js and Edge server runtimes reuse a warm module scope across multiple concurrent requests within the same process instance; a module-level QueryClient singleton would mean Request A's prefetched data — potentially including per-user data — gets dehydrated into Request B's response. Instantiating fresh inside the Server Component's own execution, per request, is what keeps requests from cross-contaminating each other's cache.
Part 7 — Core Web Vitals, verified against current thresholds
7.1 The three metrics, and what changed recently
As of today, Google's three Core Web Vitals and their "good" thresholds (measured at the 75th percentile of real-user visits) are:
| Metric | Good | Needs improvement | Poor |
|---|---|---|---|
| LCP — Largest Contentful Paint | ≤ 2.5s | 2.5s – 4s | > 4s |
| INP — Interaction to Next Paint | ≤ 200ms | 200ms – 500ms | > 500ms |
| CLS — Cumulative Layout Shift | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
The one genuinely dated fact still floating around in older material: FID (First Input Delay) is not a Core Web Vital anymore. INP fully replaced it as the responsiveness metric in March 2024, and that's still the current model — "INP is the successor metric to First Input Delay," per Google's own current documentation. If a resource still frames FID as a metric you're optimizing for, it's measuring the wrong thing: FID only captured the delay before the first interaction started processing, while INP captures the full duration of every interaction across the page's whole lifetime, which is both a stricter and a more representative measure of real responsiveness.
7.2 INP: task-splitting with scheduler.yield()
A JavaScript task running longer than roughly 50ms blocks the browser's single main thread — no paint, no input handling — until it finishes. That's the entire mechanism behind a poor INP score:
Long task, blocks the frame:
[Interaction] -> [========== 240ms of JS ==========] -> [Paint: INP 240ms, POOR]
Same work, yielded every ~40ms via scheduler.yield():
[Interaction] -> [Task 40ms] -> [Yield/Paint] -> [Task 40ms] -> [Task 40ms] -> ...scheduler.yield() is real and shipped — not behind a flag — in Chrome/Edge 129+ and Firefox 142+. Safari has not implemented it, so it is genuinely not a Baseline-available API (Baseline requires all three major engines), and any production use needs a fallback for Safari users, not just a defensive check:
// components/performance/TaskSchedulerDemo.tsx
'use client';
import { useState } from 'react';
// TypeScript's shipped DOM lib does not yet include Scheduler/scheduler.yield types
// (an open request, microsoft/TypeScript#63016, was still unresolved as of this build) —
// `as any` here is the honest current state, not laziness. A narrower alternative is
// installing `@types/wicg-task-scheduling` and typing against that instead of a blanket cast.
async function yieldToMain(): Promise<void> {
if ('scheduler' in window && 'yield' in (window as any).scheduler) {
await (window as any).scheduler.yield();
} else {
// Safari fallback: a macrotask boundary via setTimeout still yields to the event loop,
// just with lower priority guarantees than the native scheduler API provides.
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
export function HighThroughputProcessor() {
const [progress, setProgress] = useState<number>(0);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const processMassiveDataset = async () => {
setIsProcessing(true);
const items = Array.from({ length: 50000 }, (_, i) => i);
for (let i = 0; i < items.length; i++) {
Math.sqrt(items[i]!) * Math.atan2(items[i]!, 2);
if (i % 250 === 0) {
setProgress(Math.round((i / items.length) * 100));
await yieldToMain(); // give the browser a chance to paint and handle input
}
}
setProgress(100);
setIsProcessing(false);
};
return (
<div className="p-4 border rounded space-y-4">
<button onClick={processMassiveDataset} disabled={isProcessing} className="px-4 py-2 bg-indigo-600 text-white rounded">
{isProcessing ? 'Processing...' : 'Run Heavy Calculation'}
</button>
<div className="w-full bg-gray-200 h-4 rounded">
<div className="bg-indigo-600 h-4 rounded" style={{ width: `${progress}%` }} />
</div>
<span>{progress}% complete</span>
</div>
);
}7.3 LCP: getting the largest element painted early
Three causes account for almost every poor LCP score: a routing waterfall that delays even discovering the hero image, a missing priority/preload hint on an above-the-fold image, and network contention where the LCP image competes with render-blocking scripts for bandwidth.
// components/products/OptimizedHero.tsx
import Image from 'next/image';
export function OptimizedHero({ heroImageUrl, title }: { heroImageUrl: string; title: string }) {
return (
<div className="relative w-full h-[500px] overflow-hidden rounded-xl">
<Image
src={heroImageUrl}
alt={title}
fill
priority // inserts <link rel="preload"> into the document head
fetchPriority="high" // tells the browser's preload scanner to prioritize this byte-for-byte
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px" // stops mobile downloading a 4K asset
className="object-cover"
quality={80}
/>
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<h1 className="text-4xl font-extrabold text-white">{title}</h1>
</div>
</div>
);
}7.4 CLS: reserving space before content exists
Layout shifts happen when an element is inserted into the flow without previously-reserved space, or when a downloaded web font swaps in with different metrics than its fallback. next/font fixes the second case specifically: fetched fonts are self-hosted at build time — confirmed current, "no requests are sent to Google by the browser when the user visits your site" — and the loader synthesizes a fallback @font-face with calculated ascent-override, descent-override, and size-adjust values that size-match the fallback to the real font, so the swap doesn't visibly reflow anything.
// app/layout.tsx
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter' });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body className="antialiased">{children}</body>
</html>
);
}The other common CLS trigger is toggling display: none → display: block on a dynamically-revealed element: it's entirely absent from the layout tree while hidden, so inserting it shoves every following sibling down the page in a single frame. Reserve the space up front instead — a CSS aspect-ratio box, a fixed-height placeholder, or absolute positioning inside an already-sized parent — and animate opacity/visibility rather than display, so the element's footprint exists in the layout the whole time even when it's invisible.
7.5 Bundle size: barrel files and code splitting
Importing a single icon from a library's barrel index (import { Activity } from 'lucide-react') can force the bundler to parse every sibling export in that file if the library has side-effectful modules, even though only one icon is actually used:
// Forces evaluation of the whole barrel index in some bundler configurations
import { Activity } from 'lucide-react';
// Bypasses the barrel entirely — imports exactly one module
import Activity from 'lucide-react/dist/esm/icons/activity';experimental.optimizePackageImports (Part 0.5) automates the second form for a configured package list, rewriting barrel imports into direct paths at compile time — still marked experimental and "not recommended for production" in the current docs, but the default list of packages it covers has grown well beyond icon libraries to include date-fns, lodash-es, @mui/material, recharts, and others.
For genuinely heavy, non-critical-path code — a charting library, a canvas-based visualization — next/dynamic splits it into its own client chunk, loaded only when actually rendered:
'use client';
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(
() => import('@/components/analytics/HeavyChart').then((mod) => mod.HeavyChart),
{ ssr: false, loading: () => <div className="h-[400px] w-full bg-gray-100 animate-pulse rounded" /> }
);
export function DashboardAnalytics() {
return (
<div className="p-6 border rounded-lg">
<h2 className="text-xl font-bold mb-4">Traffic Performance</h2>
<HeavyChart />
</div>
);
}And for genuinely independent applications sharing a domain rather than one monolith, it's worth knowing the trade-off table between the three common multi-app strategies, since it's a recurring staff-level system design question:
+---------------------+-----------------------------------+-------------------------------------+
| Dimension | Module Federation | Multi-Zones (Next.js) |
+---------------------+-----------------------------------+-------------------------------------+
| Runtime | Shared browser runtime | Completely isolated applications |
| Routing | Single client SPA router | Inter-app path routing (e.g. a CDN) |
| Shared state | Global client-side memory | Cookies, Web Storage, token sync |
| Blast radius | High — runtime version conflicts | Low — independent deployments |
| RSC compatibility | Complex; splits Flight parsers | Native — each zone runs full RSC |
+---------------------+-----------------------------------+-------------------------------------+Senior interview defense — Web Vitals diagnostics
Q: Why does toggling display: none to display: block cause a worse CLS score than other layout-shift causes, and how do you fix it structurally rather than case by case?
An element with display: none doesn't just render invisibly — it's removed from the layout algorithm entirely, contributing zero height, so every sibling below it lays out as if it doesn't exist. The instant it flips to display: block, the layout engine has to recompute the position of every subsequent element in the same frame, and that whole shift counts against CLS at once. The structural fix is to never let an element's presence in the layout tree depend on its visibility: reserve its footprint permanently (a min-height placeholder, a CSS aspect-ratio box, or absolute positioning inside an already-sized relative parent) and toggle opacity/visibility instead of display. Done this way, revealing the element is a paint-only change — the layout was already accounting for its space the whole time, so there's nothing left to shift.
Part 8 — Advanced TypeScript for production applications
8.1 any, unknown, and never — precisely, not just "unknown is safer"
These three sit at genuinely different places in the type system, and conflating them is the fastest way to fail a type-system question:
anyopts a value out of type checking entirely — it's not "the type is unknown," it's "stop checking." Every operation on ananyvalue type-checks, including ones that will crash at runtime, and worse,anyis contagious: assigning ananyvalue to a typed variable silently widens that variable toanytoo, unlessnoImplicitAnyand careful typing stop it at the boundary.unknownis the type-safe top type — anything can be assigned to it, but nothing can be read from it without a runtime narrowing check first (typeof,instanceof, a ZodsafeParse, a user-defined type guard). This is exactly whycatch (err: unknown)is the correct modern default overcatch (err: any): you're forced to prove whaterractually is before touching it.neveris the bottom type — no value, not evennullorundefined, is assignable to it exceptneveritself. It's what a function that always throws or never returns is typed to produce, and — its most practically useful role — it's what a correctly-exhaustedswitchover a union collapses to, which is the mechanism behind compiler-enforced exhaustiveness below.
8.2 Discriminated unions with compiler-enforced exhaustiveness
export type Result<TData, TError = Record<string, string[]>> =
| { readonly ok: true; readonly data: TData }
| { readonly ok: false; readonly error: TError };
export function ok<TData>(data: TData): Result<TData, never> {
return { ok: true, data };
}
export function fail<TError>(error: TError): Result<never, TError> {
return { ok: false, error };
}
function renderActionFeedback<T>(result: Result<T>): string {
switch (result.ok) {
case true:
return 'Operation successful.';
case false:
return `Operation failed: ${JSON.stringify(result.error)}`;
default: {
// If a third variant is ever added to the union, `result` here is no longer
// assignable to `never`, and this line fails to compile — the exhaustiveness
// check is a compile error, not a runtime assertion.
const _exhaustiveCheck: never = result;
return _exhaustiveCheck;
}
}
}The mechanism worth being able to state out loud: after handling both case true and case false, TypeScript has narrowed result's type in the default branch down to the empty union — nothing is left it could possibly be — which is exactly never. Assigning it to a never-typed variable only type-checks because there's genuinely nothing left over; add a third state to the Result union without adding a case for it, and that assignment becomes a real type error at the exact place the switch fell out of sync with its own type, not a runtime surprise three files away.
8.3 Mapped types and key remapping
// Extracts the subset of keys whose value type extends Condition
export type FilterFlags<Base, Condition> = {
[Key in keyof Base]: Base[Key] extends Condition ? Key : never;
}[keyof Base];
export type SubType<Base, Condition> = Pick<Base, FilterFlags<Base, Condition>>;
// Key remapping with the `as` clause — generates a differently-named key per original key
export type AsGetters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => Promise<T[K]>;
};
// AsGetters<{ name: string; age: number }> becomes
// { getName: () => Promise<string>; getAge: () => Promise<number> }
export type EntityValidators<T> = {
[K in keyof T]: (value: T[K]) => boolean | string;
};8.4 The built-in utility types, reimplemented — because understanding them beats memorizing them
An interviewer asking "implement Partial yourself" isn't testing whether you've memorized the standard library — they're checking whether you actually understand mapped types and conditional types, or just know the utility names.
// Partial<T>: every property becomes optional
type MyPartial<T> = { [K in keyof T]?: T[K] };
// Required<T>: every property becomes mandatory — the `-?` removes an optional modifier
type MyRequired<T> = { [K in keyof T]-?: T[K] };
// Readonly<T>: every property becomes immutable
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
// Pick<T, K>: keep only the named keys
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
// Omit<T, K>: drop the named keys — implemented in terms of Pick and Exclude
type MyExclude<T, U> = T extends U ? never : T;
type MyOmit<T, K extends keyof any> = MyPick<T, MyExclude<keyof T, K>>;
// Record<K, V>: a dictionary type from a union of keys to one value type
type MyRecord<K extends keyof any, V> = { [P in K]: V };Omit's implementation is worth narrating specifically, because it's the one that actually chains two other utilities rather than being its own primitive: it computes keyof T minus the keys to omit (via Exclude, itself a conditional type distributing over a union), then Picks exactly that remaining key set. There's no special-cased "delete a key" mechanism in the type system — Omit is built entirely from keyof, conditional-type distribution, and Pick.
8.5 Conditional types, infer, and template literal types
// Unpacks the resolved type from a Promise, or from a function that returns one, recursively
export type DeepAwaited<T> = T extends Promise<infer R>
? DeepAwaited<R>
: T extends (...args: any[]) => Promise<infer R>
? DeepAwaited<R>
: T;
// A type-level parser: extracts Next.js-style [param] segments from a route string
export type ExtractRouteParams<T extends string> =
T extends `${string}[${infer Param}]/${infer Rest}`
? Param | ExtractRouteParams<Rest>
: T extends `${string}[${infer Param}]`
? Param
: never;
// Resolves to the literal union "orgId" | "productId" — verified by hovering the type in an editor
type StoreParams = ExtractRouteParams<'/organizations/[orgId]/products/[productId]'>;infer is the piece worth explaining precisely: inside a conditional type's extends clause, infer Param doesn't just check a shape — it introduces a new type variable, bound to whatever structurally matched that position, and makes it available in the conditional's "true" branch. ExtractRouteParams recurses because each match only peels off one segment at a time (Param) and hands the rest of the string (Rest) to itself, exactly like a recursive function operating on an array — except the entire computation happens at compile time, over string literal types, and produces a union type as its "return value."
8.6 Branded (nominal) types
TypeScript's type system is structural by default — two types with the same shape are interchangeable, even if they represent conceptually different things. A raw string used for both a UserId and an OrderId type-checks perfectly if you accidentally swap them at a call site. Branding closes that gap by attaching a phantom property that only exists at the type level, never at runtime:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function asUserId(raw: string): UserId {
return raw as UserId; // the one place the cast is allowed — a validated construction boundary
}
function getUser(id: UserId) { /* ... */ }
const orderId = 'ord_123' as OrderId;
// getUser(orderId); // Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.At runtime, __brand never exists — UserId and OrderId compile down to plain strings, so there's no performance cost — but the compiler treats them as distinct types, and the only legitimate way to produce one is through a constructor function you control, which is where you'd put real validation (format checking, a database existence check) rather than an unchecked cast scattered across the codebase.
8.7 The satisfies operator
export interface CacheProfile {
stale: number;
revalidate: number;
expire: number;
}
// Validated against CacheProfile's shape, but literal types are preserved rather than widened
export const cacheProfiles = {
instant: { stale: 30, revalidate: 60, expire: 300 },
hourly: { stale: 300, revalidate: 3600, expire: 86400 },
immutable: { stale: 86400, revalidate: 604800, expire: 2419200 },
} satisfies Record<string, CacheProfile>;
// typeof cacheProfiles.instant.stale is the literal type 30, NOT the general type `number` —
// this is exactly what an `: Record<string, CacheProfile>` type annotation would NOT give you;
// an annotation widens every value to CacheProfile's declared field types immediately.
type InstantStaleTime = typeof cacheProfiles.instant.stale;The distinction that actually matters here, stated directly: a type annotation (const cacheProfiles: Record<...> = {...}) checks the shape and then the variable's type genuinely becomes that annotation, discarding the more specific literal information. satisfies checks the same shape constraint but leaves the inferred type exactly as narrow as the literal expression actually was. That distinction is what lets cacheProfiles.instant.revalidate be usable later somewhere that specifically expects the literal 60, not just any number — while next.config.ts, route configuration objects, and other places Next.js validates against a fixed union benefit from exactly this kind of narrowing.
8.8 as const versus satisfies, on the same object
They solve adjacent but different problems, and conflating them is a common near-miss:
// A minimal stand-in for the shape Next.js's own route segment config expects —
// the real one is generated per-project, not a single importable named type.
interface RouteSegmentConfig {
revalidate: number | false;
dynamic: 'auto' | 'force-dynamic' | 'error' | 'force-static';
}
const routeConfig = { revalidate: 3600, dynamic: 'force-static' } as const;
// Every property becomes deeply readonly AND every value narrows to its literal type.
// But: no shape validation happened — a typo in `dynamic` here would silently type-check.
const routeConfig2 = { revalidate: 3600, dynamic: 'force-static' } satisfies RouteSegmentConfig;
// Shape-validated against RouteSegmentConfig — a typo in `dynamic` is now a real compile error —
// AND literal types are still preserved. Mutability is unaffected either way.as const is purely an inference instruction with no validation attached; satisfies is purely a validation instruction that happens to preserve the same narrow inference as const would give you. For a config object that must match a framework-defined shape — which is most of what appears in next.config.ts — satisfies is strictly the better tool, because it catches the typo as const would let straight through.
8.9 Function overloads and generic variance, briefly
// Overload signatures narrow the return type based on the argument's literal type —
// this is exactly the shape behind useActionState's two real overloads shown in Part 1.2.
function createElement(tag: 'a'): HTMLAnchorElement;
function createElement(tag: 'img'): HTMLImageElement;
function createElement(tag: string): HTMLElement;
function createElement(tag: string): HTMLElement {
return document.createElement(tag);
}
const link = createElement('a'); // typed HTMLAnchorElement, not the general HTMLElementGeneric variance is worth being able to state in one sentence, since it explains why a function parameter type check sometimes feels backwards: TypeScript function parameters are checked bivariantly in method syntax and contravariantly for standalone function types under strictFunctionTypes — meaning a function expecting a narrower parameter type is not safely substitutable where a function expecting a wider one is expected (a function that only knows how to handle a Cat can't stand in for one that promises to handle any Animal), which is the opposite direction from how return types behave (covariantly — a function promising to return a Cat safely substitutes for one promising an Animal).
8.10 Module augmentation and declaration merging
A real, common production need: extending a third-party library's types without forking it — for instance, adding a custom field to next-auth's Session type, or extending NodeJS.ProcessEnv so process.env.DATABASE_URL is typed instead of string | undefined.
// types/env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
SESSION_PASSWORD: string;
}
}
}
export {}; // an empty export turns this file into a module, which is required for `declare global` to work
// Anywhere else in the project:
process.env.DATABASE_URL; // now typed `string`, not `string | undefined`This works because TypeScript merges multiple interface declarations with the same name in the same scope into one combined shape, rather than the second declaration overwriting the first the way it would for a type alias (which doesn't support merging at all — declaring type Foo twice in the same scope is a duplicate-identifier error). This is exactly the mechanism most auth and CMS libraries rely on for their own "extend our session type in your app" documentation.
Senior interview defense — TypeScript soundness
Q: as const versus satisfies — when would you reach for one and not the other, on the same object literal?
as const only affects inference: it removes literal-type widening and makes every property deeply readonly, with zero validation against any external shape — a typo in a property name or an invalid enum-like string value type-checks just fine. satisfies does the opposite trade: it validates the literal against a named type (catching that same typo as a compile error) while still preserving the literal's narrow inferred type, exactly as as const would. For a configuration object that has to conform to a framework-defined contract — a next.config.ts export, a route segment config, a cacheProfiles map like Part 8.7's — satisfies is the correct default, because the whole point is catching a shape mismatch at the point of authorship rather than at whatever call site later fails to find the expected literal value.
8.11 JavaScript underneath the types: coercion
TypeScript's type system disappears entirely at runtime — the JavaScript underneath still coerces the way it always has, and a senior interview will probe whether you know the actual rules rather than just avoiding == by habit.
$ node
> [] + []
''
> [] + {}
'[object Object]'
> {} + []
0
> '5' - 1
4
> '5' + 1
'51'
> null == undefined
true
> null === undefined
false
> NaN === NaN
false
> Object.is(NaN, NaN)
true
> 0.1 + 0.2
0.30000000000000004Three of these are worth being able to explain, not just recite: [] + [] triggers ToPrimitive on both operands with a "default" hint — arrays have no [Symbol.toPrimitive], so it falls back to toString(), and [].toString() is '', giving '' + '' = ''. {} + [] in a statement position (a bare expression at the start of a line) parses {} as an empty block statement, not an object literal, so the line is actually +[], unary-plus coercing [] to the number 0 — the exact same tokens inside parentheses, ({} + []), parse as addition and produce '[object Object]' instead, which is a genuinely good "spot the difference" interview question. And 0.1 + 0.2 !== 0.3 isn't a JavaScript bug at all — it's IEEE 754 double-precision floating point, the same representation nearly every mainstream language uses, and the fix in production code is never comparing floats for exact equality; compare against an epsilon, or work in integer cents/minor units for money.
8.12 Scope, closures, and the temporal dead zone
function makeCounters() {
const counters = [];
for (var i = 0; i < 3; i++) {
counters.push(() => i);
}
return counters;
}
makeCounters().map((fn) => fn()); // [3, 3, 3] — every closure shares the SAME `i`
function makeCountersLet() {
const counters = [];
for (let i = 0; i < 3; i++) {
counters.push(() => i);
}
return counters;
}
makeCountersLet().map((fn) => fn()); // [0, 1, 2] — a fresh binding per iterationvar is function-scoped: there is exactly one i for the whole loop, and every closure created inside the loop body captures that same single variable, which has finished counting up to 3 by the time any of the closures actually run. let is block-scoped and the spec gives for (let ...) a special rule: a fresh binding is created for each iteration, copying the previous iteration's value forward — so each closure captures a genuinely distinct variable frozen at its own iteration's value.
The temporal dead zone is the other half of let/const's behaviour worth stating precisely: both are hoisted to the top of their block, exactly like var is — but unlike var, which initializes to undefined immediately, let/const stay in an uninitialized, unreadable state from the top of the block until their declaration line actually executes. Reading them before that point doesn't return undefined — it throws:
$ node -e "console.log(x); let x = 5;"
[eval]:1
console.log(x); let x = 5;
^
ReferenceError: Cannot access 'x' before initializationThat's a materially different failure mode than var's silent undefined, and it's specifically what makes let/const safer to reason about — a bug that var would let run silently with a wrong value becomes an immediate, loud crash instead.
8.13 The event loop: microtasks, macrotasks, and paint
console.log('1: sync');
setTimeout(() => console.log('4: macrotask'), 0);
Promise.resolve().then(() => console.log('3: microtask'));
console.log('2: sync');
// Actual order: 1, 2, 3, 4The rule that produces this order, stated as a mechanism rather than memorized: after each single macrotask finishes running (a script's initial synchronous run counts as one; each setTimeout callback is another), the event loop drains the entire microtask queue — every Promise continuation, including ones newly queued by other microtasks that ran during the same drain — before it's allowed to move on to the next macrotask or to a paint. That's why a synchronously-resolved promise's .then() always runs before even a setTimeout(fn, 0), no matter how the code is ordered on the page: .then() schedules a microtask, setTimeout schedules a macrotask, and microtasks always fully drain first. It's also why a runaway chain of promises that keep re-scheduling more microtasks — Promise.resolve().then(function loop() { return Promise.resolve().then(loop); }) — can starve rendering entirely: the browser doesn't get a chance to paint until the microtask queue is actually empty, and an infinite one never empties.
8.14 The prototype chain, briefly
const arr = [1, 2, 3];
arr.map; // found via arr -> Array.prototype -> Object.prototype -> null
Object.getPrototypeOf(arr) === Array.prototype; // true
Object.getPrototypeOf(Array.prototype) === Object.prototype; // true
Object.getPrototypeOf(Object.prototype); // null — the chain terminates hereEvery property lookup that misses on the object itself walks up this chain — arr.map isn't a property on arr; it's found on Array.prototype, which every array instance is linked to via its internal [[Prototype]]. class syntax is sugar over exactly this mechanism: class Dog extends Animal sets Dog.prototype's internal prototype to Animal.prototype, so instance methods defined on Animal remain reachable from a Dog instance through the same chain-walk arr.map used — there's no separate "class" runtime concept underneath; it's prototypal delegation the whole way down.
Part 9 — Advanced CSS, and Tailwind v4
9.1 Cascade layers: controlling specificity without a specificity war
@layer lets you declare an explicit priority order for groups of rules, independent of selector specificity — a rule in an earlier-declared layer loses to a rule in a later-declared layer even if the earlier rule's selector is more specific. This is the mechanism that finally makes "a utility class always wins over a component's own base styles" a structural guarantee instead of a specificity arms race:
@layer reset, base, components, utilities;
@layer base {
.card { padding: 1rem; border-radius: 0.5rem; }
}
@layer utilities {
.p-0 { padding: 0; } /* wins over .card's padding despite lower specificity, because
`utilities` was declared after `base` */
}The declaration order of @layer reset, base, components, utilities; — not the source order of the rules inside each layer — is what decides precedence between layers; only when two rules land in the same layer (or neither is layered at all) does ordinary specificity and source order resolve the conflict, exactly as it always has. Unlayered styles form an implicit final layer that beats every named layer, which is worth knowing specifically because it means a stray unlayered override can still silently beat your entire layer system.
9.2 Container queries: responsive to a parent, not the viewport
Media queries respond to the viewport. Container queries respond to the size of an actual ancestor element — the correct primitive for a component (a card, a widget) that needs to look different depending on how much space its container gives it, independent of the overall page width, e.g. the same card rendered narrow in a sidebar and wide in a main content area.
.card-container { container-type: inline-size; container-name: card; }
@container card (min-width: 400px) {
.card { grid-template-columns: 120px 1fr; } /* switch to a side-by-side layout */
}container-type: inline-size is what actually opts the element into being a query target — without it, @container rules targeting that ancestor simply never match.
9.3 :has() — a real parent selector
:has() matches an element based on its descendants or following siblings, which CSS had no way to express at all before this shipped — closing a gap that used to require JavaScript purely to toggle a class:
/* Style a form group as invalid if it contains an invalid input — no JS needed */
.form-group:has(input:invalid) { border-color: red; }
/* Style a card differently only when it has an image */
.card:has(img) { grid-template-columns: 100px 1fr; }
/* A sibling-combinator use: style a label that's immediately followed by a checked checkbox */
label:has(+ input:checked) { font-weight: 700; }9.4 Subgrid: aligning nested grids to their parent's tracks
Without subgrid, a card inside a grid layout can't align its own internal columns to the parent grid's tracks — each nested grid defines its own independent track sizing, so columns across sibling cards drift out of alignment the moment their content differs. grid-template-columns: subgrid tells a nested grid to reuse its parent's already-computed tracks instead of computing its own:
.gallery { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.gallery-item {
display: grid;
grid-column: span 1;
grid-template-rows: subgrid; /* inherits the parent's row tracks */
grid-row: span 3;
}This is specifically what makes a grid of cards with independently-sized headers, bodies, and footers align across cards — each card's internal rows lock to the same shared track sizes as every sibling card's, rather than each card sizing its rows independently based on its own content.
9.5 Tailwind v4: CSS-first configuration, verified against the real scaffolded output
Tailwind v4 is a genuine rewrite of the tool's configuration model, and it's current — create-next-app --tailwind scaffolds v4, not the v3 you'll see in older tutorials. The most consequential change: there is no tailwind.config.js/.ts file at all by default. Confirmed by scaffolding a real project and searching for one — none exists. Configuration instead lives directly in CSS, and the PostCSS plugin package itself changed name.
postcss.config.mjs, generated verbatim:
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;In v3 the PostCSS plugin was the tailwindcss package itself, paired with a separate autoprefixer entry. In v4, it's the distinct @tailwindcss/postcss package, and vendor-prefixing is handled internally — there's no autoprefixer dependency to add at all.
app/globals.css, generated verbatim:
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}@import "tailwindcss" pulls in the engine (replacing v3's three separate @tailwind base/components/utilities directives). @theme inline specifically is for tokens that need to reference another CSS custom property rather than hold a literal value directly — here, --color-background points at --background, which itself is swapped by the dark-mode media query above, and --font-sans points at --font-geist-sans, a variable next/font sets at runtime. A plain @theme block (no inline) is for genuinely static, compile-time-only tokens — the more common case for a project's own custom design tokens:
@theme {
--color-brand: oklch(0.58 0.2 265);
--color-brand-dark: oklch(0.48 0.2 265);
--font-display: "Geist", "Inter", sans-serif;
--spacing-gutter: 1.75rem;
}This alone — no theme.extend block, no build step beyond the normal one — generates real, usable utility classes: bg-brand, text-brand, border-brand, ring-brand, bg-brand-dark, font-display, p-gutter, gap-gutter. Tailwind v4 derives the utility set directly from the CSS custom property namespace (--color-*, --font-*, --spacing-*) rather than from a JavaScript config object being walked and merged.
9.6 A real design-system primitive: CVA + Tailwind Merge
For a component library built on utility classes, class-variance-authority (CVA) is the standard way to express variant/size combinations as a typed API instead of string concatenation, and tailwind-merge resolves conflicting utility classes (e.g. a consumer passing className="p-8" that should override a default p-4) deterministically rather than leaving both in the class string and hoping CSS source order wins:
// lib/cn.ts
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}// components/button.tsx
import { type VariantProps, cva } from 'class-variance-authority';
import type { ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-brand text-white hover:bg-brand-dark focus-visible:ring-brand',
secondary: 'bg-zinc-100 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-50',
destructive: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
}
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}VariantProps<typeof buttonVariants> derives { variant?: 'primary' | 'secondary' | 'destructive'; size?: 'sm' | 'md' | 'lg' } directly from the cva() call — the variant keys are a single source of truth shared between the runtime class-generation logic and the component's own prop types, so adding a new variant to buttonVariants automatically updates ButtonProps without a second declaration to keep in sync. This whole file builds clean against next build with zero warnings, confirmed against the real scaffolded app.
Part 10 — Advanced DOM APIs in React 19
10.1 IntersectionObserver: a real infinite-scroll trigger
Polling getBoundingClientRect() on a scroll listener to detect when an element enters the viewport is expensive — it forces a synchronous layout recalculation on every scroll event. IntersectionObserver does the same job asynchronously, off the main thread's scroll-handling critical path:
// components/infinite-scroll-trigger.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
interface InfiniteScrollTriggerProps {
onLoadMore: () => void;
disabled?: boolean;
}
export function InfiniteScrollTrigger({ onLoadMore, disabled = false }: InfiniteScrollTriggerProps) {
const sentinelRef = useRef<HTMLDivElement | null>(null);
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const node = sentinelRef.current;
if (!node || disabled) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
setIsIntersecting(entry.isIntersecting);
if (entry.isIntersecting) onLoadMore();
},
{ rootMargin: '200px', threshold: 0 } // fire 200px before the sentinel is actually visible
);
observer.observe(node);
return () => observer.disconnect(); // always tear down — an un-disconnected observer leaks
}, [onLoadMore, disabled]);
return <div ref={sentinelRef} data-intersecting={isIntersecting} aria-hidden className="h-px w-full" />;
}The useEffect cleanup function isn't optional ceremony here: without observer.disconnect(), an observer created on mount keeps watching a DOM node after the owning component unmounts, holding a reference that prevents that subtree from being garbage collected — a real, observable memory leak in a long-lived SPA-style session with many mount/unmount cycles (a virtualized list, a modal opened and closed repeatedly).
10.2 ResizeObserver: measuring an element without a resize listener on window
A window.resize listener only fires on viewport resizes — it says nothing about an individual element changing size for its own reasons (a CSS resize: both handle, a parent flex/grid reflow, content changing). ResizeObserver watches the element itself:
// components/measured-box.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
interface Size { width: number; height: number }
export function MeasuredBox({ children }: { children?: React.ReactNode }) {
const boxRef = useRef<HTMLDivElement | null>(null);
const [size, setSize] = useState<Size | null>(null);
useEffect(() => {
const node = boxRef.current;
if (!node) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
// borderBoxSize accounts for padding/border; contentRect does not — pick deliberately
const { inlineSize: width, blockSize: height } =
entry.borderBoxSize[0] ?? { inlineSize: entry.contentRect.width, blockSize: entry.contentRect.height };
setSize({ width, height });
});
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<div ref={boxRef} className="resize overflow-auto border border-dashed p-4">
{children}
<p className="mt-2 text-xs text-zinc-500">
{size ? `${Math.round(size.width)}×${Math.round(size.height)}px` : 'measuring…'}
</p>
</div>
);
}Both components above type-check cleanly under this project's "lib": ["DOM", "DOM.Iterable", "ESNext"] tsconfig.json setting — IntersectionObserverEntry, ResizeObserverEntry, and the newer borderBoxSize array member are all present in the shipped lib.dom.d.ts, unlike scheduler.yield from Part 7.2, which currently needs a manual type workaround. Not every DOM API a browser ships is typed yet; checking with a real tsc --noEmit run rather than assuming is the durable habit, not the specific list of which APIs happen to be typed today.
10.3 MutationObserver, event delegation, and passive listeners — the remaining primitives
MutationObserver watches a subtree for DOM changes injected by code you don't control — third-party widgets, browser extensions, content-editable regions — and is the right tool specifically when you can't intercept the mutation at its source:
useEffect(() => {
const node = containerRef.current;
if (!node) return;
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
// react to externally-injected nodes
}
}
});
observer.observe(node, { childList: true, subtree: true });
return () => observer.disconnect();
}, []);Event delegation — attaching one listener to a common ancestor instead of one listener per child — matters less for raw performance in React than it used to in vanilla DOM code, because React's own synthetic event system already delegates internally (a single listener per event type is attached at the root, not one per element). It still matters for a list that mounts and unmounts hundreds of rows dynamically: delegating your own imperative listener to a stable container, rather than attaching/detaching it on every row mount, avoids that churn entirely.
Passive listeners — { passive: true } on a scroll/touch listener — tell the browser up front that the handler will never call preventDefault(), which lets the browser start scrolling immediately instead of waiting for the handler to finish running first. This is a real, measurable input-latency win on any scroll or touch handler that doesn't need to block the default action, and costs nothing to add:
useEffect(() => {
const handler = () => { /* read-only scroll tracking, never preventDefault */ };
window.addEventListener('scroll', handler, { passive: true });
return () => window.removeEventListener('scroll', handler);
}, []);Part 11 — SEO file conventions, verified against a real build
11.1 generateMetadata, and why it needs generateStaticParams under Cache Components
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
interface PageProps { params: Promise<{ slug: string }> }
async function getArticle(slug: string) {
return { slug, title: `Article: ${slug}`, excerpt: `A demo article for "${slug}".` };
}
// Required under Cache Components for a dynamic segment with no Suspense wrapper —
// without it, the build fails (see below).
export async function generateStaticParams() {
return [{ slug: 'hello-world' }, { slug: 'second-post' }];
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const article = await getArticle(slug);
return {
title: article.title,
description: article.excerpt,
openGraph: { title: article.title, description: article.excerpt, type: 'article' },
alternates: { canonical: `/blog/${slug}` },
};
}
export default async function ArticlePage({ params }: PageProps) {
const { slug } = await params;
const article = await getArticle(slug);
return (
<article className="p-8">
<h1>{article.title}</h1>
<p>{article.excerpt}</p>
</article>
);
}A dynamic route that reads params with no generateStaticParams and no <Suspense> boundary fails a Cache Components build with exactly the same class of error seen twice already in this guide:
Error: Route "/blog/[slug]": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed
outside of `<Suspense>` prevents the route from being prerendered...Adding generateStaticParams() is the natural fix here specifically — not a workaround — because SEO content is exactly the case where you want your real, known slugs prerendered into the static shell rather than streamed at request time; a slug outside the static list still gets a Partial-Prerendered fallback shell rather than failing.
One more real requirement worth setting up once and forgetting: metadataBase in the root layout. Without it, next build emits a warning every time generateMetadata's openGraph fields or an opengraph-image route are present, because relative image URLs in Open Graph metadata need an absolute base to resolve against:
// app/layout.tsx
export const metadata = {
metadataBase: new URL('https://your-production-domain.com'),
};11.2 sitemap.ts and robots.ts — file conventions, not manual routes
// app/sitemap.ts
import type { MetadataRoute } from 'next';
const BASE_URL = 'https://your-production-domain.com';
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: BASE_URL, lastModified: new Date(), changeFrequency: 'weekly', priority: 1 },
{ url: `${BASE_URL}/blog`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
];
}// app/robots.ts
import type { MetadataRoute } from 'next';
const BASE_URL = 'https://your-production-domain.com';
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: '/api/' },
sitemap: `${BASE_URL}/sitemap.xml`,
};
}Both compile to real, working /sitemap.xml and /robots.txt routes with zero additional routing code — confirmed in a real build's route table. One genuinely worth flagging rather than assuming: /sitemap.xml builds as ƒ (fully dynamic, server-rendered on demand) even with no dynamic API calls inside sitemap() at all, while /robots.txt and an opengraph-image route both build as ○ (fully static) under the exact same conditions. Don't assume a file-convention route is static just because its own code contains no obviously dynamic calls — check the actual route table.
11.3 Open Graph images with next/og
// app/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const alt = 'Your App';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function Image() {
return new ImageResponse(
(
<div
style={{
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(135deg, #18181b 0%, #3f3f46 100%)',
color: 'white', fontSize: 64, fontWeight: 700,
}}
>
Your App
</div>
),
{ ...size }
);
}ImageResponse renders JSX to a PNG at request time (or at build time, for a statically-determinable route) using a constrained subset of CSS via flexbox layout — it's not a full browser rendering engine, so layout primitives outside flexbox (CSS grid, most positioning tricks) aren't available inside it. The exported alt, size, and contentType constants are themselves part of the file convention — Next.js reads them to populate the corresponding <meta> tags automatically, without you writing generateMetadata boilerplate to wire them up by hand.
Part 12 — Verification checklist, and the build that proves it
Everything in this guide was run against a real create-next-app@latest scaffold, not reconstructed after the fact. The final route table, from a clean build with every section's code in place:
▲ Next.js 16.3.6 (Turbopack)
✓ Running next.config.ts took 61ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 3.1s
Running TypeScript ...
Finished TypeScript in 1638ms ...
Collecting page data using 9 workers ...
✓ Generating static pages using 9 workers (15/15) in 463ms
Finalizing page optimization ...
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ○ /opengraph-image
├ ○ /robots.txt
├ ƒ /sitemap.xml
├ ◐ /test-auth 15m 1y
├ ○ /test-button
├ ○ /test-cache 1m 1h
├ ○ /test-observers
├ ◐ /test-query
└ /test-seo/[slug]
├ ◐ /test-seo/[slug]
├ ○ /test-seo/hello-world
└ ○ /test-seo/second-post
ƒ Proxy (Middleware)
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demandBefore calling any of this production-ready, run through the checklist below — every item is something this guide's own build actually caught at least once along the way, not a hypothetical:
-
npx tsc --noEmitpasses with zero errors — including everycookies()/headers()/params/searchParamscall actuallyawaited (Part 0.4, 3.2). -
cacheComponents: trueis a top-levelnext.config.tskey, not nested underexperimental(Part 0.5). -
partialPrefetching(if used) is also top-level, andcacheComponentsis set — it fails the build otherwise (Part 0.5). - The project has
proxy.ts, notmiddleware.ts— and never both at once, which is a hard build error (Part 0.6). - Every
revalidateTag(...)call passes a second argument — the old one-argument form is aTS2554compile error now (Part 3.5). - Every component reading
cookies(),headers(), un-cachedfetch, orconnection()sits behind its own<Suspense>boundary — including a TanStack Query prefetch and any dynamic-segmentgenerateMetadata/page that isn't fully covered bygenerateStaticParams(Parts 3.7, 6.3, 11.1). - No
cookies()/headers()call inside a plain'use cache'function — use'use cache: private'or pass the derived value in as an argument instead (Parts 3.2, 5.2). - Every Server Action re-checks authentication and authorization inside its own body — a
proxy.tsredirect is UX, not the security boundary (Parts 4.2, 5.5). - No return value from a Server Action or RSC prop spreads a raw database row — return an explicit, narrow shape rather than relying on an unavailable tainting API (Part 4.3).
-
zod,@tanstack/react-query, andtailwindcssare pinned to their actual current majors (4.x, 5.103.x, 4.x respectively as of this build) and their v3/v4-breaking APIs (z.flattenError,isServer,@theme) are used, not the deprecated forms (Parts 4.4, 6.2, 9.5). -
next buildoutput is read, not assumed — specifically the○/◐/ƒmarker next to every route, since a route can be dynamic for reasons that aren't obvious from its own source code (Part 11.2's/sitemap.xmlcase).
Closing the loop
Everything above is one build, verified once, on one day. The framework will have moved again by the time this is read widely — a future Next.js release could rename something else, a cacheLife default could shift, React could stabilize the tainting APIs Part 4.3 found unavailable today. Don't treat any specific fact here as permanent; treat the method as the actually transferable skill for an interview room where you won't have a search engine open: read the shipped .d.ts files and node_modules/next/dist/docs before trusting a remembered API shape, write the smallest possible repro and run tsc --noEmit or next build against it before asserting a behavior, and say "let me verify that" out loud rather than confidently stating something you last confirmed two major versions ago. That habit is what actually separates a senior engineer's answer from a plausible-sounding one — and it's the one thing in this guide that won't go stale.