Skip to content

Verification tools

Next.js has no in-browser playground — the only way to prove a claim about it is a real project, a real tsc/next build run, or a real browser. Every output below is real, captured against Next.js 16.3.6 and React 19.2.8. None of these tools overlap perfectly, and none is close to complete on its own — the AI Bug Catalog’s “Caught by” line for each of its 25 entries names exactly which one (if any) of the tools below would have caught it.

Type-checking alone catches surprisingly little of this catalog by design — most of Next.js’s own rules (Server/Client boundaries, cacheComponents conflicts, prerender-time data access) are enforced by the framework’s build step, not by TypeScript’s type system. What tsc does catch reliably: a genuine signature change like revalidateTag now requiring two arguments, and any params/searchParams access that skips await — but only when the prop is actually typed as a Promise (a hand-written, loose object type defeats this silently; the generated PageProps/LayoutProps helpers, from next typegen, always get it right).

Terminal window
npx tsc --noEmit
app/actions.ts(6,3): error TS2554: Expected 2 arguments, but got 1.

next build prints a route table after every successful build, one symbol per route:

The full legend (official docs, verbatim):

○ (Static) Fully prerendered at build time. Served without server rendering.
◐ (Partial Prerender) Static shell served immediately. Dynamic content streams in at request time.
● (SSG) Prerendered static HTML (from generateStaticParams or getStaticProps).
ƒ (Dynamic) Server-rendered on demand for each request.

○ Static, ● SSG, and ƒ Dynamic are the modes Next.js has always rendered in, App Router or Pages Router — real next build output, default config, no cacheComponents, a page reading cookies():

Route (app)
┌ ○ /
└ ƒ /catalog/11-cookies-static

◐ Partial Prerender only appears once Cache Components is on (cacheComponents: true) — real output from the bundled next build guide, adding generateStaticParams to a dynamic product page:

Route (app)
┌ ○ /
├ ○ /_not-found
└ /products/[id]
├ ◐ /products/[id] # Params known, data still uncached
├ ◐ /products/1
├ ◐ /products/2
└ ◐ /products/3

Without Cache Components, reading a route’s cache behavior means checking cookies()/headers()/fetch calls by eye — the table itself just prints ƒ the moment any of them run, silently, as the AI Bug Catalog’s entry 11 shows.

The warnings worth reading past the route table, not just the pass/fail exit code:

⚠ The Edge Runtime is deprecated. You can use the "nodejs" runtime instead. Learn more: https://nextjs.org/docs/messages/edge-runtime-deprecated
⚠ metadataBase property in metadata export is not set for resolving social open graph or twitter images, using "http://localhost:3000".
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.

None of these three fail the build or the CI exit code — all three still print next build’s normal success output and exit 0. A CI pipeline that only checks the exit code misses all three.

Starting with Next.js 16, the next lint command itself is removed — next build no longer runs linting as a build step at all. The bundled docs are explicit about the replacement:

The next lint command has been removed. Use Biome or ESLint directly.

For ESLint specifically, eslint-config-next still ships the same @next/eslint-plugin-next rules (including no-html-link-for-pages, no-img-element, and the rest) — you now run the ESLint CLI yourself, against a flat eslint.config.mjs:

Terminal window
npx eslint .

A codemod migrates an existing next lint setup automatically:

Terminal window
npx @next/codemod@canary next-lint-to-eslint-cli .

next experimental-analyze — real bundle composition

Section titled “next experimental-analyze — real bundle composition”

Since next build stopped printing a per-route bundle-size column (v16.0.0), next experimental-analyze (stable as of v16.1.0) is the tool that shows what actually shipped to the browser, filterable by route and by client/server:

Terminal window
npx next experimental-analyze --output
✓ Running next.config.ts took 19ms
Analyzing a production build...
✓ Analyze completed in 926ms. Results written to .next/diagnostics/analyze.
To explore the analyze results interactively, run `next experimental-analyze` without `--output`.

Without --output, it starts a local server with an interactive treemap instead of writing static files — useful for the exact kind of comparison the AI Bug Catalog’s entry 1 makes (an 8,967-byte chunk versus a 287-byte one, depending on where 'use client' sits).

next typegen — route types without a full build

Section titled “next typegen — route types without a full build”

next typegen generates the PageProps/LayoutProps/RouteContext helper types (and next-env.d.ts) without running a full next dev or next build — useful in CI, where you want type-checking without paying for a full build:

Terminal window
npx next typegen
Generating route types...
✓ Types generated successfully

Run it before tsc --noEmit in a CI job that only wants to type-check: next typegen && tsc --noEmit. Without it (or without next dev/next build having run at least once), the generated PageProps<'/route'> helper types simply do not exist yet.

next build --debug-prerender — real stack traces for prerender errors

Section titled “next build --debug-prerender — real stack traces for prerender errors”

Production builds minify server code and skip source maps by default, so a Cache-Components prerender error’s stack trace is close to useless on its own. --debug-prerender (stable since v15.4.0) disables minification, turns on server-side source maps, and — critically — continues past the first prerender failure so every blocked route shows up in one run instead of one per rebuild:

Terminal window
npx next build --debug-prerender

A blocking-prerender error with --debug-prerender points directly at the offending line (real output, from the bundled next build guide, for an unwrapped params read):

