Next.js NotesRohit’s interview study guide
Chapter 12

Testing and Deployment

How to test each kind of Next.js code at the level where it actually runs, and how to ship it: Vercel or your own servers, standalone Docker images, shared caches across instances, build-time versus runtime configuration, zones, monorepos and upgrades.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Testing a Next.js app

Added

A Next.js app mixes three kinds of code, and each needs a different kind of test. There's plain logic (price formatting, validation, mapping database rows to DTOs), which is just TypeScript. There are components that render the same way in any React environment: Client Components and synchronous Server Components. And there's code whose behaviour is the Next.js server: async Server Components awaiting data, cookies() and headers(), Suspense streaming and the Partial Prerendering shell, 'use cache' and revalidation, proxy.ts redirects, real client-side navigation.

The mental model that sorts everything else: Vitest and Jest give you a pretend browser, not a pretend Next.js server. React Testing Library's render() uses react-dom/client, the same renderer the browser uses, inside jsdom (a DOM written in JavaScript). There is no request, no React Server Components renderer, no cache and no router. Anything that only works inside a request on the server has to be tested against a real server: next build && next start, driven by Playwright. The bundled Next.js docs say the same thing in their own words: Vitest and Jest don't support async Server Components yet, so use end-to-end tests for those.

That gives the usual split: many fast unit tests for logic and interactive components, a smaller set of end-to-end tests for the flows that cross the server (the async pages, auth cookies, mutations and caching), and type-checking plus lint on every build.

Unit and component tests Vitest or Jest · Testing Library · jsdom a pretend browser plain functions (formatPrice, validation) Client Components, next/navigation mocked synchronous Server Components GET(new Request(…)) Server Actions, next/cache mocked End-to-end tests Playwright · next build + next start a real Next.js server async Server Components cookies(), headers(), searchParams Suspense streaming, the PPR shell 'use cache', revalidation, redirects proxy.ts, navigation, hydration Both rest on: next build type-checks every file · ESLint runs separately (next lint was removed in 16)
Unit tools render React in jsdom with the browser renderer; anything that needs a request, the RSC renderer or the cache needs the real server.
CodeTest withWhat you mock or provide
Pure functions in lib/Vitest or Jestnothing
Client ComponentsVitest/Jest + React Testing Librarynext/navigation (useRouter, usePathname…)
Synchronous Server ComponentsVitest/Jest + React Testing Librarywhatever Client Components they render need
Async Server ComponentsPlaywright (or render(await Page()) for simple ones)a running app
Route Handlerscall GET(new Request(url)) in Vitest; Playwright for the real routingnothing, if they don't call cookies()/headers()
Server Actionscall the function in Vitest for validation logic; Playwright for the real form flownext/cache (updateTag, revalidatePath)
proxy.ts, caching, streaming, PPRPlaywright against next build && next starta running app

All the examples below ran in one project, .sandbox/nextjs/ch12/testing (a copy of the Next.js 16.4.3 template) where I installed Vitest 5.0.5, Vite 8.3.5, jsdom 30.1.3, React Testing Library 16.3.3, user-event 14.6.8, Playwright 1.64.1 and Jest 30.5.2. The app files are built by the site's checker; the test files and configs can't be (the checker's template has no test packages), so they're shown untagged with the output they really printed.

__tests__/quantity-picker.test.tsx
import { afterEach, expect, test, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QuantityPicker } from "@/components/quantity-picker";

const push = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));

afterEach(() => {
  cleanup();
  push.mockClear();
});

test("counts up to max, then disables +", async () => {
  const user = userEvent.setup();
  render(<QuantityPicker max={2} />);
  const plus = screen.getByRole("button", { name: "+" });

  await user.click(plus);
  expect(screen.getByLabelText("Quantity").textContent).toBe("2");
  expect((plus as HTMLButtonElement).disabled).toBe(true);
});

test("navigates to the cart with the quantity", async () => {
  const user = userEvent.setup();
  render(<QuantityPicker />);

  await user.click(screen.getByRole("button", { name: "+" }));
  await user.click(screen.getByRole("button", { name: "Go to cart" }));
  expect(push).toHaveBeenCalledWith("/cart?qty=2");
});
Result (npx vitest run __tests__/quantity-picker.test.tsx --reporter=verbose)
 RUN  v5.0.5 /Users/rohitvarma/AndroidStudioProjects/StudySites/.sandbox/nextjs/ch12/testing

 ✓ __tests__/quantity-picker.test.tsx > counts up to max, then disables + 85ms
 ✓ __tests__/quantity-picker.test.tsx > navigates to the cart with the quantity 18ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  14:16:55
   Duration  581ms (environment 63%, tests 21%, import 14%, transform 2%)
What’s happening
  1. The component under test (shown next) is a Client Component that keeps qty in useState and calls useRouter() from next/navigation to go to the cart. In a real app that hook reads the App Router's context; in jsdom there is no App Router, so the test replaces the whole module with vi.mock("next/navigation", …).
  2. Vitest hoists every vi.mock call above the imports, so when quantity-picker.tsx imports useRouter it gets the fake. The factory only mentions push inside the useRouter arrow, which runs during render, after const push = vi.fn() has executed. A factory that read push directly would fail: I tried it and Vitest stopped with "There was an error when mocking a module… make sure there are no top level variables inside" (the fix is vi.hoisted).
  3. First test: max={2}, so qty starts at 1. user.click(plus) dispatches real pointer and click events, React runs setQty(q => Math.min(2, q + 1)), qty goes 1 → 2, and the + button renders with disabled because qty === max.
  4. Second test: one click takes qty to 2, then "Go to cart" calls router.push("/cart?qty=2"). The assertion checks the component's contract with the router (what it asked for), not Next.js navigation itself: that belongs to the end-to-end test further down.
  5. afterEach(cleanup) matters with Vitest's default globals: false: Testing Library can only clean up automatically when a global afterEach exists. I removed the call to check, and the second test failed with "Found multiple elements with the role "button" and name "+"", because the first test's picker was still in the document.
  6. This is the sweet spot for unit tests: an interactive Client Component, tested in about 100 ms, with only the Next.js module it touches replaced.
AdvancedThe component under test
components/quantity-picker.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

export function QuantityPicker({ max = 3 }: { max?: number }) {
  const [qty, setQty] = useState(1);
  const router = useRouter();

  return (
    <div>
      <button onClick={() => setQty((q) => Math.max(1, q - 1))}>−</button>
      <output aria-label="Quantity">{qty}</output>
      <button onClick={() => setQty((q) => Math.min(max, q + 1))} disabled={qty === max}>
        +
      </button>
      <button onClick={() => router.push(`/cart?qty=${qty}`)}>Go to cart</button>
    </div>
  );
}
What’s happening
  1. "use client" makes this a Client Component: it can hold state and handle clicks, and in the app it's rendered by the Server Component page below.
  2. − never goes below 1 and + never above max, and + is disabled at the limit, which is what the first test checks.
  3. "Go to cart" is the only place Next.js is involved, through useRouter().push. Keeping Next.js calls at the edges like this is what keeps components easy to unit test.
AdvancedSetting up Vitest

The bundled guide's setup is three dev dependencies plus a config file. I added user-event (for realistic clicks) and, because of an install error, a newer @types/node:

Shell
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/user-event

The first attempt, with the packages the template ships, stopped with npm error ERESOLVE: vitest@5.0.5 wants @types/node ^22.0.0 || >=24.0.0 as an optional peer, and create-next-app@16.4.3 pins @types/node ^20. Adding @types/node@^24 to the same install fixed it.

vitest.config.mts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  resolve: { tsconfigPaths: true }, // "@/…" imports, as in tsconfig.json
  test: {
    environment: "jsdom",
    include: ["__tests__/**/*.test.{ts,tsx}"], // keep Playwright's e2e/ folder out
  },
});
What’s happening
  1. @vitejs/plugin-react compiles JSX and TSX. Vitest doesn't use the Next.js compiler, so Next-specific transforms (next/font, CSS Modules naming, 'use cache') don't run: the directive is just an unused string to Vitest.
  2. resolve: { tsconfigPaths: true } makes @/components/… resolve like tsconfig.json says. The bundled guide uses the vite-tsconfig-paths plugin for this; with Vite 8 it printed "Vite now supports tsconfig paths resolution natively via the resolve.tsconfigPaths option", so I switched to the built-in option. Both work.
  3. environment: "jsdom" gives the tests document and window. include keeps Vitest away from Playwright's e2e/ folder, whose files would otherwise be picked up and fail.
  4. Run it with npx vitest (watch mode) or npx vitest run (once, for CI).
AdvancedWithout the mock: the router invariant

Without the mock: "invariant expected app router to be mounted"

__tests__/naive-picker.test.tsx
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import { QuantityPicker } from "@/components/quantity-picker";

test("renders without a router", () => {
  render(<QuantityPicker />);
  expect(screen.getByLabelText("Quantity").textContent).toBe("1");
});
Result (npx vitest run __tests__/naive-picker.test.tsx)
 FAIL  __tests__/naive-picker.test.tsx > renders without a router
Error: invariant expected app router to be mounted
 ❯ useRouter node_modules/next/src/client/components/navigation.ts:169:11
 ❯ QuantityPicker src/components/quantity-picker.tsx:8:18
      6| export function QuantityPicker({ max = 3 }: { max?: number }) {
      7|   const [qty, setQty] = useState(1);
      8|   const router = useRouter();
       |                  ^
What’s happening
  1. render mounts the picker with react-dom/client. The first hook, useState, works anywhere.
  2. useRouter() from next/navigation reads a React context that only the App Router's root provides. In jsdom nothing provides it, so Next.js throws its "invariant expected app router to be mounted" error.
  3. The fix is never "wrap it in the real router": mock next/navigation with just the members the component uses (useRouter, and usePathname or useSearchParams if it reads them).
  4. The same error in a real app means a component calling useRouter was rendered outside the App Router, for example from the Pages Router, which has its own useRouter in next/router.
AdvancedSynchronous Server Components render like any component

A Server Component that isn't async is a plain function returning JSX, so React Testing Library can render it in jsdom. That's the page from this app:

app/page.tsx
import Link from "next/link";
import { QuantityPicker } from "@/components/quantity-picker";

export default function Home() {
  return (
    <main>
      <h1>Mug</h1>
      <QuantityPicker max={3} />
      <Link href="/products">All products</Link>
    </main>
  );
}
__tests__/home.test.tsx
import { expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Home from "@/app/page";

vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));

