Full-Stack Questions

Next.js Interview Questions

App Router, Server and Client Components, caching, Server Actions, proxy, rendering modes and deployment.

Lesson 0534 min read#nextjs

On this page

  1. Words used in this lesson
  2. App Router vs Pages Router
  3. Server Components and Client Components
  4. Rendering modes in Next.js 16
  5. Caching in Next.js 16
  6. Dynamic params are Promises
  7. Route Handlers or Server Actions?
  8. Server Actions and security
  9. Proxy (formerly middleware)
  10. Streaming, loading.tsx and status codes
  11. Metadata, images, fonts and scripts
  12. Environment variables
  13. Authentication patterns
  14. ISR with Cache Components
  15. Edge or Node, and where to deploy
  16. Bundle size
  17. Errors, 404, i18n and testing
  18. Migration pitfalls (14 or 15 to 16)
  19. Code review table
  20. Interview questions
  21. Key takeaways
  22. What's next

In one line: Next.js is React plus a server. Every interview question comes back to one idea: for each piece of your page, decide where it runs (server or browser) and when it is made (at build time, at request time, or from a cache). Next.js 16 changed the answer to "when" a lot. Data fetching is now dynamic by default, and you opt in to caching with use cache. Old tutorials say the opposite, so this is where candidates lose points.

This lesson is about Next.js 16 and the App Router. For the React ideas underneath (Server Components, Suspense, streaming, hydration) see Frontend Depth, Lesson 02. For HTTP caching headers see Browser, Lesson 05. For login, sessions and cookies see Backend, Lesson 06. For React questions see Lesson 04. I read the Next.js docs that ship with version 16.3 and ran a small app on version 16.4 (Turbopack, cacheComponents on, output: "standalone"). Where a sentence says "I tested", it is real output from that app.

Words used in this lesson

  • The App Router is the routing system in the app/ folder, built on React Server Components. The Pages Router is the older system in the pages/ folder (getServerSideProps, getStaticProps).
  • A Server Component is a React component that runs only on the server and sends its result, not its code, to the browser. A Client Component is a component marked with "use client" that also runs in the browser, so it can use state, effects and browser APIs.
  • The RSC payload is the compact description of the rendered Server Component tree that Next.js sends so React can update the page.
  • Prerendering is making HTML ahead of the request (at build time). The static shell is the part of a page that is known at build time, such as the layout and the fallbacks of <Suspense>.
  • Partial Prerendering (PPR) is the default way Next.js renders a page when cacheComponents is on: a static shell is served at once, and the dynamic parts stream in afterwards.
  • Cache Components is the Next.js 16 caching model, turned on with cacheComponents: true. Its main tool is the use cache directive.
  • A cache tag is a label you attach to cached data so that you can throw that data away later.
  • Stale-while-revalidate means: serve the old copy now, and fetch a fresh copy in the background.
  • A Server Action is an async function marked "use server" that the browser can call, usually from a form. Next.js turns it into a POST request.
  • Proxy (file proxy.ts) is code that runs before a request reaches your routes. It was called "middleware" before version 16.
  • ISR (Incremental Static Regeneration) is rebuilding a static page in the background after it was first built.

App Router vs Pages Router