Error: Route "/products/[id]": Next.js encountered uncached or runtime data during prerendering.
...
at Page (app/products/[id]/page.tsx:2:30)
1 | export default async function Page(props: PageProps<'/products/[id]'>) {
> 2 | const { id } = await props.params
| ^
3 | const res = await fetch(`https://api.example.com/products/${id}`)

Warning, straight from the CLI: never deploy a build produced with --debug-prerender — it skips optimizations production needs.

Reading the RSC payload in the Network tab

Section titled “Reading the RSC payload in the Network tab”

Every navigation and every initial page load carries a React Server Components payload — a compact, streaming, non-JSON serialization of the server-rendered tree — visible in the browser’s Network tab as the page’s own document request (for the initial load) or as a text/x-component fetch (for a client-side navigation). A real payload fragment looks like this (trimmed, from an actual next build + next start run):

0:{"P":null,"c":["","bug-public-env"],"q":"","i":false,"f":[[["",{"children":["bug-public-env",{"children":["__PAGE__",{},"$undefined","$undefined",4608]},"$undefined","$undefined",4608]},"$undefined","$undefined",4624], ...

You will not read this format character by character in practice — the point of loading it in the Network tab is confirming that a navigation triggered a real RSC fetch at all (versus a full page reload from a stray <a>, per the AI Bug Catalog’s entry 25), and roughly how large it is relative to the actual content on screen.

A single Playwright test that loads the real, built app and asserts on the final DOM state catches an entire class of bug this page’s static tools cannot: anything that only manifests after hydration, after a client-side navigation, or after a Server Action round-trip. This course’s own probe used exactly this technique to prove the NEXT_PUBLIC_ dynamic-access bug in the AI Bug Catalog’s entry 19 — the static-literal read was correct in the initial server-rendered HTML, and only a real browser, after hydration, exposed the dynamic read’s undefined.

// A Playwright test file (tests/smoke.spec.ts) — illustrative, not a
// fence this course's own tsc harness compiles, since @playwright/test
// isn't a dependency of the type-check probe.
import { test, expect } from '@playwright/test'
test('renders the real client-side value, not the SSR one', async ({ page }) => {
await page.goto('http://localhost:3000/some-route')
await expect(page.locator('body')).toContainText('value: undefined')
})

instrumentation.ts in development, not just production

Section titled “instrumentation.ts in development, not just production”

register() in instrumentation.ts runs once for every new Next.js server instance — including next dev — not only in a deployed production build. Wiring onRequestError early, even just to console.error during local development, surfaces a server error the moment it happens instead of only after a report reaches production. See Deployment and Self-Hosting for the full, real onRequestError example this course builds against a production observability provider.

This course’s own harness: compile every snippet

Section titled “This course’s own harness: compile every snippet”

Every code fence in this course tagged with a // path.ext first line is a real file, collected by tools/verify-snippets.mjs into a namespaced directory per lesson and type-checked in one tsc --noEmit pass across the whole course — the same principle as the Playwright smoke test above, generalized to “does every snippet the reader might copy-paste actually compile,” not just the one file you happen to be editing right now:

Terminal window
node tools/verify-snippets.mjs
no type errors.
Per-module fence summary (collected / skipped-no-path / expect-error):
reading-nextjs: 46 / 0 / 10
...

A fence deliberately showing broken code (like most of the “wrong code” examples in the AI Bug Catalog) starts with // @expect-error <reason> so the harness skips it — the lesson prose carries the real, separately-captured build/tsc output instead of asking the harness to reproduce a deliberate failure on every run.

flowchart TD
  subgraph Static["Static (source/config only)"]
    Tsc["tsc --noEmit<br/>real type errors: revalidateTag, typed params"]
    Lint["ESLint (eslint-config-next) / Biome<br/>no-html-link-for-pages and friends"]
    Verify["tools/verify-snippets.mjs<br/>compiles every tagged fence in the course"]
  end
  subgraph BuildTime["Build-time (needs a real next build)"]
    Build["next build route table<br/>○ ● ◐ ƒ, plus warnings that don't fail CI"]
    Debug["next build --debug-prerender<br/>real stack traces for prerender errors"]
    Analyze["next experimental-analyze<br/>real bundle composition per route"]
    Typegen["next typegen<br/>PageProps/LayoutProps without a full build"]
  end
  subgraph Runtime["Runtime (needs a real browser or server)"]
    Rsc["RSC payload in the Network tab"]
    Pw["Playwright smoke test<br/>post-hydration, post-navigation state"]
    Instr["instrumentation.ts onRequestError<br/>in next dev, not just production"]
  end
Static tools prove things from source or config; dynamic tools need a running app or a real browser
Which command produced this line, and against which fence in the AI Bug Catalog?
app/actions.ts(6,3): error TS2554: Expected 2 arguments, but got 1.
A CI pipeline runs next build and it exits 0. Which of these is still possible?
Why does next typegen matter for catching the 'synchronous params access' bug specifically?
Why is --debug-prerender explicitly documented as unsafe to deploy?
What does a Playwright smoke test catch that tsc --noEmit and next build together cannot?
Why does this fence start with @expect-error instead of just the path comment?
// @expect-error real Turbopack build error, not a type error — see prose
// app/catalog/07-router-push/page.tsx
import { useRouter } from 'next/navigation'
Why does this course's harness mark some 'wrong code' fences with // @expect-error <reason> instead of giving them a real path comment?