test("a synchronous Server Component renders like any component", () => {
  render(<Home />);
  expect(screen.getByRole("heading", { level: 1, name: "Mug" })).toBeDefined();
  expect(screen.getByRole("link", { name: "All products" }).getAttribute("href")).toBe("/products");
});
What’s happening
  1. Home has no "use client", so in the app it renders on the server. In the test, it's just a function: render(<Home />) calls it in jsdom.
  2. It renders QuantityPicker, which calls useRouter(), so this file mocks next/navigation too. Without it the same invariant error appears, from inside the page.
  3. next/link renders a real <a href="/products"> in jsdom, so the test can check the link's href. It can't check prefetching or client navigation: there's no router to do them.
  4. It passed (✓ a synchronous Server Component renders like any component 75ms). This is fine for "does the page compose the right pieces" checks, but most real pages are async, which is where unit tests stop.
AdvancedAsync Server Components: why unit tests fall short

This page awaits its data, like most App Router pages:

app/products/page.tsx
import { getProducts } from "@/lib/products";
import { formatPrice } from "@/lib/price";

export default async function ProductsPage() {
  const products = await getProducts();
  return (
    <main>
      <h1>Products</h1>
      <ul>
        {products.map((p) => (
          <li key={p.id}>
            {p.name}: {formatPrice(p.priceCents)}
          </li>
        ))}
      </ul>
    </main>
  );
}
lib/products.ts
export type Product = { id: string; name: string; priceCents: number };

export async function getProducts(): Promise<Product[]> {
  "use cache";
  // Stands in for a database query
  return [
    { id: "mug", name: "Mug", priceCents: 1200 },
    { id: "tee", name: "T-shirt", priceCents: 2500 },
  ];
}
lib/price.ts
export function formatPrice(cents: number, currency = "CAD"): string {
  return new Intl.NumberFormat("en-CA", { style: "currency", currency }).format(cents / 100);
}

Rendering it the way you'd render a Client Component:

__tests__/naive-products.test.tsx
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import ProductsPage from "@/app/products/page";

test("renders an async Server Component like a client one", async () => {
  render(<ProductsPage />);
  expect(await screen.findByText("Mug: $12.00")).toBeDefined();
});
Result (npx vitest run __tests__/naive-products.test.tsx)
stderr | __tests__/naive-products.test.tsx > renders an async Server Component like a client one
<ProductsPage> is an async Client Component. Only Server Components can be async at the moment. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.

stderr | __tests__/naive-products.test.tsx > renders an async Server Component like a client one
A component suspended inside an `act` scope, but the `act` call was not awaited. When testing React components that depend on asynchronous data, you must await the result:

 FAIL  __tests__/naive-products.test.tsx > renders an async Server Component like a client one
TestingLibraryElementError: Unable to find an element with the text: Mug: $12.00. …

<body>
  <div />
</body>
What’s happening
  1. render(<ProductsPage />) hands an async function to react-dom/client. The browser renderer doesn't run async components: React says so in its own words, "<ProductsPage> is an async Client Component. Only Server Components can be async at the moment".
  2. From React's point of view every component in a jsdom test is a Client Component, because there's no Server Components renderer. "Server Component" is about which renderer runs the code, not about the file.
  3. findByText waits about a second (its default timeout) and gives up: the body is still an empty <div />. The test took 1009 ms, all of it waiting.
  4. So the Next.js docs' advice holds: test async Server Components end to end. For simple ones there's a workaround, shown next, but it has limits.
__tests__/server-components.test.tsx
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import ProductsPage from "@/app/products/page";
import { Greeting } from "@/components/greeting";

test("await the async component yourself, then render its output", async () => {
  render(await ProductsPage());
  expect(screen.getByText("Mug: $12.00")).toBeDefined();
});

test("request APIs have no request to read in a unit test", async () => {
  await expect(Greeting()).rejects.toThrow("`cookies` was called outside a request scope");
});
components/greeting.tsx
import { cookies } from "next/headers";

export async function Greeting() {
  const user = (await cookies()).get("user")?.value ?? "guest";
  return <p>Hello, {user}</p>;
}
What’s happening
  1. await ProductsPage() calls the component as an ordinary async function. It awaits getProducts() (the 'use cache' string does nothing in Vitest), formats the prices, and resolves to plain JSX: a <main> with two <li>s. render() then renders that JSX, which contains no async components, so it works: Mug: $12.00 is found.
  2. This only works one level deep. If the page rendered another async component (<Reviews /> inside it), that child would hit the same "async Client Component" error, because only the top call was awaited by you.
  3. Greeting calls cookies(). Outside a Next.js request there's no request to read, and the test captured the real message: `cookies` was called outside a request scope. The same goes for headers(), draftMode(), connection() and the params/searchParams that Next.js passes in.
  4. You could mock next/headers, but then the test checks your mock, not the page. The usual design answer is to keep request reading at the top (in the page) and pass plain values to components and functions you can test, and to cover the page itself with an end-to-end test.
Which of these can Vitest + React Testing Library render?

A: a Client Component that calls useRouter(). B: export default function About() returning JSX. C: export default async function Page() that awaits a database call and renders <Reviews />, another async component. D: a Server Component that reads (await headers()).get("user-agent").

Show answer

A: yes, with next/navigation mocked. B: yes, as is. C: no (and render(await Page()) doesn't save it, because <Reviews /> is async too). D: no, headers() throws "called outside a request scope". C and D are end-to-end territory.

What’s happening
  1. A is an ordinary Client Component; its only Next.js dependency is the router hook, and mocking next/navigation removes it (the "invariant expected app router to be mounted" error otherwise).
  2. B is a synchronous function returning JSX: jsdom renders it like any component, as the home.test.tsx run showed.
  3. C: render(<Page />) gives "is an async Client Component". Awaiting Page() yourself resolves the top level, but the JSX it returns still contains <Reviews />, an async component, which react-dom/client can't render either.
  4. D: headers() needs the request that Next.js stores for the duration of a render; in a test there is none, so it throws before returning anything.
AdvancedRoute Handlers and Server Actions are just functions

A Route Handler is a function from Request to Response, and a Server Action is an async function: both can be called directly, as long as you mock the Next.js APIs they call.

app/api/price/route.ts
import { formatPrice } from "@/lib/price";

export function GET(request: Request) {
  const cents = Number(new URL(request.url).searchParams.get("cents"));
  if (!Number.isInteger(cents) || cents < 0) {
    return Response.json({ error: "cents must be a whole number" }, { status: 400 });
  }
  return Response.json({ formatted: formatPrice(cents) });
}
app/actions.ts
"use server";

import { updateTag } from "next/cache";

export async function renameProduct(formData: FormData) {
  const name = String(formData.get("name") ?? "").trim();
  if (name.length < 2) return { error: "Name is too short" };
  // ...save to the database here...
  updateTag("products");
  return { ok: true, name };
}
__tests__/route-and-action.test.ts
import { expect, test, vi } from "vitest";
import { updateTag } from "next/cache";
import { GET } from "@/app/api/price/route";
import { renameProduct } from "@/app/actions";

vi.mock("next/cache", () => ({ updateTag: vi.fn() }));

test("a Route Handler is a function from Request to Response", async () => {
  const ok = GET(new Request("http://test/api/price?cents=1200"));
  expect(ok.status).toBe(200);
  expect(await ok.json()).toEqual({ formatted: "$12.00" });

  const bad = GET(new Request("http://test/api/price?cents=abc"));
  expect(bad.status).toBe(400);
});

test("a Server Action is an async function you can call", async () => {
  const form = new FormData();
  form.set("name", "Big mug");
  expect(await renameProduct(form)).toEqual({ ok: true, name: "Big mug" });
  expect(updateTag).toHaveBeenCalledWith("products");

  form.set("name", "x");
  expect(await renameProduct(form)).toEqual({ error: "Name is too short" });
});
What’s happening
  1. GET(new Request("http://test/api/price?cents=1200")) runs the handler with a standard Request; it parses cents (1200), returns Response.json({ formatted: "$12.00" }), and the test reads status and json() like any fetch response. cents=abc gives NaN, which fails Number.isInteger, so it's a 400.
  2. The "use server" directive means nothing to Vitest, so renameProduct(form) is a plain call: "Big mug" passes validation and the result is { ok: true, name: "Big mug" }; "x" returns the error object.
  3. updateTag is the catch. I first ran the test without vi.mock("next/cache"), and it failed with Next.js's real check: "updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead." With the mock, the test can also assert updateTag was called with "products".
  4. What this doesn't test: that the form posts to the action, that the action ID resolves, that updateTag really refreshes the page, or the auth checks a real action needs. Those are end-to-end concerns.
AdvancedEnd-to-end with Playwright against the production build

These pages complete the app: the cart reads searchParams and the account page reads a cookie, both inside <Suspense> because Cache Components requires request-time data to sit in a boundary.

app/cart/page.tsx
import { Suspense } from "react";

async function Quantity({ searchParams }: Pick<PageProps<"/cart">, "searchParams">) {
  const { qty = "0" } = await searchParams; // request-time data: inside <Suspense>
  return <p>Items in cart: {qty}</p>;
}

export default function CartPage({ searchParams }: PageProps<"/cart">) {
  return (
    <main>
      <h1>Cart</h1>
      <Suspense fallback={<p>Loading cart…</p>}>
        <Quantity searchParams={searchParams} />
      </Suspense>
    </main>
  );
}
app/account/page.tsx
import { Suspense } from "react";
import { Greeting } from "@/components/greeting";

export default function AccountPage() {
  return (
    <main>
      <h1>Account</h1>
      <Suspense fallback={<p>…</p>}>
        <Greeting />
      </Suspense>
    </main>
  );
}
next build
Route (app)      Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ◐ /account
├ ƒ /api/price
├ ◐ /cart
└ ○ /products           15m      1y


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
playwright.config.ts
import { defineConfig } from "@playwright/test";

const port = Number(process.env.PORT ?? 3000);

export default defineConfig({
  testDir: "e2e",
  use: {
    baseURL: `http://127.0.0.1:${port}`,
    channel: "chrome", // the installed Google Chrome; or run `npx playwright install` for bundled browsers
  },
  webServer: {
    // Test the production build, not next dev
    command: `npm run build && npm run start -- -H 127.0.0.1 -p ${port}`,
    url: `http://127.0.0.1:${port}`,
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});
e2e/shop.spec.ts
import { expect, test } from "@playwright/test";

test("the async products page renders on the server", async ({ page }) => {
  await page.goto("/products");
  await expect(page.getByRole("listitem")).toHaveText(["Mug: $12.00", "T-shirt: $25.00"]);
});

test("the picker navigates to the cart on the client", async ({ page }) => {
  await page.goto("/");
  await page.getByRole("button", { name: "+" }).click();
  await page.getByRole("button", { name: "Go to cart" }).click();
  await expect(page).toHaveURL("/cart?qty=2");
  await expect(page.getByText("Items in cart: 2")).toBeVisible();
});

test("the account page reads the cookie on the server", async ({ page, context, baseURL }) => {
  await context.addCookies([{ name: "user", value: "rohit", url: baseURL! }]);
  await page.goto("/account");
  await expect(page.getByText("Hello, rohit")).toBeVisible();
});
Result (PORT=4512 npx playwright test --reporter=list)
Running 3 tests using 1 worker

  ✓  1 e2e/shop.spec.ts:3:5 › the async products page renders on the server (290ms)
  ✓  2 e2e/shop.spec.ts:8:5 › the picker navigates to the cart on the client (283ms)
  ✓  3 e2e/shop.spec.ts:16:5 › the account page reads the cookie on the server (191ms)

  3 passed (7.5s)
What’s happening
  1. webServer makes Playwright run npm run build && npm run start itself and wait until the URL answers, so the tests hit the production build: the same route table as above, the same caching and streaming as users get. reuseExistingServer lets you keep a server running locally; on CI (CI set) it always starts a fresh one.
  2. channel: "chrome" uses the Google Chrome already installed on the machine. Without it, Playwright uses its own browsers, which you download once with npx playwright install (npx playwright install-deps on Linux CI).
  3. Test 1 loads /products, the async Server Component that couldn't be unit-tested, and checks both list items. The server ran the component; the test only sees the HTML and the DOM.
  4. Test 2 is the full version of the unit test: real clicks after hydration, a real router.push, the URL becoming /cart?qty=2, and the cart's Suspense boundary streaming in "Items in cart: 2" from searchParams.
  5. Test 3 sets a user=rohit cookie on the browser context before loading /account, so cookies() on the server reads it and the streamed Greeting says "Hello, rohit". No mocks anywhere.
  6. To check the tests weren't flaky I ran --repeat-each=10 --workers=4: 30 of 30 passed in 8.9 s. Expect E2E suites to be slower and fewer than unit tests; keep them for flows that cross the server.
AdvancedJest instead of Vitest

Jest is still everywhere in existing codebases. Next.js ships next/jest (since Next.js 12), which configures Jest to compile with the Next.js compiler (SWC), mock CSS, image and next/font imports, load .env files and next.config, and ignore .next.

jest.config.mjs
import nextJest from "next/jest.js";

// Loads next.config and .env files, and compiles TS/JSX with the Next.js compiler (SWC)
const createJestConfig = nextJest({ dir: "./" });

export default createJestConfig({
  testEnvironment: "jsdom",
  testMatch: ["<rootDir>/jest/**/*.test.tsx"],
  moduleNameMapper: { "^@/(.*)$": "<rootDir>/src/$1" },
});
jest/quantity-picker.test.tsx
import { fireEvent, render, screen } from "@testing-library/react";
import { QuantityPicker } from "@/components/quantity-picker";

const mockPush = jest.fn(); // babel-jest only lets a hoisted jest.mock factory use variables named mock…
jest.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush }) }));