Pages Router (pages/)App Router (app/)
ComponentsClient Components (they also render on the server first)Server Components by default
DatagetServerSideProps, getStaticProps, getStaticPathsasync components, fetch or any database call, use cache
Layouts_app.tsx, _document.tsx, per-page layout codeNested layout.tsx files that do not re-render on navigation
Loading and errorsManualloading.tsx, error.tsx, not-found.tsx
APIpages/api/*route.ts (Route Handlers), Server Actions
StreamingLimitedBuilt in, with <Suspense>

The Pages Router is still supported, and an app can use both while it migrates. New code should use the App Router. A strong answer says: "Layouts in the App Router stay mounted when you navigate, so they are not a safe place for per-request checks."

Popular interview lists still describe Next.js as "page-based routing with a Webpack dev server". In version 16, Turbopack is the default for next dev and next build. A project with a custom webpack config fails next build unless you pass --webpack (or move to Turbopack settings).

Server Components and Client Components

Rules to say out loud:

  • Layouts and pages are Server Components by default. You add "use client" at the top of a file to start a client boundary: that file and everything it imports go into the browser bundle.
  • Use a Client Component when you need state, effects, event handlers or browser APIs. Otherwise stay on the server: smaller JavaScript, direct access to databases and secrets.
  • Props from server to client must be serializable (plain objects, arrays, strings, numbers, dates, Maps, Sets, promises, Server Actions). Functions and class instances are not.
  • A Client Component can render Server Components passed as children. This keeps the heavy part on the server:
JavaScript
// app/page.tsx (Server Component)import Modal from "./ui/modal";   // "use client"import Cart from "./ui/cart";     // Server Component that reads the databaseexport default function Page() {  return <Modal><Cart /></Modal>;   // Cart is rendered on the server, Modal only opens and closes it}
  • Context does not work in Server Components. Put the provider in a small Client Component that takes children.

A Client Component also runs on the server during prerendering. I read process.env.SECRET_KEY (no NEXT_PUBLIC_) in a Client Component. In the browser JavaScript the value was missing, as expected. But the prerendered HTML contained the build-time value. Never read secrets in Client Components. Put them in Server Components or a server-only module, and send the browser only what it needs.

import "server-only" at the top of a module makes the build fail if a Client Component imports it. It is a cheap safety net for data-access code.

Rendering modes in Next.js 16

Before version 16 you picked per route: static (SSG), dynamic (SSR), or ISR. With cacheComponents: true you stop picking per route. Next.js prerenders every route as far as it can, and each part of the page falls into one of three groups:

GroupExampleWhat happens
StaticLayout, headings, pure calculations, module importsRendered at build time, part of the static shell
CachedA function with use cacheRendered once, stored, reused until it expires
Dynamiccookies(), headers(), searchParams, uncached fetch, connection()Streams at request time behind a <Suspense> fallback

The result is called Partial Prerendering. In the build output, ○ means fully static, ◐ means static shell plus streaming parts, and ƒ means rendered on demand. My test page (one cached product list and one cookies() greeting inside <Suspense>) showed up as ◐ /, with Revalidate 1h and Expire 1d.

JavaScript
// app/page.tsxexport default function Page() {  return (    <main>      <h1>Shop</h1>                     {/* static */}      <Products />                       {/* cached with "use cache" */}      <Suspense fallback={<p>loading user</p>}>        <Greeting />                     {/* reads cookies(): dynamic, streams in */}      </Suspense>    </main>  );}

If you forget the <Suspense>, the build tells you. I read cookies() in a page without a boundary and got:

TEXT
Error: Route "/bad": Next.js encountered uncached or runtime data during prerendering.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 route

export const instant = false lets one route block on the server instead (it then shows as ƒ). Use it while you migrate, not as a habit.

Dynamic values need a decision

Math.random(), Date.now() and crypto.randomUUID() give a different value each time, so the build refuses to bake them into HTML. I wrote <p>{Math.random()}</p> and the build failed with "Next.js encountered the unstable value Math.random() while prerendering". The fixes: call await connection() first (render at request time, inside <Suspense>), cache the value with use cache, or render it in a Client Component.

Next.js 16.3 added partialPrefetching, and 16.4 prints a warning when cacheComponents is on and partialPrefetching is not set. Set it to true for a new project. It makes <Link> prefetch the reusable "app shell" of each route.

Caching in Next.js 16

The layers

LayerWhereLifetime
Request memoizationServer, one render passfetch GET calls with the same URL and options, and functions wrapped in React.cache, run once per request
use cache entriesServer memory by default, or a shared handler with use cache: remoteSet by cacheLife; on serverless the default in-memory entries often do not survive between requests
Prerendered HTML (static shell, ISR pages)Disk or the platform's storage, often behind a CDNUntil it is rebuilt or expires
Router cacheBrowser memoryThe stale time of the entry, at least 30 s for use cache content
CDN and HTTP cachesEdgeDriven by Cache-Control headers that Next.js sets

I proved the memoization row. A function wrapped in React.cache was used by both generateMetadata and the page. It ran once per request.

All server-side entries belong to one deployment. The cache key contains the build ID (or deploymentId), the function ID and the arguments. A new deploy starts with an empty cache.

What older material says is outdated. In Next.js 13 and 14, fetch was cached by default, and people learned a four-layer diagram (Request Memoization, Data Cache, Full Route Cache, Router Cache). Since version 15, fetch is not cached by default. In version 16 the docs call the old behaviour "the previous model". You opt in with use cache.

use cache

use cache is a directive (a string at the top of a function or file) that tells Next.js to cache the return value of an async function or component. The arguments and any variables it closes over become the cache key. They must be serializable.

JavaScript
// lib/data.tsimport { cacheLife, cacheTag } from "next/cache";export async function getProducts() {  "use cache";  cacheLife("hours");        // always set a lifetime; the default is 5 min stale / 15 min revalidate  cacheTag("products");      // a label for on-demand invalidation  return db.product.findMany();}

cacheLife takes a profile name. These are the built-in profiles (from the docs):

Profilestale (browser)revalidate (server)expire
seconds30 s1 s60 s
minutes5 min1 min1 h
hours5 min1 h1 day
days5 min1 day1 week
weeks5 min1 week30 days
max5 min30 days1 year
default5 min15 minnever

A very short lifetime (the seconds profile, revalidate: 0, or expire under 5 minutes) keeps the entry out of the static shell, so it becomes a dynamic hole.

Rules that cause real bugs:

  • Cached code cannot read cookies() or headers(). I tried and got: "cookies() can't be read inside "use cache". Read it outside the cached function and pass what you need as an argument." Read the value outside and pass it in. The value then becomes part of the key. For the rare case where you cannot, use cache: private lets you read request data and keeps the result only in the browser.
  • Data for one user must never go into a shared cache without that user's id in the key.
  • React.cache values set outside a use cache function are not visible inside it.

Invalidating: four functions

FunctionWhere you can call itWhat happens
updateTag("posts")Server Actions onlyExpires the tag now. The next read waits for fresh data. For "read your own writes".
revalidateTag("posts", "max")Server Actions and Route HandlersMarks the tag stale. Visitors get the old copy while a fresh one is built.
revalidatePath("/posts")Server Actions and Route HandlersInvalidates one route. Less precise than tags.
refresh()Server ActionsRe-fetches the current route's payload without touching cached data.

What I tested: after revalidateTag("products", "max") from a Route Handler, the next request still returned the old value, and the one after it returned the new value. After updateTag("products") in a Server Action, the very next read was fresh. Calling updateTag in a Route Handler returned HTTP 500: "updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead."

JavaScript
// app/actions.ts"use server";import { updateTag } from "next/cache";import { redirect } from "next/navigation";export async function createPost(formData: FormData) {  const post = await db.post.create({ data: { title: String(formData.get("title")) } });  updateTag("posts");              // the list page shows the new post right away  redirect(`/posts/${post.id}`);   // redirect throws, so call updateTag BEFORE it}

revalidateTag("posts") with one argument is deprecated in version 16. In my project it failed the type check: error TS2554: Expected 2 arguments, but got 1. Use revalidateTag("posts", "max"), or updateTag inside a Server Action.

What stopped working with cacheComponents

The old route settings are replaced by use cache. I added export const dynamic = "force-dynamic" and the build said: "Route segment config "dynamic" is not compatible with nextConfig.cacheComponents. Please remove it." The same family (revalidate, fetchCache, dynamicParams) is not supported either. generateStaticParams must return at least one item (an empty array fails with "all generateStaticParams functions must return at least one result").

Dynamic params are Promises

Since version 15 (and with sync access removed in 16), params, searchParams, cookies(), headers() and draftMode() are asynchronous.

JavaScript
// app/blog/[slug]/page.tsxexport default async function Page(props: PageProps<"/blog/[slug]">) {  const { slug } = await props.params;  const { q } = await props.searchParams;  return <h1>{slug} {q}</h1>;}

PageProps, LayoutProps and RouteContext are global types created by next typegen (and by next dev and next build). The codemod npx @next/codemod@canary next-async-request-api . converts old code.

The mistake is quiet. I wrote the old style, params.slug on a page whose params is a Promise. The build passed, and the page rendered an empty paragraph, because .slug of a Promise is undefined. Always await params.

With Cache Components there is one more habit. If you await params at the top of a layout, the layout cannot be prerendered for unknown values. Pass the promise down and await it inside a <Suspense> boundary, so the rest of the layout stays in the static shell.

Route Handlers or Server Actions?