test("navigates to the cart with the quantity", () => {
  render(<QuantityPicker />);
  fireEvent.click(screen.getByRole("button", { name: "+" }));
  fireEvent.click(screen.getByRole("button", { name: "Go to cart" }));
  expect(mockPush).toHaveBeenCalledWith("/cart?qty=2");
});
Result (npx jest)
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.324 s, estimated 1 s
Ran all test suites.
What’s happening
  1. nextJest({ dir: "./" }) returns a function that merges your options with the Next.js defaults; it's async under the hood because it loads next.config, which is why the file exports createJestConfig(…) rather than a plain object.
  2. moduleNameMapper maps @/… to src/…: next/jest doesn't read tsconfig.json paths for you. testMatch keeps Jest on its own folder, away from the Vitest and Playwright files.
  3. jest.mock is hoisted like vi.mock. Under babel-jest, a hoisted factory may only use outside variables whose names start with mock, hence mockPush. Under next/jest's SWC transform the version named push also passed when I tried it, but the prefix keeps the test portable.
  4. fireEvent.click is the lower-level event helper; user-event works under Jest too. The Jest and Vitest APIs line up almost one to one (jest.fn / vi.fn, jest.mock / vi.mock), so the same limits apply: no async Server Components, no request APIs.
AdvancedTest the production build, not next dev
AdvancedEnvironment variables in tests
AdvancedTesting data loading in the Pages Router

#Deploying: Vercel vs self-hosting

Added

next build doesn't produce a folder of HTML you can drop anywhere. It produces a server program (next start) plus static assets, prerendered pages and shells, and caches that the server reads and refreshes at runtime. Deploying a Next.js app means giving that program what it needs: a Node.js process, streaming HTTP all the way to the browser, somewhere for caches to live, and ideally a CDN in front.

The bundled docs give the most useful mental model, functional fidelity versus performance fidelity. One next start process runs every feature correctly: Server Components, ISR, Partial Prerendering, Cache Components, Server Actions, proxy.ts and after(). What differs between platforms is how fast and consistent those features are at scale: the static shell served from a CDN edge instead of your origin, a revalidation reaching every region in a second, instances scaling up per route. Vercel fulfils that contract for you; self-hosting means fulfilling it yourself. Neither unlocks features the other lacks.

Deployment optionFeature support
Node.js server (next build + next start)All
Docker containerAll
Static export (output: 'export')Limited: no server features
Adapters (Vercel, Bun verified; Cloudflare, Netlify and others have their own integrations)Varies; verified adapters run the Next.js compatibility test suite

Here is what the platform has to provide for each feature, from the bundled "Deploying to platforms" guide:

FeatureStreamingShared cacheEdge stitchingNote
Server ComponentsRequiredNoNo
ISR, time-basedNoRecommendedNoworks per instance without one
ISR, on-demandNoRecommendedNotag invalidation must reach every instance
Partial PrerenderingRequiredRecommendedOptionalshell at the edge, holes from origin
Cache Components ('use cache')RequiredRecommendedNo
ProxyNoNoNoedge or origin
Server ActionsRequiredNoNoPOST with a streamed response
after()NoNoNoneeds graceful shutdown

The contract between Next.js and whatever sits in front of it is mostly HTTP headers. This small app has one page of each kind, and next start labels each one differently:

app/hourly/page.tsx
import { cacheLife } from "next/cache";

export default async function Hourly() {
  "use cache";
  cacheLife("hours"); // stale 5 min, revalidate 1 h, expire 1 day
  return <h1>Rates at {new Date().toISOString()}</h1>;
}
Result (next start, then curl -sD - -o /dev/null for each path; headers that matter)
== GET /
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
Cache-Control: s-maxage=31536000
== GET /hourly
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
Cache-Control: s-maxage=3600, stale-while-revalidate=82800
== GET /account
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-stale-time: 300
x-nextjs-prerender: 1
x-nextjs-postponed: 1
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
What’s happening
  1. / has no data, so it was prerendered once at build and never changes: Cache-Control: s-maxage=31536000 tells a shared cache (a CDN) it may keep it for a year. Browsers ignore s-maxage; it's only for shared caches.
  2. /hourly caches its whole page with 'use cache' and cacheLife("hours") (stale 5 minutes, revalidate 1 hour, expire 1 day). Next.js turns that into s-maxage=3600, stale-while-revalidate=82800: a CDN may serve it fresh for 3,600 s, then serve it stale while refetching for another 82,800 s (one day minus one hour, expire − revalidate). That is ISR expressed in standard HTTP.
  3. /account reads a cookie, so it's a Partial Prerender: the shell is static but the response as a whole is per user. Next.js marks it private, no-cache, no-store, so no CDN may store it, and x-nextjs-postponed: 1 says dynamic holes were streamed after the shell.
  4. x-nextjs-cache: HIT is Next.js's own server cache (the prerendered file was served); x-nextjs-stale-time: 300 is for the client router cache. Neither is for a CDN.
  5. Vary lists the request headers that change the response (rsc decides HTML or RSC payload). A CDN that respects s-maxage and stale-while-revalidate and keys on Vary can cache the first two pages correctly and must pass the third through. Getting that right is most of "what a platform has to do".
AdvancedThe static and per-user pages

The other two pages

app/page.tsx
export default function Home() {
  return <h1>Home: no data, fully static</h1>;
}
app/account/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

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

export default function Account() {
  return (
    <main>
      <h1>Account</h1>
      <Suspense fallback={<p>…</p>}>
        <Greeting />
      </Suspense>
    </main>
  );
}
next build
Route (app)      Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ◐ /account
└ ○ /hourly              1h      1d


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
What’s happening
  1. The build agrees with the headers: / and /hourly are ○ (static), and /hourly carries Revalidate 1h, Expire 1d from its cacheLife profile.
  2. /account is ◐: the <h1> and the fallback are in the prerendered shell, and Greeting is a postponed hole filled per request.
  3. The same route table is what a platform's adapter reads (as build output, not text) to decide what goes to the CDN, what needs a function, and what needs both.
AdvancedThe RSC variant of the same URL

Client-side navigation asks the same URL for the RSC payload instead of HTML, which is why a CDN has to care about more than the path:

Result (curl -sD - -o /dev/null -L -H "rsc: 1" http://127.0.0.1:4512/hourly)
HTTP/1.1 307 Temporary Redirect
location: /hourly?_rsc
HTTP/1.1 200 OK
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
Cache-Control: s-maxage=3600, stale-while-revalidate=82800
Content-Type: text/x-component
What’s happening
  1. A request with rsc: 1 but no _rsc search parameter gets a 307 to /hourly?_rsc: Next.js insists on that parameter (a hash of the headers that affect the response) so that CDNs which ignore Vary still give HTML and RSC different cache keys. Here the parameter came back with no value, since my request carried none of the other router headers; a browser's navigation requests carry them.
  2. Following the redirect returns text/x-component, the RSC payload, with the same Cache-Control as the HTML. The docs say to cache both variants together, with the same TTL, and to purge both on revalidation, or users see one render's HTML and another's navigation data.
  3. Platform rules from the bundled CDN guide: forward the rsc header, keep _rsc in the cache key (some CDNs strip query strings by default), and follow the 307.
AdvancedWhat Vercel takes off your plate

Vercel builds the app with its adapter (the docs stress it uses the same public adapter API as everyone else) and maps the build output onto its own infrastructure. In practice that means you don't operate:

  • the CDN in front, with static assets and prerendered pages pushed to it and the HTML/RSC variants keyed correctly;
  • serving the PPR static shell from the edge and stitching the streamed holes in from compute ("edge stitching", a performance optimisation in the table above);
  • a shared cache, so ISR and 'use cache' entries and tag revalidations are consistent everywhere;
  • image optimisation, scaling, rolling deploys with version-skew protection, and preview deployments per branch.

The trade-offs are the usual managed-platform ones: cost at high traffic, less control over the runtime, and vendor-specific limits. Interviewers mostly want to hear that you know these are operational conveniences, not extra framework features.

AdvancedWhat self-hosting has to provide
  • Node.js 20.9 or later (Next.js 16's minimum) and a process manager or container platform that restarts crashed processes.
  • A reverse proxy (nginx or a load balancer) in front, which the docs recommend for malformed requests, slow-connection attacks, payload limits and rate limiting.
  • Streaming end to end. Proxies and load balancers must not buffer responses, or Server Components, Server Actions and the PPR shell arrive all at once at the end. For nginx the docs set X-Accel-Buffering: no through headers() in next.config.
  • sharp for next/image optimisation (installed with next as an optional dependency).
  • For more than one instance: a shared cache with tag coordination, the same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY and deploymentId on every instance (next two topics).
  • Graceful shutdown: send SIGTERM/SIGINT and allow 10–30 seconds (the docs' figure) so in-flight requests and after() callbacks finish.
  • A CDN that respects Cache-Control, Vary and _rsc, plus a purge call after on-demand revalidation (Next.js can't purge your CDN for you).
AdvancedDeployment adapters, stable in 16.2

Adapters

Since Next.js 16.2 the Deployment Adapter API is stable: adapterPath in next.config (or the NEXT_ADAPTER_PATH environment variable, so a platform can inject it) points at a module that hooks into the build and turns the standard output into platform-specific output. Together with cacheHandler and cacheHandlers it's the whole integration surface. A verified adapter is open source and runs the Next.js compatibility test suite; the docs list Vercel and Bun, with Cloudflare and Netlify working on verified adapters while offering their own integrations. Community projects such as OpenNext fill the same role for other clouds.

AdvancedA static export is not "self-hosting a server"
AdvancedServerless target and next export

#Self-hosting with output: standalone and Docker

Added

next start needs the project's entire node_modules to run, because it doesn't know which files your server code will actually require. In this project that's 439 MB. output: 'standalone' fixes that at build time: Next.js runs output file tracing (@vercel/nft), which statically follows every import, require and fs call from each route and from the server itself, and copies only the files those traces list into .next/standalone, along with a generated server.js that replaces next start.

The mental model is packing a suitcase from a checklist the build wrote. The .nft.json trace files are the checklist; .next/standalone is the packed suitcase you can carry to any machine with the same OS and CPU and run with plain node. Two things are deliberately left out: .next/static (the hashed JS and CSS) and public/, because the docs expect a CDN to serve them. If no CDN does, you copy them in yourself.

In Docker this becomes a three-stage build: install dependencies, build, then copy only the suitcase into a slim runtime image.

1 · deps COPY package*.json RUN npm ci Linux node_modules cached until the lockfile changes 2 · builder COPY . . RUN npm run build .next/standalone .next/static · public 3 · runner (image) server.js + traced node_modules + .next/static + public USER nextjs node_modules 3 folders only Measured: naive one-stage image (copy all, next start) 1.25 GB this image 400 MB, of which the node:24-bookworm-slim base is 350 MB
Only the traced server, the static assets and public/ reach the runtime image; build tools and the full node_modules stay behind in earlier stages.
next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  output: "standalone",
};

export default nextConfig;
Result (next build, then these commands in the project folder)
$ ls -A .next/standalone
.next
node_modules
package.json
server.js
$ du -sh .next/standalone/{.next,node_modules}
1.6M	.next
 39M	node_modules
$ du -sh .next/standalone/node_modules/* | sort -h | tail -4
968K	node_modules/sharp
1.5M	node_modules/react-dom
 18M	node_modules/@img
 18M	node_modules/next
$ ls .next/standalone/node_modules/@img
colour
sharp-darwin-arm64
sharp-libvips-darwin-arm64
$ du -sh node_modules .next/static public
439M	node_modules
632K	.next/static
 20K	public
What’s happening
  1. output: "standalone" is the only change to the template's config. next build runs as usual (same route table), then traces each route and the server and copies what the traces list into .next/standalone.
  2. The suitcase is 41 MB against 439 MB of node_modules. It has 13 packages (next, react, react-dom, sharp, styled-jsx, @swc/helpers, @next/env and a few small ones) instead of the 345 that npm ci installs, because TypeScript, ESLint and the compiler are build tools nothing at runtime loads.
  3. next alone is 18 MB: the server runtime. Another 18 MB is @img/sharp-libvips-darwin-arm64, the native image library behind next/image optimisation. It's traced because next requires sharp for image optimisation when it's installed, and it's the darwin-arm64 build because I built on a Mac. A suitcase is only valid for the OS and CPU it was packed on, which is one reason to build inside Docker.
  4. .next/standalone/.next (1.6 MB) holds the server bundles, manifests and prerendered pages, and server.js holds your next.config serialised as JSON (I could see "output":"standalone" and "cacheMaxMemorySize":52428800 in it). Config changes need a rebuild.
  5. .next/static (632 KB) and public/ (20 KB) are not in the suitcase. The next section shows what happens if you forget them.
AdvancedThe rest of the standalone app

The rest of the app

app/layout.tsx
import "./globals.css";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
app/page.tsx
import Image from "next/image";
import Link from "next/link";

export default function Home() {
  return (
    <main>
      <Image src="/globe.svg" alt="" width={32} height={32} />
      <h1>Shop</h1>
      <Link href="/status">Server status</Link>
    </main>
  );
}
app/status/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { hostname } from "node:os";

async function ServerInfo() {
  await connection(); // render this part per request, not at build time
  return (
    <p>
      Rendered by {hostname()} at {new Date().toISOString()}
    </p>
  );
}

export default function StatusPage() {
  return (
    <main>
      <h1>Status</h1>
      <Suspense fallback={<p>Checking…</p>}>
        <ServerInfo />
      </Suspense>
    </main>
  );
}
app/api/health/route.ts
import { connection } from "next/server";

export async function GET() {
  await connection();
  return Response.json({ ok: true, node: process.version, uptime: Math.round(process.uptime()) });
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/health
└ ◐ /status


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
What’s happening
  1. / is static and uses next/image and next/link, so its HTML references hashed CSS and JS under /_next/static/ and an image from public/.
  2. /status prerenders its heading and fallback; ServerInfo awaits connection() and streams per request, printing os.hostname(), which inside Docker is the container ID, a handy way to see which instance answered.
  3. /api/health awaits connection() so it runs per request (otherwise a GET handler with no request data can be prerendered at build); it's the endpoint a load balancer or orchestrator probes.
AdvancedRunning server.js before and after copying assets

Running server.js, before and after copying the static files

Shell
cd .next/standalone && PORT=4512 HOSTNAME=127.0.0.1 node server.js
Result (curl -s -o /dev/null -w "%{http_code} path" for each path)
▲ Next.js 16.4.3
- Local:         http://127.0.0.1:4512
- Network:       http://127.0.0.1:4512
200  /
404  /_next/static/chunks/1k4ge9w1zsfwm.css
404  /globe.svg
200  /api/health
--- after copying
200  /_next/static/chunks/1k4ge9w1zsfwm.css  Cache-Control: public, max-age=31536000, immutable
200  /globe.svg  Cache-Control: public, max-age=0
What’s happening
  1. server.js reads PORT and HOSTNAME (defaults 3000 and 0.0.0.0); here it's bound to 127.0.0.1:4512 so only this machine can reach it.
  2. The HTML for / is served (200) and /api/health works, but the page's stylesheet and globe.svg are 404: a browser would show unstyled, non-interactive HTML, because none of the hashed JS loads either.
  3. The fix from the docs: cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/. After a restart server.js serves them itself: hashed assets as public, max-age=31536000, immutable (safe forever, the name changes when the content does), public/ files as max-age=0 (their names don't change).
  4. In production you'd usually upload .next/static to a CDN or object store and set assetPrefix; the Dockerfile below copies both folders into the image so the container is self-sufficient.
The unstyled page

You deploy .next/standalone to a VM, run node server.js, and the home page appears as plain black-on-white HTML with no working buttons. The server logs show no errors. What happened, and where would you look?

Show answer

.next/static (and probably public/) weren't copied into the standalone folder, so every CSS and JS request is a 404. Look at the browser's network tab: /_next/static/chunks/….css and ….js return 404.

What’s happening
  1. The HTML is rendered by server.js from .next/standalone/.next/server, which is in the standalone output, so the document itself arrives (200).
  2. The document references /_next/static/chunks/<hash>.css and .js. Those live in .next/static, which standalone output deliberately doesn't copy, so the server answers 404 (exactly what I saw before copying).
  3. Without CSS the page is unstyled; without the JS chunks React never hydrates, so buttons with onClick do nothing. The server logs nothing because a 404 for a missing file isn't an error.
  4. Copy .next/static to .next/standalone/.next/static and public to .next/standalone/public (or serve both from a CDN with assetPrefix) and restart.
AdvancedThe Dockerfile

This is the multi-stage pattern from Next.js's with-docker example, written out and built with docker build -t nj12-shop . (Docker 29.4) in the same project folder. It isn't part of the checker's build, so it's shown untagged.

Dockerfile
FROM node:24-bookworm-slim AS base

# 1. Install dependencies from the lockfile only (cached until package*.json change)
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# 2. Build the app; .next/cache survives between builds in a BuildKit cache mount
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN --mount=type=cache,target=/app/.next/cache npm run build

# 3. Runtime image: only the standalone server, static assets and public/
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 --gid nodejs nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
.dockerignore
node_modules
.next
.git
Dockerfile
.dockerignore
npm-debug.log
.env*
Result (docker build, selected lines; then docker images)
#9 [deps 3/3] RUN npm ci
#9 7.155 added 345 packages, and audited 346 packages in 7s
#9 DONE 7.8s
#12 [builder 4/4] RUN --mount=type=cache,target=/app/.next/cache npm run build
#12 1.487 ✓ Compiled successfully in 1090ms
#12 DONE 2.9s
#14 [runner 4/5] COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
#14 DONE 0.1s

IMAGE              ID             DISK USAGE   CONTENT SIZE   EXTRA
nj12-shop:latest   d206471d79ff        400MB         93.3MB
IMAGE                   ID             DISK USAGE   CONTENT SIZE   EXTRA
node:24-bookworm-slim   a5f4366e8346        350MB         83.5MB
What’s happening
  1. deps copies only package.json and package-lock.json and runs npm ci. Docker caches a layer until its inputs change, so this 7.8-second step reruns only when the lockfile does. It installs the Linux builds of native packages (sharp-linux-arm64 here), which is why you never copy a Mac's node_modules into an image (.dockerignore excludes it).
  2. builder copies the dependencies and the source and runs next build. RUN --mount=type=cache,target=/app/.next/cache keeps Next.js's build cache (Turbopack's compiled artifacts) between builds outside the image: after I edited one page and rebuilt, npm ci was CACHED and the build step took 1.7 s instead of 2.9 s.
  3. runner starts again from the bare base image and copies three things: public/, .next/standalone (to /app, so server.js sits at the root) and .next/static (to /app/.next/static, where server.js looks). Build tools, the source and the full node_modules never reach it. Inside, ls /app/node_modules/@img showed sharp-libvips-linux-arm64 and sharp-linux-arm64.
  4. It runs as a non-root nextjs user (whoami in the container printed nextjs), sets NODE_ENV=production, and sets HOSTNAME=0.0.0.0. That last line matters more than it looks; see the gotcha below.
  5. The image is 400 MB on disk, 350 MB of it the node:24-bookworm-slim base: the app adds about 50 MB. For comparison I built the naive version (one stage: COPY . ., npm ci && npm run build, CMD ["npm", "start"]): 1.25 GB on disk, 332 MB compressed against 93 MB.
Result (docker run -d -p 127.0.0.1:4512:3000 nj12-shop: the log, the /api/health body, the /status paragraph)
▲ Next.js 16.4.3
- Local:         http://localhost:3000
- Network:       http://0.0.0.0:3000
✓ Ready in 0ms
{"ok":true,"node":"v24.22.0","uptime":2}
Rendered by 74974a5519e2 at 2026-10-25T18:04:03.497Z
What’s happening
  1. -p 127.0.0.1:4512:3000 publishes the container's port 3000 on the Mac's loopback only. Inside, server.js listens on 0.0.0.0:3000 because of ENV HOSTNAME=0.0.0.0.
  2. /api/health reports Node v24.22.0, the image's Node, not the Mac's v26: the container is self-contained.
  3. /status printed 74974a5519e2, the start of the container ID, because Docker sets each container's hostname to its ID. Behind a load balancer this tells you which container answered.
  4. docker stats showed about 52 MiB of memory for the idle server. I stopped and removed the test containers afterwards.
AdvancedWhen tracing misses a file

Tracing is static analysis, so it can't see files you load by a computed path (fs.readFile(join(dir, name))), or native binaries a package loads at runtime. Add them with outputFileTracingIncludes (keys are route globs, values are globs from the project root), and drop accidental inclusions with outputFileTracingExcludes:

next.config.js (excerpt, not built here)
module.exports = {
  outputFileTracingIncludes: {
    "/*": ["src/i18n/locales/**/*.json"],
    "/api/pdf": ["./node_modules/some-native-lib/bin/**/*"],
  },
  outputFileTracingExcludes: {
    "/api/*": ["src/temp/**/*"],
  },
};
What’s happening
  1. "/*" applies the include to every route's trace; "/api/pdf" only to that route. The values are resolved from the project root, even in a src/ project.
  2. Includes only affect routes that produce a server trace: fully static pages and Edge routes have nothing to add files to.
  3. Also watch base images: node:*-alpine uses musl, bookworm-slim uses glibc, and native packages like sharp ship separate builds for each. Building in the same image family you run in avoids sharp failing to load at runtime. The docs also note glibc systems may need a different memory allocator for image-heavy workloads.
AdvancedRunning several instances of the same image

A single container is the easy case. Behind a load balancer, four settings decide whether instances agree with each other:

SettingWhat breaks without it
Same build for every instanceEach next build gets a new build ID, new Server Action IDs and a new action encryption key; build once and run that image everywhere
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY (base64, 16/24/32 bytes) at buildEach build generates its own key for encrypting closed-over Server Action arguments; an instance from another build can't decrypt them: "Failed to find Server Action"
deploymentId (or NEXT_DEPLOYMENT_ID)During a rolling deploy, an old page can fetch new chunks or call new action IDs; with it, a mismatch turns client navigation into a full reload
A shared cache handlerEach instance has its own cache and its own idea of what's revalidated (next topic)

And for shutdown: send SIGTERM, keep routing away from the instance, and let it drain. The docs say the server finishes in-flight requests and pending after() callbacks before exiting, and recommend a 10–30 second drain period.

AdvancedDocker images before standalone output

#Caching when self-hosting

Added

Every Next.js cache you've met so far lives inside one server process. 'use cache' entries go into an in-memory LRU (the default cacheMaxMemorySize is 50 MB; server.js above showed 52428800); prerendered and ISR pages are files on that instance's disk; tag invalidations are remembered in that process's memory. With one instance that's all correct. With several, each instance has its own cache, so two users can see different data for the same URL, and revalidateTag() only clears the instance that happened to receive the call.

The mental model: each instance keeps its own notebook. A shared cache handler gives them one whiteboard for entries, plus a noticeboard of crossed-out tags that every instance reads before it answers a request. Next.js gives you two plug points, and interviewers like to check you know which is which:

  • cacheHandlers (plural, stable in 16): storage for 'use cache' entries. default backs 'use cache', remote backs 'use cache: remote', and you can add named ones ('use cache: sessions'). 'use cache: private' never uses a handler.
  • cacheHandler (singular, since 14.1): the server response cache: ISR and prerendered pages, Route Handler responses, the old fetch cache and unstable_cache, and (with images.customCacheHandler, 16.2) optimised images.
CDN (optional) caches s-maxage pages · you purge it after revalidating Load balancer Instance A handles POST /api/revalidate Instance B serves the next GET / Shared store (Redis, a database…) entries: key → bytes, tags, timestamp tags: prices → invalidated at 18:06:55 get / set updateTags get / set refreshTags
A shared handler stores entries where every instance can read them, and tag invalidations written by one instance are read by the others before each request.

This app caches a "prices" lookup with 'use cache' and exposes a revalidation endpoint. I built it once and ran two next start processes from the same build on ports 4512 (A) and 4612 (B), as a load balancer's pool would:

app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { cacheLife, cacheTag } from "next/cache";

async function getPrices() {
  "use cache";
  cacheTag("prices");
  cacheLife("hours");
  // Stands in for a slow database query
  return { computedAt: new Date().toISOString(), by: process.pid };
}

async function Prices() {
  await connection(); // run on every request, so the cache lookup happens at runtime
  const prices = await getPrices();
  return (
    <p>
      computed at {prices.computedAt} by pid {prices.by}, served by pid {process.pid}
    </p>
  );
}

export default function Home() {
  return (
    <main>
      <h1>Prices</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <Prices />
      </Suspense>
    </main>
  );
}
Result (two instances of one build, no cache handler configured; curl each in turn)
A: computed at 2026-10-25T18:06:49.208Z by pid 48354, served by pid 48354
A: computed at 2026-10-25T18:06:49.208Z by pid 48354, served by pid 48354
B: computed at 2026-10-25T18:06:50.285Z by pid 48352, served by pid 48352
POST A /api/revalidate -> {"revalidated":"prices","by":48354}
B: computed at 2026-10-25T18:06:50.285Z by pid 48352, served by pid 48352
A: computed at 2026-10-25T18:06:50.345Z by pid 48354, served by pid 48354
What’s happening
  1. Prices awaits connection(), so it renders on every request and calls getPrices() at runtime, where the 'use cache' lookup goes to the configured handler: here the default in-memory one, private to each process.
  2. A's first request misses, runs getPrices() (stamping 18:06:49.208Z and pid 48354) and stores the entry in A's memory. A's second request, a second later, is a hit: same timestamp.
  3. B is another process with an empty cache, so its first request computes its own entry at 18:06:50.285Z. The same URL already shows two different results, depending on which instance answers.
  4. POST /api/revalidate lands on A: revalidateTag("prices", { expire: 0 }) marks the tag expired in A. B's next request still serves its 18:06:50.285Z entry; it never heard about the invalidation. A's next request misses and recomputes (18:06:50.345Z).
  5. With cacheLife("hours"), B would keep serving its copy for up to an hour (and stale for up to a day). That's the multi-instance bug the docs warn about, and why "Shared cache: Recommended" appears in the platform table.
AdvancedThe revalidation endpoint
app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";

export async function POST() {
  revalidateTag("prices", { expire: 0 }); // next read is a miss, no stale serving
  return Response.json({ revalidated: "prices", by: process.pid });
}
next build
Route (app)
┌ ◐ /
├ ○ /_not-found
└ ƒ /api/revalidate


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
What’s happening
  1. Route Handlers can't use updateTag (Server Actions only), so this uses revalidateTag with { expire: 0 }: the docs' form for "the next request must miss", rather than "max", which would serve stale content while refreshing.
  2. A real endpoint would check a secret or an authenticated user before revalidating anything; this one is wide open to keep the demo short.
  3. / is ◐: the heading and fallback are the static shell, and the prices are a hole filled per request, which is what makes the runtime cache lookup visible.
AdvancedA shared handler: one whiteboard for every instance

The fix is a cacheHandlers.default whose store every instance can reach. In production that's Redis or similar; to keep this runnable without extra services, the store is a folder both processes share (the SHARED_CACHE_DIR environment variable), which plays the same role. The handler follows the interface in the bundled cacheHandlers reference: get, set, refreshTags, getExpiration and updateTags.

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

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  cacheHandlers: {
    // 'use cache' entries go to a store every instance shares (src/ because this project uses a src folder)
    default: require.resolve("./src/cache-handlers/shared-folder.js"),
  },
};