Route Handler (route.ts)Server Action ("use server")
Called byAnyone with HTTP: browser, mobile app, webhookYour own React UI (forms, event handlers)
ShapeWeb Request and Response, any methodA function call; Next.js sends a POST
Good forPublic API, webhooks, file downloads, streamingMutations from your own pages
ReadsGET can be prerendered when it uses no request dataNot for reads

Notes: a route.ts cannot sit in the same folder as a page.tsx. GET handlers are not cached unless you opt in. With Cache Components a GET that uses no request data is prerendered, and you put use cache in a helper function, not in the handler body. Do not call your own Route Handler from a Server Component. Call the shared function directly.

Server Actions and security

A Server Action is a public POST endpoint. The directive hides the code from the browser, but anyone who can send the right POST can call it. What Next.js does for you: it compares the Origin header with the host (CSRF check, configurable with serverActions.allowedOrigins), caps the body at 1 MB by default, encrypts action IDs, removes unused actions from the client bundle, and encrypts closed-over variables. What it does not do: decide who the user is, or what they may do.

JavaScript
"use server";import { z } from "zod";const Input = z.object({ itemId: z.string().min(1) });export async function completeItem(raw: unknown) {  const session = await auth();                       // 1. authenticate  if (!session?.user) throw new Error("Unauthorized");  const { itemId } = Input.parse(raw);                // 2. validate the shape  const item = await db.item.findFirst({              // 3. authorize: own rows only    where: { id: itemId, ownerId: session.user.id },  });  if (!item) return { ok: false as const };  await db.item.update({ where: { id: item.id }, data: { completed: true } });  return { ok: true as const };                       // 4. return a small, safe shape}

Other facts interviewers like:

  • The client sends Server Actions one at a time. Promise.all over actions does not run them in parallel.
  • After a deploy, an open tab may call an action ID that no longer exists. The error is "Failed to find Server Action". Use rolling deploys and show a retry button.
  • On several servers, set the same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY on all of them. Otherwise one server cannot decrypt what another encrypted.
  • Expected errors (validation) are returned, for example to useActionState. Unexpected errors are thrown and caught by error.tsx.

Proxy (formerly middleware)

middleware.ts is deprecated and renamed proxy.ts, and the exported function is proxy. The codemod is npx @next/codemod@canary middleware-to-proxy .. Proxy runs on the Node.js runtime, and you cannot set runtime in it. (To keep the Edge runtime you must keep the old middleware file.)

JavaScript
// proxy.ts (project root)import { NextResponse } from "next/server";import type { NextRequest } from "next/server";export function proxy(request: NextRequest) {  if (!request.cookies.get("session")?.value) {    return NextResponse.redirect(new URL("/login", request.url));  }  const res = NextResponse.next();  res.headers.set("x-from-proxy", "1");  return res;}export const config = { matcher: ["/dashboard/:path*"] };

I tested this. Without a cookie, /dashboard returned 307 with location: /login. With a cookie it returned 200 and the header x-from-proxy: 1. The build output listed ƒ Proxy (Middleware).

Things to remember:

  • Without a matcher, proxy runs on every request, including _next/static and images. Always set one.
  • The matcher must be a constant that can be read at build time.
  • Proxy also runs on prefetches. Do optimistic checks only (read a cookie, decode a token). Do not query the database here.
  • It is not a full security layer. Check again where the data is read (see the auth section).
  • You cannot call revalidateTag in proxy.
  • Good uses: redirects, locale detection, rewrites, simple header changes. Bad uses: heavy logic, database calls.

Streaming, loading.tsx and status codes

loading.tsx is a shortcut: Next.js wraps the page in a <Suspense> boundary and uses your component as the fallback. For finer control, use your own <Suspense> around the slow part so that the rest of the page is real content.

The status code problem. The HTTP status line is sent with the first byte. Once a fallback has streamed, the server has already said 200. I tested two pages: notFound() called straight in the page (before any await or <Suspense>) returned 404. notFound() called inside a <Suspense> child returned 200, and Next.js added <meta name="robots" content="noindex"> so that search engines do not index it. If you need a true 404 for SEO or monitoring, check that the resource exists before the response starts to stream.

Bots that cannot run JavaScript (for example social preview crawlers) are detected by their user agent. They get a fully rendered page and the metadata in <head>. You can tune this with htmlLimitedBots.

Metadata, images, fonts and scripts

Metadata. Export a metadata object (static) or an async generateMetadata function (dynamic) from a layout or page. Both work only in Server Components. If the page and generateMetadata need the same data, wrap the loader in React.cache so it runs once (my test confirmed this). Other files: sitemap.ts, robots.ts, opengraph-image.tsx. Remember a canonical URL (alternates.canonical) for pages with several URLs.

Images (next/image). It resizes, converts to modern formats, lazy-loads, and reserves space to avoid layout shift. Rules: give width and height (or fill with a positioned parent); set sizes for responsive images (without sizes the browser assumes full viewport width and may download too much); remote hosts must be allowed in images.remotePatterns. For the main image above the fold, use preload (version 16 deprecated priority) or loading="eager" with fetchPriority="high". Version 16 changed defaults: minimumCacheTTL is 4 hours (was 60 seconds), images.qualities is only [75], at most 3 redirects, and local images with query strings need images.localPatterns.

Fonts (next/font). Fonts are downloaded at build time and served from your own domain, with no request to Google at runtime and less layout shift.

Scripts (next/script). Strategies: afterInteractive (default), lazyOnload (idle time), beforeInteractive (only in the root layout, for critical scripts) and worker (experimental). Put analytics on lazyOnload or afterInteractive.

Environment variables

  • Variables are server-only by default. A NEXT_PUBLIC_ prefix means "copy this value into the browser JavaScript at build time".
  • Frozen at build. I built with NEXT_PUBLIC_API=build-time and started the server with NEXT_PUBLIC_API=run-time. Both the server output and the client showed build-time, and the string run-time was not in the build. To use one Docker image in many environments, do not use NEXT_PUBLIC_ for values that change. Read a normal variable on the server after await connection() (I got the runtime value that way), or fetch config from an API.
  • Lookups such as process.env[name] are not inlined.
  • .env files are read from the project root, not from src/. Commit .env.example, never .env.local.

Authentication patterns

A safe layering, taken from the Next.js guides:

  1. Proxy for fast, optimistic checks (is there a session cookie?).
  2. A Data Access Layer (DAL): server-only functions such as getCurrentUser() and getInvoice(id) that check the session and the permission, and return small DTOs (data transfer objects, plain objects with only the needed fields).
  3. Checks in each Server Action and Route Handler.

Do not rely on a layout for security. Layouts do not re-render on navigation, and a layout does not stop the page below it from running. With Cache Components, reading the session is a runtime read: wrap session-dependent UI in <Suspense>, and pass a user id to use cache functions to cache data per user. For the token-versus-session decision see Browser, Lesson 02. Libraries such as Auth.js (NextAuth) or a hosted provider save time, but the DAL rule still applies.

ISR with Cache Components

With cacheComponents and partialPrefetching, ISR works like this: generateStaticParams lists the pages to build now. A visit to an unlisted URL gets the app shell at once, then Next.js builds the full page in the background and caches it for the next visitor. Time-based refresh comes from cacheLife, and on-demand refresh from tags. Awaiting params inside <Suspense> is what makes the shell possible.

Edge or Node, and where to deploy

  • Version 16 recommends the Node.js runtime for almost everything. The edge runtime has fewer APIs, does not support ISR, and is not supported with Cache Components.
  • Vercel gives the whole feature set with no setup (CDN, image optimization, ISR storage, rollbacks). Self-hosting (a Node server, Docker, or a Kubernetes cluster) supports all features too, but you own the cache, the CDN and the deploy process.
  • output: "standalone" creates .next/standalone/server.js with only the files needed. Copy public/ and .next/static next to it. I ran next start with standalone on and got the warning: "next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead.
  • On many servers: share the cache (a custom cacheHandler, or use cache: remote), set the same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, set a stable deploymentId or build ID, and put a reverse proxy such as nginx in front.
  • output: "export" makes plain static files, but you lose proxy, Server Actions, Route Handlers that need a server, and image optimization without a custom loader.

Bundle size

Run the analyzer: next experimental-analyze (Turbopack, experimental, available from 16.1) or @next/bundle-analyzer for Webpack. Look for large libraries inside Client Components. Fixes: move the code to a Server Component, move the "use client" boundary lower, load heavy widgets with next/dynamic, and import only what you use. Version 16 removed the First Load JS column from the build output because it was inaccurate with Server Components. Use Lighthouse or real-user data instead.

Errors, 404, i18n and testing

  • error.tsx is an error boundary for a route segment. It must be a Client Component and receives error and retry. In 16.3 retry became stable: it re-fetches and re-renders the segment (the older reset only clears the error state). global-error.tsx handles errors in the root layout. not-found.tsx is shown by notFound() and for unknown URLs.
  • Expected errors (a failed validation, a 404 from an API) are values you return or handle. Unexpected errors are thrown to error.tsx.
  • i18n. Put the locale in the URL (app/[lang]/...), detect it from Accept-Language in proxy and redirect, and load a dictionary on the server. Await params to read lang.
  • Testing. Unit-test pure functions and synchronous components with Vitest or Jest. These tools do not support async Server Components, so test those with end-to-end tests (Playwright). See Frontend Depth, Lesson 05.

Migration pitfalls (14 or 15 to 16)

PitfallWhat to do
Sync params, cookies(), headers()await them; run the codemod
middleware.tsRename to proxy.ts, export proxy; Node runtime only
revalidateTag(tag)Add the profile ("max") or use updateTag in an action
dynamic, revalidate, fetchCache, dynamicParams with cacheComponentsRemove; use use cache and cacheLife
Custom webpack configMove to Turbopack options or build with --webpack
next lintRemoved; run ESLint (flat config) directly
next/image priority, domainsUse preload and remotePatterns
Parallel route slotsAdd a default.tsx to each slot
Node 18Node 20.9 or newer, TypeScript 5.1 or newer
Experimental PPR from 15 canaryStay on that canary or migrate to cacheComponents; the flags were removed

Code review table

You seeProblemSuggest
"use client" on a whole page to use one useStateEverything below it goes to the browserExtract a small client component
Secret read in a Client ComponentValue can end up in HTMLRead on the server
Server Action with no auth checkIt is a public endpointAuthenticate, validate, authorize inside
await cookies() in the root layoutWhole page waits; cannot be in the shellRead it inside a <Suspense> child
"use cache" function that calls cookies()Build or runtime errorPass the value as an argument
One shared cache entry holding per-user dataData leakInclude the user id in arguments
NEXT_PUBLIC_ value that differs per environmentFrozen at buildServer variable or runtime config
Fetching your own /api/... from a Server ComponentExtra hopCall the function directly
No matcher in proxyRuns on every assetAdd a matcher

Interview questions

What is the difference between the App Router and the Pages Router?

(must know) The App Router (app/) is built on React Server Components. Components run on the server by default, layouts nest and stay mounted, and I get loading.tsx, error.tsx, streaming with <Suspense>, Route Handlers and Server Actions. The Pages Router (pages/) uses getServerSideProps and getStaticProps, and each page is a Client Component that is also rendered on the server. Both are still supported and can live in one project during a migration. For new work I choose the App Router. One caution I give: the App Router has more caching rules to learn, so I read the version's docs before I rely on old blog posts.

Server Component or Client Component: how do you decide?

(must know) I start on the server. I use a Client Component only when I need state, effects, event handlers or browser APIs. I keep the "use client" boundary as low as I can, so a search box is a client component but the header around it is not. Props that cross the boundary must be serializable. A Client Component can show Server Components that I pass as children. I also remember that a Client Component is prerendered on the server, so it must never read secrets.

What changed in caching between Next.js 14 and 16?

(senior signal) In 14, fetch was cached by default, and people used the four layers: request memoization, the Data Cache, the Full Route Cache and the Router Cache. In 15, fetch stopped being cached by default. In 16, with cacheComponents: true, everything is dynamic unless I say otherwise, and I opt in with the use cache directive plus cacheLife and cacheTag. Route settings such as dynamic = "force-dynamic" and revalidate are replaced (the build fails if I keep them). Rendering becomes Partial Prerendering by default: a static shell plus streamed dynamic parts. The old model is still documented as "previous model" for projects without Cache Components.

How does use cache build its cache key, and what can go wrong?

(senior signal) The key is made from the build ID (or deployment ID), a function ID, and the serializable arguments plus the variables the function closes over. So different arguments mean different entries, and a new deploy means an empty cache. Problems: cached code cannot read cookies() or headers() (I pass the value as an argument), arguments must be serializable (no class instances), and on serverless the default in-memory store often does not survive between requests, so I use use cache: remote with a shared handler when hit rate matters. Another trap is putting per-user data in a shared entry: the user id must be in the key. I also always set cacheLife, so the lifetime is visible in the code.

revalidateTag or updateTag or revalidatePath?

(must know) updateTag is for Server Actions when the user must see their own change at once: it expires the tag and the next read waits for fresh data. revalidateTag(tag, "max") is stale-while-revalidate: visitors get the old copy while a new one is built. It also works in Route Handlers, so it is my choice for webhooks from a CMS. revalidatePath invalidates a whole route when I do not know the tags. I prefer tags, because paths over-invalidate. I tested the difference: after revalidateTag the next request still got the old value; after updateTag the next read was fresh. The one-argument revalidateTag(tag) is deprecated and gives a TypeScript error in 16.

A page reads cookies() and the build fails. What do you do?

(trap) The message says Next.js met runtime data outside <Suspense>. The page cannot be prerendered if a request-only read blocks the whole thing. My options: (1) move the read into a small child component and wrap it in <Suspense> with a fallback, so the rest stays in the static shell; (2) if the data does not depend on the user, cache it with use cache; (3) read the cookie outside and pass the value into a cached function; (4) as a short-term step, export const instant = false. I prefer (1) or (3).

Why did my page render empty after I upgraded, with no error?

(trap) A likely cause is old code that reads params.slug or searchParams.q directly. These are Promises now. In my test the build passed and the page showed an empty value, because a Promise has no slug. I use const { slug } = await props.params with the generated PageProps type, and I run the next-async-request-api codemod across the project. In a layout with Cache Components, I pass the promise into a <Suspense> child instead of awaiting at the top.

Route Handler or Server Action?

(must know) A Server Action is for mutations that come from my own UI: forms and buttons. It is easy to call, works with useActionState, and can invalidate the cache. A Route Handler is a real HTTP endpoint: use it for webhooks, a public or mobile API, file downloads, or anything called from outside my React app. Both are public entry points, so both need authentication, validation and authorization. I do not use Server Actions for reads, and I do not fetch my own Route Handler from a Server Component.

How do you secure a Server Action?

(senior signal) I remember that it is a public POST endpoint. Next.js gives a CSRF check (Origin against Host), a 1 MB body limit, encrypted action IDs and dead-code removal. Those do not know my users. Inside every action I do four steps: authenticate from the session (not from a form field), validate the input shape with Zod, authorize against the row (for example findFirst({ id, ownerId: session.user.id })), and return only a small result. I never trust an id or a role sent by the client. For destructive actions I may ask for a fresh login. I add rate limiting for abuse, and in a multi-server deploy I set a shared NEXT_SERVER_ACTIONS_ENCRYPTION_KEY.

What is proxy.ts, and how is it different from middleware.ts?

(must know) It is the same feature with a new name in version 16, to make clear that it sits in front of the app like a network proxy. It runs before routes: redirects, rewrites, header changes, locale redirects. The big change is the runtime: proxy runs on Node.js, and edge is not supported in it (if I need Edge I must keep middleware.ts). I always set a matcher, because otherwise it runs for every static file. I keep it light, because it also runs on prefetch requests. I do cookie checks, not database calls, and I repeat the real authorization where the data is read.

How do you protect pages in a Next.js app?

(senior signal) In layers. Proxy does a cheap check for a session cookie and redirects to login. Then the Data Access Layer does the real check: getCurrentUser() verifies the session on the server and each data function checks permission and returns a small DTO. Server Actions and Route Handlers repeat the check. I do not use a layout as the guard, because layouts stay mounted during navigation and do not stop the page or a parallel slot from running. I read the session inside <Suspense> so the shell still prerenders.

Why is my NEXTPUBLIC variable wrong in production?

(trap) NEXT_PUBLIC_ values are copied into the JavaScript at build time. If I built once and deploy the same image to staging and production, both have the build-time value. I tested it: the runtime value never appeared. Fixes: build per environment, or do not use NEXT_PUBLIC_ for values that change. Read a server variable after await connection(), or expose a small config endpoint. And I never put secrets behind NEXT_PUBLIC_.

Why did notFound() return status 200 for SEO?

(trap) The status code goes out with the first byte. If the page already streamed a fallback (from loading.tsx or a <Suspense>), the server cannot change it to 404 later. In my test notFound() before any await gave 404; inside a Suspense child it gave 200 plus a noindex meta tag. Next.js adds noindex so that search engines skip the URL, but monitoring may call it a "soft 404". If a real 404 matters, I check that the item exists early, before the stream starts.

How do you make a page with personalised and public parts fast?

(senior signal) With Cache Components I split the page. The shell (layout, headings) is static. Public data like a product list is in a function with use cache, a cacheLife, and a tag. The personalised part (the user's name, cart count) reads cookies() inside a <Suspense> child with a small fallback. The first paint comes from the CDN, and the personal part streams in. I push await calls down the tree so that as much as possible is in the shell. I check the build table for ◐, and I avoid await params at the top of layouts.

How do you deploy Next.js to many servers?

(senior signal) Everything that was per-server must be shared or made stable. The cache needs a shared handler (a custom cacheHandler, or use cache: remote), otherwise each pod has its own copy. I set one NEXT_SERVER_ACTIONS_ENCRYPTION_KEY for all instances, a stable build ID or deploymentId for version-skew protection, and use rolling deploys so tabs still open on the old build can finish. I put nginx or a load balancer in front. For Docker I use output: "standalone", copy public and .next/static, and start with node server.js (not next start, which warns). Static assets can go to a CDN with assetPrefix.

How do you find out why the JavaScript bundle is big?

(must know) I run the analyzer (next experimental-analyze with Turbopack, or @next/bundle-analyzer with Webpack) and look at the Client Component bundles. Usual causes: a big library inside a Client Component (date, chart, editor), a "use client" boundary too high, and barrel imports. Fixes: move the work to a Server Component, move the boundary down, lazy-load with next/dynamic, and use a lighter library. Then I confirm the result with Lighthouse and real-user Core Web Vitals, since version 16 no longer prints First Load JS.

What do you need to know to migrate from Next.js 14 or 15 to 16?

(senior signal) Node 20.9 or newer. Await all request APIs (run the codemod). Rename middleware to proxy. Add the second argument to revalidateTag. Turbopack is the default, so a custom webpack config needs attention. next lint is gone. Image defaults changed (cache TTL, qualities, redirects, local patterns). Parallel route slots need default.tsx. If I turn on cacheComponents, I remove dynamic, revalidate, fetchCache and dynamicParams, and I do it route by route, using instant = false to defer hard ones. I upgrade in a branch, run the build, then test the key flows end to end.

How do you test a Next.js app?

(senior signal) In layers. Pure functions and the data layer get unit tests. Client Components and synchronous Server Components can be tested with Vitest and React Testing Library. Next.js documents that these tools do not support async Server Components, so for pages that await data I use Playwright end-to-end tests against a production build (next build then start), where caching behaves as in production. For Server Actions I test the function with a fake session and I test the form flow end to end. I also run the build in CI, because many caching and Suspense mistakes are build errors.

Key takeaways

  • Where and when. Every part of a page is static, cached, or dynamic. <Suspense> marks the dynamic holes.
  • Next.js 16 is dynamic by default. You opt in to caching with use cache, cacheLife and cacheTag. dynamic, revalidate and dynamicParams are not allowed with cacheComponents.
  • Invalidation: updateTag (actions, immediate), revalidateTag(tag, "max") (stale-while-revalidate), revalidatePath, refresh.
  • Request data is async (params, cookies(), headers()), and cached code cannot read it directly.
  • Server Actions are public endpoints. Authenticate, validate and authorize inside each one.
  • proxy.ts replaces middleware.ts, runs on Node.js, needs a matcher, and does only light checks.
  • NEXT_PUBLIC_ is frozen at build. Secrets stay on the server, even out of Client Components.
  • Streaming fixes the status line early. A mid-stream notFound() is a 200 with noindex.
  • Self-hosting at scale needs a shared cache, a shared encryption key, a stable deployment ID and node server.js for standalone output.

What's next

Lesson 06: Node.js and Express Interview Questions. Next.js runs on Node, so we look at the event loop, streams, Express 5, security and graceful shutdown.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series