export default nextConfig;
cache-handlers/shared-folder.js
// A 'use cache' handler whose store is a folder all instances share: a stand-in for Redis.
const fs = require("node:fs/promises");
const path = require("node:path");
const { createHash } = require("node:crypto");

const dir = process.env.SHARED_CACHE_DIR || "/tmp/next-shared-cache";
const tagsFile = path.join(dir, "tags.json");
let tagTimes = {}; // tag -> { stale, expired } in ms, synced from tags.json by refreshTags()

const fileFor = (key) => path.join(dir, createHash("sha256").update(key).digest("hex") + ".json");

async function writeAtomic(file, text) {
  await fs.mkdir(dir, { recursive: true });
  const tmp = `${file}.${process.pid}.tmp`;
  await fs.writeFile(tmp, text);
  await fs.rename(tmp, file); // readers never see half a file
}

async function readTags() {
  try {
    tagTimes = JSON.parse(await fs.readFile(tagsFile, "utf8"));
  } catch {
    // no tags.json yet, or the store is down: keep the tag times we already have
  }
}

module.exports = {
  async get(cacheKey) {
    let stored;
    try {
      stored = JSON.parse(await fs.readFile(fileFor(cacheKey), "utf8"));
    } catch {
      return undefined; // a miss; never throw from get()
    }
    let revalidate = stored.revalidate;
    for (const tag of stored.tags) {
      const { stale = 0, expired = 0 } = tagTimes[tag] ?? {};
      if (expired > stored.timestamp && expired <= Date.now()) return undefined;
      if (stale > stored.timestamp) revalidate = -1; // serve, refresh in the background
    }
    const bytes = new Uint8Array(Buffer.from(stored.value, "base64"));
    const value = new ReadableStream({
      start(controller) {
        controller.enqueue(bytes);
        controller.close();
      },
    });
    return { ...stored, revalidate, value }; // a fresh stream for every hit
  },

  async set(cacheKey, pendingEntry) {
    const entry = await pendingEntry;
    const bytes = Buffer.from(await new Response(entry.value).arrayBuffer()); // read the stream once
    await writeAtomic(fileFor(cacheKey), JSON.stringify({ ...entry, value: bytes.toString("base64") }));
  },

  refreshTags: readTags, // once per request, before the first read: learn other instances' invalidations

  async getExpiration(tags) {
    return Math.max(...tags.map((tag) => tagTimes[tag]?.expired ?? 0), 0);
  },

  async updateTags(tags, durations) {
    await readTags();
    const now = Date.now();
    for (const tag of tags) {
      const times = tagTimes[tag] ?? {};
      if (durations) {
        times.stale = now;
        if (durations.expire !== undefined) times.expired = now + durations.expire * 1000;
      } else {
        times.expired = now;
      }
      tagTimes[tag] = times;
    }
    await writeAtomic(tagsFile, JSON.stringify(tagTimes));
  },
};
app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { cacheLife, cacheTag } from "next/cache";

async function getPrices() {
  "use cache";
  cacheTag("prices");
  cacheLife("hours");
  // Stands in for a slow database query
  return { computedAt: new Date().toISOString(), by: process.pid };
}

async function Prices() {
  await connection(); // run on every request, so the cache lookup happens at runtime
  const prices = await getPrices();
  return (
    <p>
      computed at {prices.computedAt} by pid {prices.by}, served by pid {process.pid}
    </p>
  );
}

export default function Home() {
  return (
    <main>
      <h1>Prices</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <Prices />
      </Suspense>
    </main>
  );
}
app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";

export async function POST() {
  revalidateTag("prices", { expire: 0 }); // next read is a miss, no stale serving
  return Response.json({ revalidated: "prices", by: process.pid });
}
next build
Route (app)
┌ ◐ /
├ ○ /_not-found
└ ƒ /api/revalidate


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
What’s happening
  1. cacheHandlers.default points every 'use cache' in the app at this module (the page and route are unchanged from the previous app). The path starts with ./src/ because this project keeps its code in a src folder.
  2. set(cacheKey, pendingEntry) awaits the entry, reads its value stream once into bytes, and writes the entry as JSON with the bytes in base64, through a temporary file and rename so another instance never reads half a file. Keys are hashed into file names because real cache keys are long.
  3. get(cacheKey) reads the file, or returns undefined for a miss: the docs are explicit that get must never throw, because an exception becomes a render error rather than a miss. It then checks the entry's tags against the tag times: expired after the entry was written means a miss; stale means revalidate: -1 (serve now, refresh in the background). It returns a new ReadableStream for each hit, because a stream can be read only once.
  4. updateTags(tags, durations) is what revalidateTag and updateTag call. It records "invalidated at now" per tag in tags.json, the noticeboard. durations present means stale-while-revalidate until expire seconds pass; absent means expire immediately.
  5. refreshTags() runs once per request, before the first cache read, and reloads the noticeboard. That's how instance B learns about an invalidation that A recorded. getExpiration(tags) answers the same question for the soft tags behind revalidatePath.
  6. No age check in get: Next.js compares timestamp with expire and revalidate itself. A real handler still needs eviction (a TTL from expire, or an LRU), because Next.js never deletes from your store.
Instance B after a revalidation on A

Same two-instance setup, now with the shared handler and SHARED_CACHE_DIR pointing both at one folder. A computes and caches the prices; B serves them; then POST /api/revalidate goes to A. What does B show on its next request, and what does A show after that?

Show answer

B recomputes (the shared tag time tells it its copy is expired) and writes a new entry; A's next request then serves B's new entry, computed by B's pid. One result for the whole pool.

Result (the same script as before, with the shared handler)
A: computed at 2026-10-25T18:06:54.578Z by pid 48556, served by pid 48556
A: computed at 2026-10-25T18:06:54.578Z by pid 48556, served by pid 48556
B: computed at 2026-10-25T18:06:54.578Z by pid 48556, served by pid 48536
POST A /api/revalidate -> {"revalidated":"prices","by":48556}
B: computed at 2026-10-25T18:06:55.729Z by pid 48536, served by pid 48536
A: computed at 2026-10-25T18:06:55.729Z by pid 48536, served by pid 48556
What’s happening
  1. A's first request misses in the shared folder, computes (18:06:54.578Z, pid 48556) and set writes the file. A's second request is a hit.
  2. B's first request finds the same file: "computed by pid 48556, served by pid 48536". Both instances now agree.
  3. The POST on A calls updateTags(["prices"], { expire: 0 }), which writes {"prices":{"stale":…,"expired":…}} to tags.json. I checked the folder afterwards: one entry file and tags.json.
  4. B's next request runs refreshTags() first, reads that prices expired after its entry's timestamp, so get returns undefined; B recomputes (18:06:55.729Z, pid 48536) and writes the new entry.
  5. A's next request reads the shared entry B just wrote: computed by 48536, served by 48556. Without the noticeboard (refreshTags), B would have kept serving the old file even though it was shared.
AdvancedWhat a production handler adds

My folder handler is a teaching sketch: correct for the demo, but missing what the docs require for production:

  • A store every instance reaches, such as Redis, Memcached, DynamoDB or S3. A Map inside the process just recreates per-instance caches.
  • Bounded size: a TTL derived from expire, or an LRU; Next.js never deletes entries.
  • Defensive errors: get returns undefined on any failure, refreshTags catches its own errors, and set failures are caught (the response is already streaming, so a failed write only costs a future miss).
  • Coalescing of concurrent misses for the same key, so a popular entry expiring doesn't send a hundred renders to the database at once. The bundled docs point to community Redis handlers that add this, plus GET timeouts.

The docs don't say what an uncaught refreshTags error does, so I made the folder handler's refreshTags throw "store down" and rebuilt. GET / still answered 200 with the static shell ("Loading…"), the server logged ⨯ Error: store down, the payload carried an error digest for the prices hole, and the response never finished: curl was still waiting when its 60-second timeout ran out. A store outage inside refreshTags turns every request that reads the cache into a hung page, so catch the error and fall back to the tag times you already have.

AdvancedThe other handler: cacheHandler for pages and ISR

'use cache' entries aren't the only cache. The prerendered HTML and RSC of ISR pages (the /hourly page in the deploying topic), Route Handler responses and the pre-16 fetch/unstable_cache data go through cacheHandler (singular), whose default stores files on each instance's disk plus memory. Its interface is older and different:

next.config.js (shape from the docs, not built here)
module.exports = {
  cacheHandler: require.resolve("./cache-handler.js"), // class with get, set, revalidateTag, resetRequestCache
  cacheMaxMemorySize: 0, // don't keep a second, per-instance copy in memory
};
What’s happening
  1. get(key, ctx) receives an opaque key and a ctx.kind such as APP_PAGE, APP_ROUTE, PAGES, FETCH or IMAGE. The docs warn that keys are not pathnames (in 16.4 response keys include a hash of the source route), so store them unchanged.
  2. Entries contain Buffer (for example rscData) and Map (segmentData) values that JSON.stringify loses. The docs list the resulting errors on client navigation, segmentData.get is not a function and failed to pipe response, so a remote handler must serialise those types properly.
  3. revalidateTag(tags) must invalidate on every instance, so the tag index (page entries carry tags in their x-next-cache-tags header) belongs in the shared store too.
  4. cacheMaxMemorySize: 0 turns off the in-memory copy that would otherwise make each instance keep serving its own version.
AdvancedA CDN in front

A CDN multiplies the problem: now there's a cache Next.js doesn't control at all.

  • It can cache what Next.js marks cacheable: s-maxage=31536000 for static pages and s-maxage, stale-while-revalidate for ISR pages (both measured in the deploying topic), and must pass private, no-store responses through.
  • On-demand revalidation does not reach the CDN. revalidateTag and revalidatePath invalidate Next.js's caches; the CDN keeps its copy until s-maxage runs out. Pair every on-demand revalidation with a purge call to your CDN for both the HTML and the RSC variants of the affected URLs.
  • Cache HTML and RSC together, with the same TTL, keyed on Vary or on _rsc. Run proxy.ts decisions (auth, rewrites) before the CDN cache, or bypass caching for those routes.
  • PPR at the edge needs more: the CDN serves the cached shell, then sends a POST with next-resume: 1 and the opaque postponedState as the body to get only the dynamic holes, and concatenates the streams. That's what "edge stitching" means in the platform table; plain next start does both in one pass. I didn't build a resuming CDN here.
AdvancedRemote caching needs a remote handler
AdvancedHow cache handlers evolved, 12.2 to 16

#Build and runtime configuration

Added

Every value in a Next.js app is read at one of three moments: during next build (on CI), when the server starts (next start or server.js), or during a request. Whatever is read at build time is frozen into the artefact: static pages are rendered with it, NEXT_PUBLIC_ variables are pasted into the browser JavaScript as string literals, and next.config choices are serialised into the output.

The mental model is photographs versus a live camera. A static page and every NEXT_PUBLIC_ value are photographs taken on the build machine; you can promote that image from staging to production, but the photographs don't change. Code that runs per request (after connection(), cookies() and friends) is the live camera: it reads process.env on the server that answers. If you want one Docker image for every environment, environment-specific values must come from the live camera.

app/page.tsx
import { Banner } from "@/components/banner";

export default function Home() {
  // A static page renders once, during next build
  return (
    <main>
      <p>API_URL (static page): {process.env.API_URL}</p>
      <Banner />
    </main>
  );
}
Result (built with one set of values, started with another; text of each <p>)
$ API_URL=https://build.example NEXT_PUBLIC_BANNER=built-in-ci DEPLOYMENT_ID=v42 next build
$ API_URL=https://prod.example NEXT_PUBLIC_BANNER=set-at-start DEPLOYMENT_ID=v43 next start
GET /         API_URL (static page): https://build.example
              Banner (client): built-in-ci
GET /runtime  API_URL (request time): https://prod.example
What’s happening
  1. / reads no request data, so it's static: next build rendered it once, with the build machine's API_URL=https://build.example, and saved the HTML. next start with https://prod.example serves that saved HTML, so the page still says build.example.
  2. Banner is a Client Component reading process.env.NEXT_PUBLIC_BANNER. At build, Next.js replaced that expression with the literal "built-in-ci" in the browser bundle: grep found ["Banner (client): ","built-in-ci"] in .next/static/chunks/32lb9sew9nu4i.js. Starting the server with set-at-start changes nothing, in the HTML or in the browser.
  3. /runtime awaits connection() before reading process.env.API_URL, so that part renders per request on the running server and shows https://prod.example. This is the docs' pattern for runtime configuration in the App Router.
  4. Only variables prefixed with NEXT_PUBLIC_ ever reach the browser. API_URL stays on the server whichever moment reads it; the moment only decides which value you get.
  5. The rule for a promotable image: read environment-specific values at request time on the server, and pass what the browser needs down as props (or from an endpoint), instead of baking them in with NEXT_PUBLIC_.
AdvancedBanner, runtime page and config

The rest of the app

components/banner.tsx
"use client";

export function Banner() {
  // NEXT_PUBLIC_ values are replaced with string literals in the browser bundle at build time
  return <p>Banner (client): {process.env.NEXT_PUBLIC_BANNER}</p>;
}
app/runtime/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";

async function ApiUrl() {
  await connection(); // render at request time, so process.env is read now
  return <p>API_URL (request time): {process.env.API_URL}</p>;
}

export default function RuntimePage() {
  return (
    <Suspense fallback={<p>…</p>}>
      <ApiUrl />
    </Suspense>
  );
}
next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  deploymentId: process.env.DEPLOYMENT_ID, // read when next build runs
  poweredByHeader: false,
};

export default nextConfig;
next build
Route (app)
┌ ○ /
├ ○ /_not-found
└ ◐ /runtime


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
What’s happening
  1. The route table confirms the split: / is ○ (rendered at build), /runtime is ◐ (shell at build, the ApiUrl hole per request).
  2. Inlining is textual: only the literal process.env.NEXT_PUBLIC_BANNER is replaced. The docs point out that process.env[name] or const env = process.env; env.NEXT_PUBLIC_X are not inlined, and are undefined in the browser.
  3. deploymentId: process.env.DEPLOYMENT_ID and poweredByHeader: false are read when next.config.ts runs. The responses had no X-Powered-By: Next.js header (the standalone server earlier, with the default config, sent one).
AdvancedWhere environment variables come from

next build, next start and next dev load .env* files from the project root (not from src/) into process.env, and stop at the first place a variable is found:

OrderSourceNote
1process.env (the real environment)always wins, which is how Docker -e and CI secrets override files
2.env.$(NODE_ENV).locale.g. .env.production.local
3.env.localskipped when NODE_ENV is test
4.env.$(NODE_ENV)e.g. .env.production, committed
5.envcommitted defaults

NODE_ENV is development for next dev and production for every other command; .env files can reference each other with $VARIABLE. Secrets don't belong in committed files, and anything prefixed NEXT_PUBLIC_ must be assumed public. More on that in Environment variables and secrets.

Advancednext.config options that matter when you deploy
OptionWhat it doesRead
output: 'standalone' | 'export'traced server folder, or plain static filesbuild
basePath, assetPrefixserve the app under /shop; load /_next/static from a CDN or a zone prefixbuild
images (remotePatterns, loader, unoptimized)what next/image may fetch and who optimises itbuild and runtime
headers(), redirects(), rewrites()routing rules applied before your code runsbuild: restarting with a different env didn't change a rewrite (zones topic)
cacheComponents, cacheLifethe caching model and its named profilesbuild
cacheHandler, cacheHandlers, cacheMaxMemorySizewhere caches live, per instance or sharedruntime
deploymentId, generateBuildIdversion-skew detection, stable IDs across rebuildsbuild, and deploymentId again at start (see gotcha)
serverExternalPackages, transpilePackageskeep native/Node packages out of the bundle; compile workspace packagesbuild
outputFileTracingRoot, outputFileTracingIncludeswhat standalone output traces in a monorepobuild
adapterPatha deployment adapter (stable in 16.2)build
poweredByHeader, compress, generateEtagsthe X-Powered-By header, gzip in Node, ETagsruntime
AdvancedBuild caching in CI

next build keeps a cache in .next/cache (in 16, mostly Turbopack's file-system cache of compiled modules). CI machines start empty, so unless the folder is restored between runs, every build is a cold build (the docs say you may see a "No Cache Detected" message). On this small app:

Result (in the env-timing app; /usr/bin/time -p, build output filtered)
$ rm -rf .next && time next build
✓ Compiled successfully in 1043ms
real 2.41
$ du -sh .next/cache/turbopack
 23M	.next/cache/turbopack
$ time next build
✓ Compiled successfully in 84ms
real 1.49
.github/workflows/ci.yml (excerpt from the bundled docs, not run here)
- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      ${{ github.workspace }}/.next/cache
    key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
    restore-keys: |
      ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
What’s happening
  1. The first build compiled everything (1,043 ms of compilation) and left 23 MB in .next/cache/turbopack. The second, with the cache present, compiled in 84 ms; the whole command went from 2.41 s to 1.49 s because type-checking and prerendering still ran. On a big app the compile share, and the saving, is much larger. These are single runs on my Mac, not a benchmark.
  2. The GitHub Actions key changes whenever the lockfile or any source file changes, so each commit saves a new cache; restore-keys falls back to the latest cache with the same lockfile, which is still mostly valid because Turbopack only recompiles what changed.
  3. The bundled CI guide has the same recipe for CircleCI, GitLab, Bitbucket, Azure, Jenkins, Travis, AWS CodeBuild and Heroku: it's always "persist .next/cache between builds". Vercel does it automatically. In Docker, the cache mount in the Dockerfile above does the same job.
One image, two environments

CI builds one Docker image with NEXT_PUBLIC_API_URL=https://api.staging.example. Ops runs the same image in production with -e NEXT_PUBLIC_API_URL=https://api.example. A Client Component calls fetch(process.env.NEXT_PUBLIC_API_URL + "/orders"). Which API does the browser call in production?

Show answer

Staging: https://api.staging.example/orders. The value was pasted into the JavaScript bundle at build time; the container's environment only affects server code that reads process.env at runtime.

What’s happening
  1. During next build, process.env.NEXT_PUBLIC_API_URL in client code was replaced with the literal "https://api.staging.example", exactly as NEXT_PUBLIC_BANNER became "built-in-ci" in the run above.
  2. The production container's -e NEXT_PUBLIC_API_URL=… sets the variable for the Node process. Server code reading it per request would see the production URL; the browser bundle doesn't read anything, it already contains the string.
  3. Fixes: build one image per environment (and accept that it isn't promotable), or keep the URL server-side and pass it down: a Server Component reads process.env.API_URL after connection() and gives it to the Client Component as a prop, or the browser calls a same-origin Route Handler that proxies to the right API.
AdvancedRuntime config before Next.js 16

#Multi-zones, monorepos and upgrading

Added

Three things large Next.js codebases run into once they outgrow one app and one version. Multi-zones split one domain into several Next.js apps, each owning a set of paths (/blog/* from one app, everything else from another), built and deployed independently: micro-frontends at the route level. A monorepo (npm/pnpm workspaces, usually with Turborepo) is about something else: keeping those apps and their shared packages in one repository so code is shared and builds are cached. Upgrading is about moving any of them across Next.js versions without breaking, which since 15 has mostly meant codemods plus the version guides.

The mental model for zones is a shopping mall with separate shops behind one entrance. Inside a shop you move from aisle to aisle without leaving (soft, client-side navigation); walking into another shop means going through its door (a full page load, new JavaScript). Pages visited together belong in the same shop.

Browser one domain Shop zone :4512 / → its own page rewrites: /blog /blog/:path+ /blog-static/:path+ Blog zone :4612 /blog, /blog/[slug] assetPrefix: /blog-static inside a zone: <Link>, soft navigation · across zones: <a>, full page load
The user only ever talks to one origin; the shop zone forwards the blog's paths and the blog's prefixed assets to the blog app.
next.config.ts
import type { NextConfig } from "next";

const BLOG_URL = process.env.BLOG_URL ?? "http://127.0.0.1:4001";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  async rewrites() {
    return [
      { source: "/blog", destination: `${BLOG_URL}/blog` },
      { source: "/blog/:path+", destination: `${BLOG_URL}/blog/:path+` },
      { source: "/blog-static/:path+", destination: `${BLOG_URL}/blog-static/:path+` },
    ];
  },
};

export default nextConfig;
Result (blog zone on :4612; shop zone on :4512, built with BLOG_URL=http://127.0.0.1:4612; every request to :4512)
$ curl -s :4512/ | grep -o '<h1>[^<]*</h1>'
<h1>Shop zone</h1>
$ curl -sD - :4512/blog     (status line, cache headers, then the <h1>)
HTTP/1.1 200 OK
x-nextjs-cache: HIT
cache-control: s-maxage=31536000
<h1>Blog zone</h1>
asset: /blog-static/_next/static/chunks/3xqbxqlv0j2xu.js
via shop zone: 200 application/javascript; charset=UTF-8
$ curl -s :4512/blog/hello | grep -o '<h1>…</h1>'
<h1>Post: <!-- -->hello</h1>
What’s happening
  1. The shop zone is the entry point for the whole domain. A request for / matches its own app/page.tsx. /blog and /blog/hello match the rewrites, so the shop zone's server fetches them from BLOG_URL and streams the answer back: the browser never sees port 4612.
  2. The blog zone sets assetPrefix: "/blog-static", so its HTML references /blog-static/_next/static/chunks/….js instead of /_next/static/…. Without the prefix, both apps would claim /_next/static/ and the shop zone would answer the blog's asset requests with its own 404s. The third rewrite forwards that prefix, and the asset came back 200 through the shop zone.
  3. BLOG_URL is read when the rewrites are built into the routes manifest during next build. I checked by restarting the shop zone with BLOG_URL=http://127.0.0.1:4999, and then with no BLOG_URL at all: /blog still answered 200 from port 4612 both times. Changing a zone's address means rebuilding the shop zone (the 4001 default is only a placeholder).
  4. Each zone is built, tested and deployed on its own; a broken blog deploy can't take down checkout. The cost: every path must belong to exactly one zone (two zones serving /blog is a routing conflict), and moving between zones reloads the page.
AdvancedThe blog zone
next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  assetPrefix: "/blog-static", // this zone's JS and CSS live under /blog-static/_next/…
};

export default nextConfig;
app/blog/page.tsx
import Link from "next/link";

export default function Blog() {
  return (
    <main>
      <h1>Blog zone</h1>
      <Link href="/blog/hello">Hello post</Link> {/* same zone: soft navigation */}
      <a href="/">Shop home</a> {/* other zone: plain <a>, full page load */}
    </main>
  );
}
app/blog/[slug]/page.tsx
export function generateStaticParams() {
  return [{ slug: "hello" }];
}

export default async function Post({ params }: PageProps<"/blog/[slug]">) {
  const { slug } = await params;
  return <h1>Post: {slug}</h1>;
}
next build
Route (app)
┌ ○ /_not-found
├ ○ /blog
└   /blog/[slug]
  ├ ◐ /blog/[slug]
  └ ○ /blog/hello


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
app/page.tsx
export default function Home() {
  return (
    <main>
      <h1>Shop zone</h1>
      <a href="/blog">Read the blog</a> {/* another zone: not <Link> */}
    </main>
  );
}
next build
Route (app)
┌ ○ /
└ ○ /_not-found


○  (Static)  prerendered as static content
Result (headless Chrome at http://127.0.0.1:4512/blog: set window.__marker, click "Hello post", go back, click "Shop home")
"/blog | Blog zone | marker=still here"
"/blog/hello | Post: hello | marker=still here"
"/ | Shop zone | marker=undefined"
What’s happening
  1. The blog zone's routes live under app/blog/ because the shop zone forwards /blog… unchanged; /blog/hello is prerendered by generateStaticParams, and other slugs would render on demand (◐ /blog/[slug]).
  2. In the browser, I set a JavaScript variable on window and clicked the <Link href="/blog/hello">. The URL and heading changed and the variable survived: a soft navigation inside the blog zone, with no new document.
  3. Then the plain <a href="/"> to the shop zone: the variable was gone, so the browser loaded a new document (a hard navigation), with the shop zone's own JavaScript.
  4. That's why the docs say to link across zones with <a>, not <Link>: <Link> would prefetch and try to soft-navigate to a path the current app doesn't own.
AdvancedMonorepos and Turborepo

Zones (and plenty of single apps) usually live in a monorepo: apps/shop, apps/blog, and shared packages/ui, packages/config, linked with npm, pnpm or Yarn workspaces. Turborepo adds a task runner on top: turbo run build builds packages in dependency order, runs independent ones in parallel, and caches each task's outputs by a hash of its inputs, locally or in a remote cache, so CI rebuilds only what a commit touched. For a Next.js app the task's outputs are .next/** without .next/cache/**. I didn't set up a Turborepo here; the Next.js side of it is what I could test:

  • Workspace packages that ship TypeScript source must be compiled by the app: list them in transpilePackages.
  • Standalone output in a monorepo must trace from the repository root (outputFileTracingRoot: path.join(__dirname, "../../")) so shared packages are copied, and the output is then nested by the app's path, exactly the layout I hit by accident in the standalone topic.
  • Several lockfiles make Next.js guess the workspace root and warn; it named the fix itself, "set turbopack.root in your Next.js config".
AdvancedUpgrading: the commands
From → toCommandNotes
16.1+ → latestnpx next upgradebuilt into the CLI; --revision picks a tag or version
older → anynpx @next/codemod@canary upgrade latestbumps next, React and eslint-config-next, then offers the relevant codemods
with an agentnpx next@canary upgrade --agent=latestprepares the version guides for a coding agent
one changenpx @next/codemod <transform> <path>--dry and --print to preview

I ran two codemods from @next/codemod@16.4.3 (one from the 16.0 set, one from 15.0) on a small project written the Next.js 14 way: a middleware.ts and a page using synchronous request APIs. I committed it to a throwaway git repository first and looked at git status and git diff afterwards:

Result (npx @next/codemod@16.4.3 middleware-to-proxy src, then git status --short)
 D src/middleware.ts
?? src/proxy.ts
Result (npx @next/codemod@16.4.3 next-async-request-api src, then git diff)
--- a/src/app/inbox/page.tsx
+++ b/src/app/inbox/page.tsx
 import { cookies } from "next/headers";
 
-export default function Inbox({ searchParams }: { searchParams: { folder?: string } }) {
-  const theme = cookies().get("theme")?.value;
+export default async function Inbox(props: { searchParams: Promise<{ folder?: string }> }) {
+  const searchParams = await props.searchParams;
+  const theme = (await cookies()).get("theme")?.value;
   return <h1 className={theme}>Inbox: {searchParams.folder}</h1>;
 }
What’s happening
  1. middleware-to-proxy renamed the file and the exported function (middleware → proxy, the proxy.ts shown below); config.matcher stayed as it was. Its summary said "0 ok" although it had renamed the file, so check git status rather than the counts. Run a second time with the change uncommitted, it refused ("please stash or commit your git changes") unless given --force.
  2. next-async-request-api made the page async, typed searchParams as a Promise, awaited it once at the top under the old name so the body didn't change, and wrapped cookies() in (await cookies()). That's the Next.js 15 breaking change; 15 still tolerated the old synchronous access with warnings, and 16 removed it.
  3. The docs note that the upgrade codemod doesn't run this one for you: if an app still has synchronous params, cookies() and friends, run next-async-request-api separately.
  4. Codemods change syntax, not architecture: the next two blocks show what that means.
AdvancedUpgraded app vs Cache Components

The upgraded app builds, until you turn on Cache Components

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

const nextConfig: NextConfig = {};

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

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

export const config = { matcher: ["/inbox/:path*"] };
app/inbox/page.tsx
import { cookies } from "next/headers";

export default async function Inbox(props: { searchParams: Promise<{ folder?: string }> }) {
  const searchParams = await props.searchParams;
  const theme = (await cookies()).get("theme")?.value;
  return <h1 className={theme}>Inbox: {searchParams.folder}</h1>;
}
next build
Route (app)
┌ ○ /_not-found
└ ƒ /inbox


ƒ Proxy (Middleware)

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
Result (the same files with cacheComponents: true in next.config.ts)
Error: Route "/inbox": 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"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route
What’s happening
  1. With an empty config (the pre-Cache Components model, which upgraded 15 apps keep), the codemodded app builds on 16.4.3: /inbox is ƒ, dynamic because it reads cookies and searchParams, and the proxy appears as ƒ Proxy (Middleware).
  2. Turning on cacheComponents (the default for new 16 apps) changes the rules: request-time data must sit inside <Suspense> or be cached. The build failed with the message above and named the three ways out.
  3. The [stream] fix is the one the rest of this site uses: move the cookie read into a child component inside <Suspense>, like Greeting in the testing topic. [block], export const instant = false, is the migration escape hatch: the 16.3 codemod cache-components-instant-false adds it to every page and layout so you can enable Cache Components first and remove the opt-outs route by route.
  4. The docs' checklist warns against enabling React Compiler or Cache Components "merely to complete the upgrade": upgrade the version first, adopt the new model as separate work.
AdvancedWhat changed in each major version
VersionBreaking or notable for upgrades
13 → 14Node 18.17+; next export removed (use output: 'export'); ImageResponse from next/og; @next/font removed (use next/font)
14 → 15React 19; async cookies(), headers(), draftMode(), params, searchParams (sync access deprecated); fetch and GET Route Handlers no longer cached by default; client router cache no longer reuses page segments (staleTimes to opt back in); runtime: 'experimental-edge' → 'edge'; geo/ip removed from NextRequest
15 → 16Node 20.9+, TypeScript 5.1+; Turbopack default for dev and build (a custom webpack config makes next build fail unless you pass --webpack); sync request APIs removed; middleware → proxy (Node.js only; keep middleware for Edge); PPR flags replaced by cacheComponents; revalidateTag(tag, profile) plus updateTag and refresh; parallel route slots are checked by "strict route matching" (every URL one slot matches needs a page or default.js in each sibling slot, or the build fails; the guide words it as "every slot needs default.js", see the routing chapter); next lint, AMP and serverRuntimeConfig/publicRuntimeConfig removed; next/image defaults changed (minimumCacheTTL, qualities, local IPs blocked); next dev writes to .next/dev
16.x minorsthe next upgrade command (16.1); stable adapterPath and image caching through cacheHandler (16.2); codemods for instant = false and for dropping per-route prefetch = 'partial' once partialPrefetching is on (16.3)
AdvancedCodemods fix syntax, not behaviour

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

RohitDownloads · All chapters

Sync progress across devices

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

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

Sync is on in this browser.

Sync ID

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