Next.js NotesRohit’s interview study guide
Chapter 11

Styling, Errors and Observability

How CSS gets into an App Router page, what happens when something throws (on the server, in an action, in the browser), and how you find out about it in production.

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

#Styling options

Added

In the App Router, CSS is part of the module graph. Every import "./something.css" in a layout, page or component is collected by the bundler, minified, grouped into .css chunks and attached to the routes whose modules imported it. When a route renders, React puts a <link rel="stylesheet"> for each of those chunks in the <head> (React 19 hoists stylesheets and orders them by precedence), so the CSS arrives with the HTML and works with JavaScript turned off.

A useful mental model: a stylesheet follows the import, not the component. Ask two questions about any CSS file: which module imports it (that decides which route loads it) and is it scoped (a .module.css file is renamed per file, so its classes can't collide; a plain .css file is global and applies to the whole document once it's loaded). Everything else (Tailwind, Sass, CSS-in-JS) is a variation on those two.

The options, in the order the Next.js docs recommend them:

OptionWhat it isServer Components?Cost
Tailwind CSSutility classes generated at build time from the class names in your sourceyesone global stylesheet with only the utilities you use; needs tailwindcss + @tailwindcss/postcss
CSS Modulesname.module.css, class names renamed per fileyesnone, built in
Global CSSplain .css imported in a layoutyesnone, but every rule is global and stays loaded
Sass.scss / .sass, also as .module.scssyesinstall sass
CSS-in-JSstyled-components, styled-jsx, Emotion…only in Client Componentsa style registry in the root layout, runtime JS

The example is a three-page app. The home page uses a CSS Module and renders a Client Component that has its own module; the root layout imports a global stylesheet. The Result block is what next start sent for / (curl -s localhost:4511/ | grep -o …) and the two CSS files it linked, from .next/static/chunks/. The app's other files follow the walkthrough.

app/page.tsx
import styles from "./page.module.css";
import { LikeButton } from "./like-button";

export default function Home() {
  return (
    <main className={styles.card}>
      <h1 className={styles.title}>Home</h1>
      <LikeButton />
    </main>
  );
}
Result (next start: the HTML for / and its two CSS files)
<link rel="stylesheet" href="/_next/static/chunks/2rf2--uiir30_.css" data-precedence="next"/>
<link rel="stylesheet" href="/_next/static/chunks/1gm7blby7ti5l.css" data-precedence="next"/>
class="_6T_DqW_card"
class="cVxzua_card"
class="cVxzua_title"

--- 2rf2--uiir30_.css (globals.css)
:root{--ink:#1f2937;--accent:#2563eb}body{color:var(--ink);margin:0;font-family:system-ui,sans-serif}

--- 1gm7blby7ti5l.css (page.module.css + like-button.module.css)
.cVxzua_card{border:1px solid var(--accent);border-radius:8px;padding:16px}.cVxzua_title{color:var(--accent);margin:0 0 8px}
._6T_DqW_card{background:#fee2e2;border-radius:999px;padding:4px 12px}
What’s happening
  1. The root layout (its files follow below) imports ./globals.css. Because the root layout wraps every route, that stylesheet is on every page: the custom properties --ink and --accent and the body rules are global by design.
  2. page.tsx (a Server Component) imports page.module.css and gets back an object: styles.card and styles.title are strings, the renamed class names. The component runs on the server, so only those strings end up in the HTML; no CSS-in-JS runtime is involved.
  3. like-button.tsx is a Client Component and imports its own module, which also defines .card. The two .card rules can't collide because each file's classes get the file's own prefix: in the production HTML above, the page's card is cVxzua_card and the button's is _6T_DqW_card.
  4. At build time both module files were merged into one chunk for / (they're imported by the same route), and globals.css went into its own chunk. The HTML for / links exactly those two files; /shop and /blog link the global chunk plus their own.
  5. The route table shows all four routes as static ○: styling has no effect on rendering mode. CSS Modules work identically in Server and Client Components because the renaming happens at build time, not in the browser.
AdvancedLayout, CSS Modules and global CSS files
app/like-button.tsx
"use client";

import { useState } from "react";
import styles from "./like-button.module.css";

export function LikeButton() {
  const [likes, setLikes] = useState(0);
  return (
    <button className={styles.card} onClick={() => setLikes(likes + 1)}>
      ♥ {likes}
    </button>
  );
}
app/page.module.css
.card {
  padding: 16px;
  border: 1px solid var(--accent);
  border-radius: 8px;
}

.title {
  margin: 0 0 8px;
  color: var(--accent);
}
app/like-button.module.css
.card {
  padding: 4px 12px;
  border-radius: 999px;
  background: #fee2e2;
}
app/globals.css
:root {
  --ink: #1f2937;
  --accent: #2563eb;
}

body {
  margin: 0;
  font-family: system-ui, sans-serif;
  color: var(--ink);
}
app/layout.tsx
import Link from "next/link";
import "./globals.css";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        <nav>
          <Link href="/">Home</Link> <Link href="/shop">Shop</Link> <Link href="/blog">Blog</Link>
        </nav>
        {children}
      </body>
    </html>
  );
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /blog
└ ○ /shop


○  (Static)  prerendered as static content

Under next dev the same classes are readable: page-module__cVxzua__card, page-module__cVxzua__title and like-button-module__6T_DqW__card (file name, a per-file hash, class name). Production keeps only the hash and the class name. Never hard-code a generated class name in tests or other CSS; select by role, text or a data- attribute instead.

AdvancedGlobal CSS in nested layouts never unloads

You can import a plain .css file in any layout, page or component, not only the root layout. That looks like a way to scope CSS to a section of the site, but it isn't: once loaded, a global stylesheet stays in the document for the rest of the client-side session. The docs say so directly (React's stylesheet support "does not remove stylesheets as you navigate between routes"), and this example shows it.

app/shop/shop.css
body {
  background: #fef3c7;
}
app/shop/layout.tsx
import "./shop.css";

export default function ShopLayout({ children }: LayoutProps<"/shop">) {
  return <section>{children}</section>;
}
app/shop/page.tsx
export default function Shop() {
  return <h1>Shop</h1>;
}
app/blog/blog.module.css
.post {
  max-width: 60ch;
  line-height: 1.6;
}
app/blog/page.tsx
import styles from "./blog.module.css";

export default function Blog() {
  return (
    <article className={styles.post}>
      <h1>Blog</h1>
      <p>Long-form text.</p>
    </article>
  );
}
What’s happening
  1. app/shop/layout.tsx imports shop.css, which sets a yellow body background. The author's intent: "the shop is yellow".
  2. A fresh load of /shop links the global chunk plus 0f_q5p3uid_gl.css (just body{background:#fef3c7}); a fresh load of /blog links the global chunk plus its module chunk. So far each route gets only its own CSS.
  3. Now navigate on the client: Home → Shop → Blog. Each navigation adds the new route's stylesheets to the <head>, and none are removed, so on /blog the shop's body rule is still in the document and still matches.
  4. The measurements below (headless Chrome, getComputedStyle(document.body).backgroundColor and the <link> tags after each step) show the blog turning yellow after a client navigation and white after a reload.
  5. The fix is to never write bare element or body selectors in a non-root stylesheet: put section styles in a CSS Module (.shopPage) applied to the section's wrapper, or set a class or data- attribute on a wrapper element that only that section renders.
headless Chrome, next start
load /           sheets: 2rf2--uiir30_.css, 1gm7blby7ti5l.css                                       bg: rgba(0, 0, 0, 0)
click Shop       sheets: 2rf2--uiir30_.css, 1gm7blby7ti5l.css, 0f_q5p3uid_gl.css                    bg: rgb(254, 243, 199)
click Blog       sheets: 2rf2--uiir30_.css, 1gm7blby7ti5l.css, 0f_q5p3uid_gl.css, 2no7ulbho8ayc.css bg: rgb(254, 243, 199)
reload /blog     sheets: 2rf2--uiir30_.css, 2no7ulbho8ayc.css                                       bg: rgba(0, 0, 0, 0)
Shop is yellow. Is the blog?

A user opens /shop (yellow, from shop.css imported in app/shop/layout.tsx), then clicks the Blog link. What colour is the blog's background? And if they press reload?

Show answer

Yellow after the click, white after the reload.

What’s happening
  1. The first load of /shop renders the root layout and the shop layout on the server; the HTML links globals.css and shop.css, so the body is rgb(254, 243, 199).
  2. Clicking Blog is a client-side navigation: the router fetches the RSC payload for /blog, which references the blog's CSS chunk; React inserts that <link> into the existing <head>.
  3. Nothing removes the shop.css link: React keeps hoisted stylesheets for the life of the document. Its body { background } rule still matches, so the blog is yellow (measured: rgb(254, 243, 199) with four stylesheets loaded).
  4. Reload starts a new document. The server renders /blog alone and links only globals.css and the blog module, so the body is transparent again (two stylesheets).
  5. This is the exact behaviour the docs warn about, and it's why "global CSS only for truly global styles" is the rule: a bug that only appears after a particular navigation path is hard to find.
AdvancedTailwind CSS

Tailwind generates utility classes (p-6, text-2xl) at build time by scanning your source files for class names. Tailwind 4 needs no config file: install it, register its PostCSS plugin and @import it in your global CSS. Because the output is plain CSS and the class names are plain strings, it works in Server Components with zero runtime, which is why the docs (and create-next-app's Tailwind option) push it as the default.

The site's template project doesn't include Tailwind, so these files aren't built by the page checker. I built them in a separate copy of the template with npm install -D tailwindcss @tailwindcss/postcss sass (it installed tailwindcss 4.3.3, @tailwindcss/postcss 4.3.3 and sass 1.106.1), and the output below is from that build.

postcss.config.mjs
export default {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};
app/globals.css
@import "tailwindcss";

@theme {
  --color-brand: #2563eb;
}
app/layout.tsx
import "./globals.css";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body className="bg-white text-gray-900">{children}</body>
    </html>
  );
}
app/page.tsx
import styles from "./price.module.scss";

export default function Home() {
  return (
    <main className="mx-auto max-w-md p-6">
      <h1 className="text-2xl font-bold text-brand">Tailwind in a Server Component</h1>
      <p className={styles.price}>€42</p>
    </main>
  );
}
app/price.module.scss
@use "sass:color";

$sale: #dc2626;

.price {
  font-weight: 700;
  color: $sale;

  &:hover {
    color: color.adjust($sale, $lightness: -10%);
  }
}
What’s happening
  1. @import "tailwindcss" pulls in Tailwind's base styles (preflight) and its theme variables; @theme { --color-brand: … } adds a design token, which Tailwind turns into a text-brand utility.
  2. At build time the PostCSS plugin scans the project's files for class-like strings and emits a rule only for the utilities it finds. The page's p-6, max-w-md and text-brand became .p-6{padding:calc(var(--spacing) * 6)}, .max-w-md{max-width:var(--container-md)} and .text-brand{color:var(--color-brand)} in the built CSS.
  3. The whole Tailwind stylesheet was 5,135 bytes (1,794 gzipped), mostly preflight; it doesn't grow with the number of components, only with the number of distinct utilities.
  4. price.module.scss is Sass compiled first, then treated as a CSS Module: it came out as .On13OW_price{color:#dc2626;font-weight:700}.On13OW_price:hover{color:#b21d1d} in its own chunk. Sass works the same way for global .scss files.
  5. Because utilities are just strings, don't build class names dynamically (`text-${color}-600`): the scanner only sees complete class names in your source, so a constructed one has no CSS. Map values to full class names instead ({ red: "text-red-600" }).

Sass needs sass installed and nothing else; sassOptions in next.config.ts passes options such as additionalData (a prelude added to every file) or implementation: "sass-embedded". The 16 upgrade guide notes that Turbopack doesn't support Webpack's ~ prefix for imports from node_modules (@import "~bootstrap/…" becomes @import "bootstrap/…"). The build above also printed a deprecation warning until I replaced Sass's global darken() with color.adjust() from sass:color; old Sass code bases will show many of those.

AdvancedCSS-in-JS with Server Components

Runtime CSS-in-JS libraries (styled-components, Emotion, styled-jsx) generate CSS while components render, usually through React context and hooks. Server Components have neither state nor context and never run in the browser, so these libraries only work in Client Components. Try it in a Server Component and the build stops:

app/page.tsx
export default function Home() {
  return (
    <div>
      <p className="note">Hello</p>
      <style jsx>{`
        .note { color: tomato; }
      `}</style>
    </div>
  );
}
next build (built in a sandbox copy; it fails)
> Build error occurred
Error: Turbopack build failed with 1 error:
./src/app/page.tsx
Error: 'styled-jsx' cannot be imported from a Server Component module
It only works in a Client Component but none of its parents are marked with 'use client', so they're Server Components by default.

Moving the component into a "use client" file fixes the build, but there's a second problem: during server rendering the library has nowhere to put the CSS it generated. Without a registry, the HTML I got contained the generated class (<p class="jsx-f617598c21418d8a note">) but no tomato rule anywhere, so the text renders unstyled until JavaScript loads and injects the styles. That's a flash of unstyled content, and nothing at all with JavaScript off.

The fix the docs give is a style registry: a Client Component in the root layout that collects the rules rendered so far and flushes them into the HTML with useServerInsertedHTML.

app/registry.tsx
"use client";

import { useState } from "react";
import { useServerInsertedHTML } from "next/navigation";
import { StyleRegistry, createStyleRegistry } from "styled-jsx";

export default function StyledJsxRegistry({ children }: { children: React.ReactNode }) {
  // One registry per server render, created once (lazy initial state)
  const [registry] = useState(() => createStyleRegistry());

  useServerInsertedHTML(() => {
    const styles = registry.styles(); // the <style> elements collected so far
    registry.flush();
    return <>{styles}</>;
  });

  return <StyleRegistry registry={registry}>{children}</StyleRegistry>;
}
app/layout.tsx
import StyledJsxRegistry from "./registry";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        <StyledJsxRegistry>{children}</StyledJsxRegistry>
      </body>
    </html>
  );
}
app/note.tsx
"use client";

export function Note({ children }: { children: React.ReactNode }) {
  return (
    <p className="note">
      {children}
      <style jsx>{`
        .note {
          color: tomato;
        }
      `}</style>
    </p>
  );
}
app/page.tsx
import { Note } from "./note";

export default function Home() {
  return <Note>Styled on the server?</Note>;
}
What’s happening
  1. page.tsx stays a Server Component and renders <Note>, a Client Component; Client Components are still rendered to HTML on the server, so Note's <style jsx> runs there and adds its rule to whatever styled-jsx registry is in context.
  2. StyledJsxRegistry (also a Client Component) creates that registry once per server render with lazy useState, and provides it with <StyleRegistry> around the whole app.
  3. useServerInsertedHTML is a Next.js hook that lets a Client Component inject HTML into the stream as it's flushed. The callback returns the collected <style> elements and clears the registry, so each chunk of streamed HTML carries the styles its components produced.
  4. With the registry, the HTML next start sent had the rule in the <head>, before the paragraph: <style id="__jsx-f617598c21418d8a">.note.jsx-f617598c21418d8a{color:tomato}</style>. (In my build the same <style> appeared twice in the head, back to back, in the prerendered index.html. It's harmless. It isn't the partial-prefetching double render: a rebuild with partialPrefetching: false had the same two tags. I didn't find the cause.)
  5. styled-components has the same shape (a ServerStyleSheet in the registry, plus compiler: { styledComponents: true } in next.config); the docs list which libraries support the App Router. What this demonstrates: CSS-in-JS works, but only as client code with extra plumbing and runtime cost, which is why new App Router projects lean on Tailwind or CSS Modules, or on zero-runtime libraries (vanilla-extract, StyleX, Panda) that compile to static CSS.
AdvancedOrdering, chunking and inlining
  • Order follows imports. In production, CSS order is the order the modules were imported: if page.tsx imports <BaseButton> (which imports base-button.module.css) before page.module.css, the base button's rules come first and the page's can override them. The docs recommend keeping CSS imports in a predictable entry file and turning off import-sorting lint rules, because re-sorting imports can change the cascade.
  • Dev can differ from production. The docs warn that CSS ordering "can behave differently in development"; always check a next build. In this example dev served separate, unminified files per source, production merged the two modules into one chunk.
  • Chunking: experimental.cssChunking defaults to true (merge CSS files into fewer chunks when import order allows). Turbopack also has 'graph', a cost-based grouping you tune with requestCost (bytes one extra request is worth, default 20,000) and weightDistribution; Webpack has 'strict' (keep import order, more requests) and false.
  • Inlining: experimental.inlineCss: true replaces the <link> tags with <style> tags in the initial HTML. It removes a render-blocking request (good for first visits and small atomic CSS like Tailwind) but stops the browser caching CSS separately (bad for returning visitors and big CSS). Production only.
  • External stylesheets: a package's CSS (import "bootstrap/dist/css/bootstrap.css") can be imported anywhere in app/, and React 19 also accepts <link rel="stylesheet" href="…" precedence="default" /> in components.
AdvancedHow it used to work
  • Pages Router: global CSS is imported in pages/_app.js (the docs: "you should import them inside pages/_app.js" because of the global nature of stylesheets); CSS Modules work anywhere. Since Next.js 9.5.4, CSS from node_modules can be imported in any component.
  • Next.js 13–15 with Webpack used the same CSS Modules idea with a different naming scheme, so generated class names in old snapshots or tests won't match Turbopack's (cVxzua_card), which is another reason never to depend on them.
  • Tailwind 3 needed a tailwind.config.js with a content array of globs; Tailwind 4 detects sources automatically and is configured in CSS (@theme). The docs keep a separate Tailwind v3 guide for very old browsers.
AdvancedThree styling traps

#Error handling with error.js

Added

Next.js splits errors into two kinds, and the split decides the tool. Expected errors are part of normal operation: a form field is invalid, a record doesn't exist, the user isn't allowed. You handle those in code and show them as data (return a message from a Server Action, call notFound(), render "No results"). Unexpected errors are bugs and outages: a database is down, a value is undefined. You let those throw, and an error boundary catches them and shows fallback UI instead of a broken page.

error.tsx is the file convention for that boundary. Put it in a route segment and Next.js wraps that segment's content in a React error boundary whose fallback is your component. A useful mental model is circuit breakers in a building: each segment can have its own breaker, a fault trips the nearest breaker above it, and the rest of the building (parent layouts, sibling navigation, client state outside the boundary) keeps its power. global-error.tsx is the main breaker for faults in the root layout itself.

The exact nesting is what interviewers probe. For one segment, Next.js builds this tree (from the docs' component hierarchy):

layout.tsx template.tsx error.tsx boundary loading.tsx (Suspense) not-found boundary page.tsx and nested layouts Throw in page / loading caught by this error.tsx Throw in this layout caught by the parent's error.tsx Throw in the root layout caught by global-error.tsx
error.tsx sits inside its segment's layout and template, so it can't catch errors they throw; those go one level up.

The example is a dashboard whose report fails the first time the server asks for it, like a database connection that's cold after a deploy. Below are the dashboard segment's files, with a client counter in the dashboard layout so you can see what survives; the root layout, an app-level error.tsx and global-error.tsx follow in Which boundary catches what.

The Result block shows the first request three ways: the response headers and the parts of the body that matter (curl -sD -), the server terminal, and what headless Chrome showed against the same next start as I clicked through the error UI.

app/dashboard/error.tsx
"use client"; // error boundaries must be Client Components

import { useEffect } from "react";

export default function DashboardError({
  error,
  retry,
  reset,
}: {
  error: unknown;
  retry: () => void;
  reset: () => void;
}) {
  const err = error instanceof Error ? (error as Error & { digest?: string }) : null;

  useEffect(() => {
    console.error("dashboard boundary:", error); // send to your error tracker here
  }, [error]);

  return (
    <div role="alert">
      <h2>Couldn't load the report</h2>
      <p id="message">{err?.message ?? String(error)}</p>
      <p id="digest">digest: {err?.digest ?? "none"}</p>
      <button onClick={() => retry()}>Retry</button> <button onClick={() => reset()}>Reset</button>
    </div>
  );
}
Result (next start: curl, the server terminal, then headless Chrome)
HTTP/1.1 200 OK
x-nextjs-prerender: 1
x-nextjs-postponed: 1
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked

…<!--$?--><template id="B:0"></template><p>Loading report…</p><!--/$-->…
…13:E{\"digest\":\"1125690818\"}…
…$RX("B:0","1125690818")…

--- terminal (next start)
[report] call 1
⨯ Error: connect ECONNREFUSED db.internal:5432 (user=admin)
    at e (.next/server/chunks/ssr/_sandbox_nextjs_0-sm51l1rb0uf._.js:1:138)
    at f (.next/server/chunks/ssr/_sandbox_nextjs_0-sm51l1rb0uf._.js:1:285) {
  digest: '1125690818'
}

first load        h2: Couldn't load the report
                  message: Minified React error #441; visit https://react.dev/errors/441 for the full message …
                  digest: 1125690818            counter: Layout clicks: 0
counter x2        counter: Layout clicks: 2     (error UI unchanged)
click Reset       h2: Couldn't load the report  digest: 1125690818   counter: Layout clicks: 2
click Retry       report: Revenue 1200, orders 31                   counter: Layout clicks: 2

--- terminal during those clicks
[report] call 2        (printed after Retry; Reset printed nothing)
What’s happening
  1. /dashboard is ◐ in the build (the route table and the other files follow below): await connection() tells Next.js the page needs a request, so the layout, the counter and loading.tsx's "Loading report…" are prerendered into a static shell and the page itself becomes a dynamic hole that renders per request.
  2. On the first request, the server sends the shell, then renders the hole: getReport() runs ([report] call 1) and throws. The server log prints the full error, stack and a digest: '1125690818'.
  3. In the response, React doesn't send the message. The RSC data for that slot is only E{"digest":"1125690818"}, and the HTML calls $RX("B:0","1125690818"), React's instruction to mark the Suspense boundary as failed so the browser renders it instead.
  4. After hydration, the browser rethrows that error inside the dashboard segment, and the nearest boundary, dashboard/error.tsx, renders. Its error.message is React's production placeholder (error #441, below) and its error.digest is 1125690818, the same number as in the server log. That number is the only link between what the user saw and what you logged.
  5. The dashboard layout is outside the boundary, so its heading and counter (and the root layout's nav) stay interactive while the error UI shows; the user can click the counter or leave the page.
  6. error.tsx must be a Client Component ("use client"): error boundaries catch errors while React renders in the browser, so they need to exist there. Typing error as unknown (as the 16.4 docs do) forces you to narrow it, because a Client Component can throw anything, not only Error objects.
  7. The last rows of the Result: Reset re-rendered the boundary without asking the server and the error came straight back; Retry made the server run getReport() again ([report] call 2), which succeeded, and the report replaced the error UI while the layout's counter kept its value. retry() vs reset() below explains why.
AdvancedThe rest of the dashboard segment
app/lib/report.ts
import "server-only";

let calls = 0;

export async function getReport() {
  calls++;
  console.log(`[report] call ${calls}`);
  if (calls === 1) {
    // the first call after the server starts fails, like a cold database connection
    throw new Error("connect ECONNREFUSED db.internal:5432 (user=admin)");
  }
  return { revenue: 1200, orders: 31 };
}
app/dashboard/page.tsx
import { connection } from "next/server";
import { getReport } from "../lib/report";

export default async function DashboardPage() {
  await connection(); // render per request, not at build time
  const report = await getReport();
  return (
    <p>
      Revenue {report.revenue}, orders {report.orders}
    </p>
  );
}
app/dashboard/loading.tsx
export default function Loading() {
  return <p>Loading report…</p>;
}
app/dashboard/layout.tsx
import { Bomb } from "../bomb";
import { Counter } from "./counter";

export default function DashboardLayout({ children }: LayoutProps<"/dashboard">) {
  return (
    <section>
      <h1>Dashboard</h1>
      <Counter /> <Bomb label="dashboard layout" />
      {children}
    </section>
  );
}
app/dashboard/counter.tsx
"use client";

import { useState } from "react";

export function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>Layout clicks: {n}</button>;
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
└ ◐ /dashboard


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

React error #441 is, in React's error code list: "An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error." Older Next.js versions showed that sentence directly; with React 19.3's minified production build you see the #441 link instead. Either way, the real message never leaves the server in production: the database host and user name in my error message stayed in the terminal.

What status code does the failing dashboard return?

The first request to /dashboard throws in the page. loading.tsx exists and the route is ◐. What HTTP status does the browser get, and what does the user see first?

Show answer

200 OK. The user first sees the static shell with "Loading report…", then the error UI from dashboard/error.tsx once the page hydrates.

What’s happening
  1. Because the route has a prerendered static shell, Next.js starts the response by sending that shell (x-nextjs-prerender: 1, x-nextjs-postponed: 1). Sending the first bytes means sending the status line, and at that moment nothing has failed: the status is 200.
  2. The dynamic hole renders after the headers are gone. When getReport() throws, the only thing the server can still do is write more of the body: it streams E{"digest":…} for the slot and the $RX(…) script that marks the Suspense boundary as errored.
  3. The user sees "Loading report…" from the shell (it's real HTML in the response), and when React hydrates it renders that boundary on the client, rethrows the error and shows dashboard/error.tsx.
  4. So with streaming, an error inside a Suspense boundary can't change the status code. This matters for monitoring (status-code-based alerts miss these; use onRequestError), for SEO (crawlers get a 200 with a fallback) and for tests (assert on content, not only status).
AdvancedWhich boundary catches what

The rest of the dashboard app: a Bomb button that throws during render after it's clicked (it sets an "armed" state, then throws on the next render), used once in the dashboard layout (above) and once in the root layout, plus the app-level error.tsx and global-error.tsx.

app/bomb.tsx
"use client";

import { useState } from "react";

// A button that throws during the next render after it's clicked
export function Bomb({ label }: { label: string }) {
  const [armed, setArmed] = useState(false);
  if (armed) throw new Error(`${label} exploded`);
  return <button onClick={() => setArmed(true)}>Break {label}</button>;
}
app/root-bomb.tsx
import { Bomb } from "./bomb";

export function RootBomb() {
  return <Bomb label="root layout" />;
}
app/layout.tsx
import Link from "next/link";
import { RootBomb } from "./root-bomb";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        <nav>
          <Link href="/">Home</Link> <Link href="/dashboard">Dashboard</Link> <RootBomb />
        </nav>
        {children}
      </body>
    </html>
  );
}
app/page.tsx
export default function Home() {
  return <h1>Home</h1>;
}
app/error.tsx
"use client";

export default function RootError({ error, retry }: { error: unknown; retry: () => void }) {
  return (
    <div role="alert">
      <h2>App-level boundary</h2>
      <p id="root-message">{error instanceof Error ? error.message : String(error)}</p>
      <button onClick={() => retry()}>Retry</button>
    </div>
  );
}
app/global-error.tsx
"use client";

export default function GlobalError({ error, retry }: { error: unknown; retry: () => void }) {
  return (
    // replaces the root layout, so it brings its own <html> and <body>
    <html lang="en">
      <body>
        <title>Something went wrong</title>
        <h2>The whole app failed</h2>
        <p id="global-message">{error instanceof Error ? error.message : String(error)}</p>
        <button onClick={() => retry()}>Retry</button>
      </body>
    </html>
  );
}
headless Chrome, next start
click "Break dashboard layout"   h2: App-level boundary        message: dashboard layout exploded   nav still there
click "Break root layout"        h2: The whole app failed      message: root layout exploded        nav gone, title "Something went wrong"
What’s happening
  1. The dashboard layout's bomb throws inside dashboard/layout.tsx. The dashboard's own error.tsx sits inside that layout (see the diagram), so it can't catch it; the error travels up to app/error.tsx, which replaced the whole dashboard section while the root layout's nav stayed.
  2. Note the message: dashboard layout exploded, the real text. Errors thrown in Client Components in the browser aren't sent through the server, so there's nothing to redact and no digest. Don't put secrets in client code anyway: it's public.
  3. The root layout's bomb throws in app/layout.tsx. Only global-error.tsx sits above the root layout, and when it renders it replaces the root layout, which is why it must render its own <html> and <body>, why the nav disappeared, and why it sets <title> with React's <title> component (error files are Client Components, so they can't export metadata).
  4. global-error.tsx doesn't get your global CSS, fonts or theme class either, since those come from the root layout it replaced. The docs: import or inline what it needs. Its version history notes that since Next.js 15.2 it's also displayed in development.
  5. Takeaway: add app/error.tsx so that a fault in any nested layout costs one section, not the whole document; keep global-error.tsx as the last resort.
Advancedretry() and reset(): which one recovers

The measurements above show the difference. reset() only clears the boundary's error state and re-renders its children with what the client already has. For an error that came from a Server Component, what the client has is the error (the E{digest} slot), so re-rendering throws it again: the UI stayed on "Couldn't load the report" and the server saw no request. retry() asks the server for the route again and then re-renders the boundary, inside a transition. In 16.4.3's source (next/dist/client/components/error-boundary.js) it is literally startTransition(() => { router.refresh(); reset(); }): a router.refresh() of the current route plus a reset(). The server ran getReport() again ([report] call 2), it succeeded, and the report replaced the error UI. The layout's counter stayed at 2 throughout: a refresh merges the new server payload into the existing tree without losing Client Component state, and the counter is outside the boundary, so nothing reset it (the catchError docs describe retry() the same way: it "re-renders the page inside a Transition, preserving Client Components state outside of the error boundary").

reset()retry()
What it doesclears error state, re-renders childrenrouter.refresh() + reset() in a transition
Server requestnoyes
Recovers from a Server Component errorno (measured: same error again)yes, if the cause was temporary
Recovers from a client render erroryes, if the cause was in client state that changedyes
Availablesince Next.js 13unstable_retry in 16.2, stable retry in 16.3

Version note for interviews: from Next.js 13 to 16.1, the error component received ({ error, reset }) and the docs typed error as Error & { digest?: string }. 16.2 added unstable_retry next to reset (error still typed Error); 16.3 renamed it retry and typed error as unknown (checked in the published next/dist/client/components/error-boundary.d.ts of 16.1.0, 16.2.0, 16.3.0 and 16.4.3). Most existing code and most interview answers use reset. A pattern you'll see in that older code for server errors is calling router.refresh() and reset() together inside startTransition: that is exactly what retry() does (the source quoted above; I didn't run the hand-written version here). In 16.3+, use retry() unless you specifically want a client-only re-render.

AdvancedComponent-level boundaries with catchError

error.tsx is tied to route segments. For a widget inside a page (a weather card, a recommendations rail) you'd normally write a class component error boundary; catchError from next/error (stable in 16.3) builds one for you that also knows about Next.js: it lets redirect() and notFound() through, offers retry(), and clears itself when you navigate to another route.

app/widget-boundary.tsx
"use client";

import { catchError, type ErrorInfo } from "next/error";

function WidgetFallback({ title }: { title: string }, { error, retry }: ErrorInfo) {
  return (
    <div role="alert" className="widget">
      <strong>{title} is unavailable</strong> ({error instanceof Error ? error.message : String(error)})
      <button onClick={() => retry()}>Retry</button>
    </div>
  );
}

export default catchError(WidgetFallback);
app/weather.tsx
"use client";

import { useState } from "react";

export function Weather() {
  const [broken, setBroken] = useState(false);
  if (broken) throw new Error("weather API returned 503");
  return <button onClick={() => setBroken(true)}>Weather: 18°C (click to break)</button>;
}
app/page.tsx
import WidgetBoundary from "./widget-boundary";
import { Weather } from "./weather";

export default function Home() {
  return (
    <main>
      <h1>Home</h1>
      <WidgetBoundary title="Weather">
        <Weather />
      </WidgetBoundary>
      <p id="news">News: still here</p>
    </main>
  );
}
What’s happening
  1. catchError(WidgetFallback) returns a component that takes WidgetFallback's first-argument props (title) plus children, and wraps the children in an error boundary.
  2. The fallback function receives two arguments: the wrapper's props and an ErrorInfo with error, retry and reset. It must live in a "use client" module, like any boundary.
  3. page.tsx is a Server Component; it can render <WidgetBoundary title="Weather"> because the wrapper is a Client Component with serialisable props.
  4. Clicking the weather button sets broken, and the next render throws. Headless Chrome against next start read main's text afterwards as: "Home / Weather is unavailable (weather API returned 503) Retry / News: still here". Only the widget was replaced.
  5. Compare with error.tsx: same mechanism, but you choose the blast radius per component instead of per route segment. The docs note you don't need to wrap error.tsx exports in catchError; they're already rendered inside Next.js's boundary.
AdvancedWhat you get without a boundary, and in development

If no error.tsx catches a server error in production, Next.js renders its built-in error UI. In the observability example later in this chapter (/report, no error.tsx anywhere), headless Chrome read the page as:

headless Chrome, next start, no error.tsx in the app
This page couldn’t load

A server error occurred. Reload to try again.

Reload

ERROR 1808271612

The number is the digest again, so a user's screenshot is enough to find the server log line.

In development (next dev), the same dashboard behaves differently in three ways. I ran it and read the error UI: error.message was the real message, connect ECONNREFUSED db.internal:5432 (user=admin), because dev serialises it for debugging; the digest was a different number (1787787768: it's derived from the error, and dev's stack differs from the production build's); and the Next.js dev indicator in the corner turned red with "1 Issue", which opens the error overlay with the server stack mapped to src/app/lib/report.ts:10:11. The overlay doesn't replace your error.tsx, it sits on top as a badge. Never test error messages in dev and assume production shows the same.

AdvancedErrors that boundaries don't handle
  • Event handlers and async callbacks run outside rendering, so a throw in onClick never reaches an error boundary (in the observability example, a throwing click handler only fired window's error event; the page stayed as it was). Catch those yourself and put the error in state, or wrap the work in startTransition: per the docs, an unhandled error inside a transition is rethrown to the nearest boundary.
  • notFound(), redirect(), forbidden(), unauthorized() work by throwing special errors that Next.js recognises. Boundaries let them pass through to not-found.tsx and the redirect logic. The trap is your own try/catch around them, covered in the next topic.
  • To deliberately escalate, throw from inside error.tsx: the error goes to the parent boundary.
AdvancedError-handling traps interviewers like

#Errors in Server Actions and Route Handlers

Added

Server Actions and Route Handlers are both "the client calls the server" but they fail differently, and the difference is what you control. A Server Action is an RPC call that React makes for you: the client sends a POST with the action's ID and arguments, and gets back an RSC stream holding the return value. Whatever you return reaches the client exactly; whatever you throw reaches the client as a digest only. So the rule from the docs: model expected errors as return values, keep throw for the unexpected.

A Route Handler is plain HTTP. You choose the status code and the body, so expected errors are a 4xx with a JSON body you design. If the handler throws, Next.js answers 500 with an empty body and logs the error on the server.

A mental model: an action's return value is a letter you write to the user, a thrown error is an incident report that goes to the ops team (the server log and onRequestError), and the user only gets the incident number.

Server Action throw new Error( "SMTP login failed…") Server terminal message, stack, digest onRequestError same error + route context HTTP 500, RSC body 1:E{"digest":"…"} Browser error #441 + digest Your tracker search by digest the user quotes it A returned { ok: false, message } skips all of this: status 200, the object arrives as written.
A throw splits into a detailed copy for you and a digest for the user; a return value is just data.

The page has four buttons: Subscribe (a form with useActionState), Delete everything (an action called from a click handler), and two Save and go forms (a redirect inside try/catch, and the fix). The Result block is what headless Chrome recorded for each click against next start, with each action's POST response captured by wrapping window.fetch (bodies are the RSC lines after the header line 0:{…}; other headers left out), and what the server terminal printed. The other files follow.

app/actions.ts
"use server";

export type SubscribeState = { ok: boolean; message: string };

export async function subscribe(_prev: SubscribeState, formData: FormData): Promise<SubscribeState> {
  const email = String(formData.get("email") ?? "");
  if (!email.includes("@")) {
    // expected failure: return it as data
    return { ok: false, message: "Enter a valid email address." };
  }
  if (email.startsWith("crash")) {
    // unexpected failure: a bug or an outage
    throw new Error("SMTP login failed for ops@corp.internal");
  }
  return { ok: true, message: `Subscribed ${email}` };
}
Result (headless Chrome against next start; each action POST captured by wrapping window.fetch)
Subscribe "rohit"        POST / → 200 text/x-component   1:{"ok":false,"message":"Enter a valid email address."}
                         form shows: Enter a valid email address.
Delete everything        POST / → 500 text/x-component   1:E{"digest":"2720023591"}
                         caught: Minified React error #441; … | digest=2720023591
Save and go              POST / → 200 text/x-component   1:"caught: NEXT_REDIRECT"
                         form shows: caught: NEXT_REDIRECT    (still on /)
Subscribe "crash@x.com"  POST / → 500 text/x-component   1:E{"digest":"3509504137"}
                         app/error.tsx: Something went wrong / Minified React error #441; … / 3509504137

--- terminal (next start)
⨯ Error: refusing: table users is locked by pid 4242
    at e (.next/server/chunks/ssr/[root-of-the-server]__13n9tvn6z73h3._.js:1:760)
    … {
  digest: '2720023591'
}
⨯ Error: SMTP login failed for ops@corp.internal
    at f (.next/server/chunks/ssr/[root-of-the-server]__13n9tvn6z73h3._.js:1:972)
    … {
  digest: '3509504137'
}
What’s happening
  1. subscribe has the useActionState signature (previousState, formData). For a bad email it returns { ok: false, message }; React sends that object back and useActionState makes it the new state, so the form shows "Enter a valid email address." The response captured in the browser was 200, text/x-component, with the body 1:{"ok":false,"message":"Enter a valid email address."}: the return value, as written.
  2. For crash@x.com it throws. The server log printed ⨯ Error: SMTP login failed for ops@corp.internal with its stack and digest: '3509504137'; the response was 500 with the body 1:E{"digest":"3509504137"}, and nothing else.
  3. When an action called through useActionState throws, React rethrows the error during the next render, so the nearest error boundary takes over: app/error.tsx replaced the page and showed the #441 placeholder with digest 3509504137. The form, and anything the user typed, is gone. That's the right outcome for a real outage and the wrong one for a typo, which is why validation must be a return value.
  4. Never return err.message from a catch to "be helpful": returned data isn't redacted, so you'd ship whatever the library put in the message (hosts, SQL, emails) to the browser. Return a message you wrote; log the original.
  5. What this demonstrates: an action's return type is its error API. Design it like one ({ ok, message, fieldErrors }), and treat throw as "show the error boundary and page someone".
AdvancedThe form, the boundary and the page
app/subscribe-form.tsx
"use client";

import { useActionState } from "react";
import { subscribe, type SubscribeState } from "./actions";

const initial: SubscribeState = { ok: false, message: "" };

export function SubscribeForm() {
  const [state, formAction, pending] = useActionState(subscribe, initial);
  return (
    <form action={formAction}>
      <input name="email" defaultValue="rohit" />
      <button disabled={pending}>Subscribe</button>
      <p id="form-message" aria-live="polite">{state.message}</p>
    </form>
  );
}
app/error.tsx
"use client";

export default function RootError({ error, retry }: { error: unknown; retry: () => void }) {
  const e = error as Error & { digest?: string };
  return (
    <div role="alert">
      <h2>Something went wrong</h2>
      <p id="boundary-message">{e.message}</p>
      <p id="boundary-digest">{e.digest}</p>
      <button onClick={() => retry()}>Retry</button>
    </div>
  );
}
app/page.tsx
import { saveThenRedirect, saveThenRedirectFixed } from "./redirect-actions";
import { DeleteButton } from "./delete-button";
import { RedirectForm } from "./redirect-form";
import { SubscribeForm } from "./subscribe-form";

export default function Home() {
  return (
    <main>
      <SubscribeForm />
      <DeleteButton />
      <RedirectForm label="Save and go" action={saveThenRedirect} />
      <RedirectForm label="Save and go (fixed)" action={saveThenRedirectFixed} />
    </main>
  );
}
AdvancedCalling an action from an event handler

When you call an action as a plain async function (not through a form or useActionState), a thrown error becomes a rejected promise in the browser, which you can catch like any other.

app/delete-action.ts
"use server";

export async function deleteEverything() {
  throw new Error("refusing: table users is locked by pid 4242");
}
app/delete-button.tsx
"use client";

import { useState } from "react";
import { deleteEverything } from "./delete-action";

export function DeleteButton() {
  const [caught, setCaught] = useState("");
  async function onClick() {
    try {
      await deleteEverything();
    } catch (err) {
      const e = err as Error & { digest?: string };
      setCaught(`${e.message} | digest=${e.digest}`);
    }
  }
  return (
    <>
      <button onClick={onClick}>Delete everything</button>
      <p id="caught">{caught}</p>
    </>
  );
}
What’s happening
  1. deleteEverything() in the client is a stub that POSTs the action ID to the server and decodes the RSC response.
  2. The server action throws; the server logs the full error with digest: '2720023591' and replies 500 with 1:E{"digest":"2720023591"}.
  3. The stub turns that into a rejected promise whose value is an Error with the #441 placeholder message and digest 2720023591. Because the call is inside try/await, it lands in catch, and the page stays as it was.
  4. Nothing reached an error boundary: the error happened in an event handler, outside rendering. This is the pattern when one failed button shouldn't take down the page: catch, show a toast with the digest, offer a retry.
redirect() inside try/catch
app/redirect-actions.ts
"use server";

import { redirect, unstable_rethrow } from "next/navigation";

export async function saveThenRedirect(_prev: string, _formData: FormData): Promise<string> {
  try {
    // ...save something, then:
    redirect("/thanks");
  } catch (err) {
    return `caught: ${err instanceof Error ? err.message : String(err)}`;
  }
}

export async function saveThenRedirectFixed(_prev: string, _formData: FormData): Promise<string> {
  try {
    redirect("/thanks");
  } catch (err) {
    unstable_rethrow(err); // lets redirect(), notFound() and friends through
    return `caught: ${err instanceof Error ? err.message : String(err)}`;
  }
}

The "Save and go" button submits saveThenRedirect through useActionState. Where does the user end up, and what does the form show?

Show answer

They stay on /, and the form shows caught: NEXT_REDIRECT. The fixed version goes to /thanks.

What’s happening
  1. redirect("/thanks") doesn't return: it throws a special error whose message is NEXT_REDIRECT (the target URL rides along on the error object). Next.js catches it further up and turns it into a redirect.
  2. Here your own catch gets there first. It treats the redirect like any failure and returns "caught: NEXT_REDIRECT"; the action completes normally, so the response is 200 with 1:"caught: NEXT_REDIRECT" and no redirect instruction (measured above).
  3. useActionState sets the state to that string, the form displays it, and location.pathname stays /.
  4. unstable_rethrow(err) at the top of the catch checks whether err is one of Next.js's control-flow errors (redirect, permanentRedirect, notFound, and the internal errors request APIs throw during prerendering) and rethrows it. With it, the second button navigated to /thanks and showed "Thanks!".
  5. Simpler alternative: call redirect() after the try/catch, once the work has succeeded. That's the usual shape: try the mutation, return errors as data, and redirect outside the try.
AdvancedThe redirect demo's other files
app/redirect-form.tsx
"use client";

import { useActionState } from "react";

type Props = { label: string; action: (prev: string, formData: FormData) => Promise<string> };

export function RedirectForm({ label, action }: Props) {
  const [result, formAction] = useActionState(action, "");
  return (
    <form action={formAction}>
      <button>{label}</button>
      <p className="redirect-result">{result}</p>
    </form>
  );
}
app/thanks/page.tsx
export default function Thanks() {
  return <h1>Thanks!</h1>;
}
What’s happening
  1. RedirectForm receives the action as a prop from the Server Component page. Passing a Server Action across the boundary is allowed (it's sent as a reference, not code), which lets one client component drive both versions.
  2. useActionState(action, "") wraps it; the form's action posts the form to the server and the returned string becomes result.
  3. For the broken version the string is caught: NEXT_REDIRECT; for the fixed one there is no return value at all, because the rethrown redirect makes Next.js answer with a redirect and the router navigates.
AdvancedRoute Handlers
app/api/orders/[id]/route.ts
export async function GET(_req: Request, ctx: RouteContext<"/api/orders/[id]">) {
  const { id } = await ctx.params;
  if (!/^\d+$/.test(id)) {
    // expected: the caller sent bad input, say so with a 4xx and a body
    return Response.json({ error: "id must be numeric" }, { status: 400 });
  }
  if (id === "500") {
    // unexpected: let it throw
    throw new Error("orders replica lag > 30s (host=db-replica-2)");
  }
  return Response.json({ id: Number(id), status: "shipped" });
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/orders/[id]
└ ○ /thanks


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
curl -s -i (date and connection headers left out)
== /api/orders/7
HTTP/1.1 200 OK
content-type: application/json

{"id":7,"status":"shipped"}

== /api/orders/abc
HTTP/1.1 400 Bad Request
content-type: application/json

{"error":"id must be numeric"}

== /api/orders/500
HTTP/1.1 500 Internal Server Error
Transfer-Encoding: chunked

(empty body)

--- terminal (next start)
⨯ Error: orders replica lag > 30s (host=db-replica-2)
    at t (.next/server/chunks/_sandbox_nextjs_1rz_2wzm3t4ny._.js:1:196)
What’s happening
  1. The handler is ƒ (dynamic): it reads a dynamic params value, so it runs per request.
  2. A non-numeric id is the caller's mistake. The handler returns 400 with a JSON body the client can act on; that's part of the API contract and should be documented like the success shape.
  3. 500 throws. Next.js catches it, logs the message and stack on the server and sends 500 Internal Server Error with an empty body: no message, no digest, no stack. The host name in the message stayed on the server.
  4. Route Handler errors don't get a digest in the log or the response, so to correlate a client report with a log line, add your own request ID (a header you set in proxy.ts, or one your platform adds) and log it.
  5. If you want a JSON error body for unexpected failures too, wrap the handler body in try/catch, log the error, and return Response.json({ error: "internal", requestId }, { status: 500 }). Don't echo err.message.
AdvancedTraps with action and handler errors

#Instrumentation and OpenTelemetry

Added

Error boundaries are for the user; instrumentation is for you. Next.js gives you two files at the project root (or in src/): instrumentation.ts, which runs on the server, and instrumentation-client.ts, which runs in the browser. Think of them as the server's and the browser's flight recorders: you install the recorder once at startup, and it sees every request or page load after that.

instrumentation.ts has two optional exports. register() is called once when a server instance starts, and the server waits for it before handling requests: it's where you start an OpenTelemetry SDK, an APM agent or an error tracker. onRequestError(error, request, context) (Next.js 15+) is called whenever the server catches an error while rendering a page, running a Route Handler or a Server Action, with the original error (message, stack, digest) and context such as the route pattern and what kind of work failed. It's the one place that sees the unredacted version of every error the user only saw a digest for.

instrumentation-client.ts (Next.js 15.3+) is a plain module that runs after the HTML loads and before React hydrates: the place to initialise browser error tracking, analytics and performance marks. It can export onRouterTransitionStart(url, navigationType) to observe client navigations.

The example app fails in five places on purpose: a page render (/report), a Route Handler (/api/health), a Server Action (a form on /), a click handler in the browser, and proxy.ts. The Result block is the server terminal under next start while I requested each one (the browser side comes later in the topic).

instrumentation.ts
import type { Instrumentation } from "next";

export async function register() {
  console.log(`[instrumentation] register() runtime=${process.env.NEXT_RUNTIME}`);
}

export const onRequestError: Instrumentation.onRequestError = async (err, request, context) => {
  const e = err as Error & { digest?: string };
  // in a real app: await fetch(YOUR_ERROR_TRACKER, { method: "POST", body: ... })
  console.log(
    "[onRequestError]",
    JSON.stringify({
      message: e.message,
      digest: e.digest,
      path: request.path,
      method: request.method,
      routePath: context.routePath,
      routeType: context.routeType,
      renderSource: "renderSource" in context ? context.renderSource : undefined,
      renderType: "renderType" in context ? context.renderType : undefined,
    }),
  );
};
Result (terminal, next start, while requesting each route; stack lines left out)
✓ Ready in 63ms
✓ Running next.config.ts took 16ms
[instrumentation] register() runtime=nodejs
⨯ Error: report query timed out
  digest: '1808271612'
[onRequestError] {"message":"report query timed out","digest":"1808271612","path":"/report","method":"GET","routePath":"/report","routeType":"render","renderSource":"react-server-components"}
⨯ Error: health check: redis unreachable
[onRequestError] {"message":"health check: redis unreachable","path":"/api/health","method":"GET","routePath":"/api/health","routeType":"route"}
Error: proxy failed to parse session cookie
⨯ Error: report query timed out
  digest: '1808271612'
[onRequestError] {"message":"report query timed out","digest":"1808271612","path":"/report?_rsc=nbyHqrfo6NJ5Ldm2","method":"GET","routePath":"/report","routeType":"render","renderSource":"react-server-components-payload"}
⨯ Error: payment provider timed out
  digest: '1288953854'
[onRequestError] {"message":"payment provider timed out","digest":"1288953854","path":"/","method":"POST","routePath":"/","routeType":"action","renderSource":"react-server-components-payload"}
What’s happening
  1. register() printed [instrumentation] register() runtime=nodejs once, right after next start was ready and before any request arrived. It printed nothing during next build, and nothing again on later requests. process.env.NEXT_RUNTIME tells you which runtime you're in, because the same file is also loaded for the Edge runtime when a route uses it.
  2. A request to /report streamed the shell and then the Revenue hole threw. onRequestError received the original error ("report query timed out"), the same digest as the server log (1808271612), routePath: "/report", routeType: "render" and renderSource: "react-server-components".
  3. /api/health threw inside a Route Handler: routeType: "route", and no digest (Route Handlers don't get one, as in the previous topic).
  4. Submitting the form ran failingAction, which threw: routeType: "action", method: "POST", path: "/". The browser, with no error.tsx in this app, showed Next.js's built-in "This page couldn’t load … ERROR 1288953854", and the log line had digest 1288953854.
  5. onRequestError is async: the docs ask you to await any reporting you do in it. The "renderSource" in context checks are there because context is a union in the types (render errors carry renderSource and renderType, route and action errors may not), and narrowing is what TypeScript needs. In my runs, renderType was never present, so JSON.stringify dropped it.
AdvancedRoutes that fail on purpose
app/report/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";

async function Revenue() {
  await connection();
  throw new Error("report query timed out");
  return <p>never</p>;
}

export default function ReportPage() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Revenue />
    </Suspense>
  );
}
app/api/health/route.ts
import { connection } from "next/server";

export async function GET() {
  await connection(); // run per request (a GET without it is prerendered at build)
  throw new Error("health check: redis unreachable");
}
app/actions.ts
"use server";

export async function failingAction() {
  throw new Error("payment provider timed out");
}
app/page.tsx
import { ClientBomb } from "./client-bomb";
import { failingAction } from "./actions";

export default function Home() {
  return (
    <main>
      <h1>Home</h1>
      <form action={failingAction}>
        <button>Run failing action</button>
      </form>
      <ClientBomb />
    </main>
  );
}
app/layout.tsx
import Link from "next/link";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        <nav>
          <Link href="/">Home</Link> <Link href="/report">Report</Link>
        </nav>
        {children}
      </body>
    </html>
  );
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/health
├ ○ /proxied
└ ◐ /report


ƒ Proxy (Middleware)

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand

The second /report error is the same page reached by clicking the Report link: a client navigation fetches the RSC payload (?_rsc=…), so renderSource is react-server-components-payload instead of react-server-components. The same digest (1808271612) in both shows that the digest identifies the error, not the request.

Which errors reach onRequestError?

With the app above running in production, five things fail: the /report render, the /api/health handler, the form's Server Action, the Throw in the browser button's click handler, and proxy.ts throwing for /proxied?explode=1. Which of them call onRequestError?

Show answer

Three: the render, the Route Handler and the action. Not the browser click handler, and (measured in 16.4.3, against what the docs say) not proxy.ts either.

What’s happening
  1. Render, route and action errors happen while the Next.js server handles a request; each produced an [onRequestError] line with routeType render, route and action (log above).
  2. The click handler throws in the browser. onRequestError is a server hook, so it never hears about it; instrumentation-client.ts's window error listener recorded click handler bug (below). Client errors need a client-side tracker.
  3. The proxy error returned 500 and the terminal printed Error: proxy failed to parse session cookie with a stack, but no [onRequestError] line followed, in next start and in next dev alike. The docs say otherwise: the onRequestError page lists Proxy (routeType: 'proxy') among the contexts it reports.
  4. The source explains the gap. The wrapper Next.js puts around your proxy function (next/dist/build/templates/middleware.js) does call onRequestError with routeType: 'proxy' and routePath: '/proxy', but it finds your instrumentation.ts through a global that only the Edge runtime sandbox sets (_ENTRIES.middleware_instrumentation). proxy.ts always runs on Node.js in 16, so the lookup finds nothing. To confirm, I renamed the file to the deprecated middleware.ts (export function middleware), which still runs on the Edge runtime: the build warned that the convention is deprecated, the server printed register() runtime=edge as well as runtime=nodejs, and the same request then produced [onRequestError] {"message":"proxy failed to parse session cookie","path":"/proxied?explode=1","method":"GET","routePath":"/proxy","routeType":"proxy"}.
  5. Practical consequence: in 16.4.3, wrap your proxy.ts logic in try/catch and report errors from there yourself, and never assume one hook gives you full coverage; check each kind of failure once against your tracker.
AdvancedThe browser side: instrumentation-client.ts

The rest of the app: the browser-side file, the button whose click handler throws, and the proxy.

instrumentation-client.ts
// Runs in the browser after the HTML loads and before hydration
type Log = { errors: string[]; navigations: string[] };
const log: Log = { errors: [], navigations: [] };
(window as unknown as { __obs: Log }).__obs = log;

window.addEventListener("error", (event) => {
  log.errors.push(String(event.error?.message ?? event.message));
});

export function onRouterTransitionStart(url: string, navigationType: "push" | "replace" | "traverse") {
  log.navigations.push(`${navigationType} ${url}`);
}
app/client-bomb.tsx
"use client";

export function ClientBomb() {
  return (
    <button
      onClick={() => {
        throw new Error("click handler bug");
      }}
    >
      Throw in the browser
    </button>
  );
}
proxy.ts
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  if (request.nextUrl.searchParams.has("explode")) {
    throw new Error("proxy failed to parse session cookie");
  }
  return NextResponse.next();
}

export const config = { matcher: "/proxied" };
app/proxied/page.tsx
export default function Proxied() {
  return <h1>Proxied</h1>;
}
headless Chrome, next start: window.__obs after each step
page load                      errors: []                                   navigations: []
click "Throw in the browser"   errors: ["click handler bug"]
click the Report link          errors: ["click handler bug", "Minified React error #441; …"]   navigations: ["push /report"]
                               page: This page couldn’t load … ERROR 1808271612
What’s happening
  1. instrumentation-client.ts runs once per page load, before hydration; the docs guarantee that only its synchronous top-level code finishes before React starts, so anything you await or import() there may land later. Keep it small: the docs say dev warns if it takes more than 16ms.
  2. The error listener caught click handler bug: an uncaught error in an event handler reaches window.onerror, not a React boundary. This is where a real tracker (Sentry, Datadog RUM) hooks in.
  3. Clicking Report called onRouterTransitionStart("/report", "push") before the navigation; back/forward would be "traverse". From 16.3, an experimental flag (experimental.instrumentationClientRouterTransitionEvents) adds a third argument with an ID, timestamp and the routes you came from.
  4. The /report render failed on the server, and the client's rethrow of that error also reached the window listener as #441: so a browser tracker sees server-render errors too, but only as the placeholder and digest. Joining the two sides by digest is how you get the full story.
AdvancedOpenTelemetry: Next.js already emits the spans

Logs tell you what happened; a trace tells you where the time went in one request, as a tree of timed spans. Next.js is instrumented with OpenTelemetry out of the box: it creates spans for the request, the render, fetch calls, Route Handlers, generateMetadata and more. Nothing records them until you install an SDK, and register() is where that happens. The docs recommend @vercel/otel, which works on Vercel and self-hosted, in the Node.js and Edge runtimes.

The packages aren't in the site's template, so I built this in a separate copy of the template, outside the repository, after npm install @vercel/otel @opentelemetry/sdk-logs @opentelemetry/api-logs @opentelemetry/instrumentation @opentelemetry/api @opentelemetry/sdk-trace-base @opentelemetry/core (it installed @vercel/otel 2.1.3, @opentelemetry/api 1.9.1, @opentelemetry/sdk-trace-base 2.12.0). A real app sends spans to a collector over OTLP; to see them here, a tiny exporter prints one line per finished span.

instrumentation.ts
import { registerOTel } from "@vercel/otel";
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { LineExporter } from "./lib/line-exporter";

export function register() {
  registerOTel({
    serviceName: "shop",
    // a real app exports to a collector (OTLP); this prints one line per finished span
    spanProcessors: [new SimpleSpanProcessor(new LineExporter())],
  });
}
lib/line-exporter.ts
import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
import { ExportResultCode, type ExportResult } from "@opentelemetry/core";

export class LineExporter implements SpanExporter {
  export(spans: ReadableSpan[], done: (result: ExportResult) => void) {
    for (const s of spans) {
      const ms = (s.duration[0] * 1e3 + s.duration[1] / 1e6).toFixed(1);
      const trace = s.spanContext().traceId.slice(0, 6);
      const parent = s.parentSpanContext?.spanId.slice(0, 6) ?? "root";
      console.log(`[span] trace=${trace} id=${s.spanContext().spanId.slice(0, 6)} parent=${parent} ${s.name} ${ms}ms`);
    }
    done({ code: ExportResultCode.SUCCESS });
  }
  async shutdown() {}
}
lib/products.ts
import { trace } from "@opentelemetry/api";

const tracer = trace.getTracer("shop");

export async function getProduct(id: string) {
  // a custom span: it joins the trace of the request that's rendering
  return tracer.startActiveSpan("db.getProduct", async (span) => {
    try {
      span.setAttribute("product.id", id);
      await new Promise((r) => setTimeout(r, 40)); // pretend database call
      return { id, name: `Product ${id}` };
    } finally {
      span.end();
    }
  });
}
app/products/[id]/page.tsx
import { getProduct } from "@/lib/products";

export default async function ProductPage({ params }: PageProps<"/products/[id]">) {
  const { id } = await params;
  const [product, price] = await Promise.all([
    getProduct(id),
    fetch("http://localhost:4511/api/price").then((r) => r.json()),
  ]);
  return (
    <h1>
      {product.name}: €{price.price}
    </h1>
  );
}

The app also has app/products/[id]/loading.tsx (a "Loading…" paragraph) and app/api/price/route.ts (await connection(), then Response.json({ price: 42 })). The build listed /products/[id] as ◐ and /api/price as ƒ. One curl localhost:4511/products/8 against next start printed this trace (the short load route module, resolve page components, prepare route module and start response spans, which appeared on every request in my re-run, are left out):

terminal, next start: one request, spans in the order they finished
[span] trace=80d723 id=4892b4 parent=0347f1 resolve segment modules 0.9ms
[span] trace=80d723 id=0347f1 parent=369f89 build component tree 1.8ms
[span] trace=80d723 id=0048cb parent=b302d4 executing api route (app) /api/price 0.2ms
[span] trace=80d723 id=b302d4 parent=ec8bfa GET /api/price 5.2ms
[span] trace=80d723 id=ec8bfa parent=369f89 fetch GET http://localhost:4511/api/price 9.8ms
[span] trace=80d723 id=0eb290 parent=369f89 build component tree 0.5ms
[span] trace=80d723 id=4b4fae parent=369f89 db.getProduct 39.3ms
[span] trace=80d723 id=369f89 parent=317e12 render route (app) /products/[id] 48.0ms
[span] trace=80d723 id=78f7cf parent=317e12 NextNodeServer.clientComponentLoading 1.4ms
[span] trace=80d723 id=c9468a parent=369f89 db.getProduct 27.9ms
[span] trace=80d723 id=317e12 parent=root GET /products/[id] 53.2ms
GET /products/[id] 53.2ms render route (app) /products/[id] 48.0ms build component tree (×2) 1.8 + 0.5ms fetch GET http://localhost:4511/api/price 9.8ms GET /api/price (the handler, same trace) 5.2ms db.getProduct (custom span, ×2) 39.3 + 27.9ms all spans share trace id 80d723…; indentation is the parent → child link
The observed trace for one request, drawn as its parent/child tree.
What’s happening
  1. registerOTel installs a tracer provider when the server starts. From then on, Next.js's built-in spans are recorded: the root GET /products/[id] (span type BaseServer.handleRequest), render route (app) /products/[id], build component tree, resolve segment modules and so on, all with the same trace ID 80d723….
  2. fetch is instrumented: the page's fetch got a fetch GET http://localhost:4511/api/price span, and the Route Handler that answered it produced GET /api/price as a child of that fetch span. The outgoing request carried the trace context, so the downstream handler joined the same trace. Across services, that's how one trace follows a request through your whole system.
  3. db.getProduct is my custom span. startActiveSpan makes it a child of whatever span is active, here the render span, and span.end() in finally closes it even if the function throws. Attributes (product.id) are what you filter by in your tracing backend.
  4. The trace showed something worth knowing: db.getProduct ran twice in one request, and build component tree appeared twice, while the fetch ran once. I saw the same on every request (and wrapping getProduct in React's cache() didn't change it, so the two calls were in separate React renders). The second render is Partial Prefetching (partialPrefetching: true, the template's default): after the normal dynamic render, Next.js runs a "runtime prerender" of the page and embeds the result in the response so the client can cache it (see two render passes per request in the data fetching chapter). Rebuilt with partialPrefetching: false and nothing else changed, three requests each produced one db.getProduct span and one build component tree. The fetch wasn't repeated because, during that prerender, Next.js's fetch returns a promise that never resolves for an uncached request instead of calling the network (next/dist/server/lib/patch-fetch.js, the prerender-runtime case), so it becomes a hole; a plain function can't know it's in a prerender, so it runs again. Traces are how you discover this kind of thing.
  5. Spans created at the top level of instrumentation.ts or inside register() don't belong to any request (the docs: they're dropped or become separate root traces); create spans in code that runs during a request.

The docs also describe a manual setup with @opentelemetry/sdk-node for when @vercel/otel isn't flexible enough. NodeSDK only works in the Node.js runtime, so import it conditionally (I didn't run this one):

instrumentation.ts (manual setup, from the docs, not run)
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    await import("./instrumentation.node");
  }
}

Two environment variables control Next.js's own spans: NEXT_OTEL_VERBOSE=1 emits more spans than the default set, and NEXT_OTEL_FETCH_DISABLED=1 turns off the fetch spans (useful when another library instruments fetch).

AdvancedHow instrumentation evolved
  • 13.2: instrumentation.ts with register() added as an experimental feature behind a next.config flag (experimental.instrumentationHook), which older code and tutorials still set.
  • 14.0.4: Turbopack support for instrumentation.
  • 15.0: instrumentation stable (no flag), and onRequestError added. Before 15, server error reporting meant wrapping handlers or using a vendor SDK that patched Next.js internals.
  • 15.3: instrumentation-client.ts. Before it, client monitoring was initialised in a root-layout client component (running after hydration started) or in pages/_app.
  • 16.3: experimental third argument for onRouterTransitionStart.
  • 15 also removed automatic Speed Insights instrumentation; apps that relied on it need an explicit integration.
AdvancedInstrumentation traps

#Logging and debugging

Added

A Next.js app logs in two places: the server terminal (Server Components, Server Actions, Route Handlers, proxy.ts, instrumentation.ts) and the browser console (Client Components after hydration, instrumentation-client.ts). Client Components also run on the server for the initial HTML, so their render-time logs can appear in the terminal too. Asking "where does this code run?" (see Server Components by default) answers "where do I look for its logs?".

In development, Next.js bridges the two to save you switching windows: server console.log calls made while rendering are replayed in the browser console with a grey Server badge, and with logging.browserToTerminal browser logs are forwarded to the terminal prefixed with [browser]. The dev server also logs every request with timings, every server fetch with its cache result and every Server Action call. In production none of that happens: server logs go to the server's stdout only, and the browser sees only browser logs.

The example renders a product page whose stock count comes from a fetch in a Server Component, with a Client Component button that calls a Server Action. I loaded / in headless Chrome against next dev, clicked Add to cart, and recorded both the terminal and the browser console; the config and other files follow the walkthrough.

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

async function Stock() {
  const res = await fetch("http://localhost:4511/api/stock");
  const { inStock } = await res.json();
  console.log(`[server] Stock rendered, inStock=${inStock}`);
  return <p>{inStock} left</p>;
}

export default function Home() {
  return (
    <main>
      <h1>Product</h1>
      <Suspense fallback={<p>Checking stock…</p>}>
        <Stock />
      </Suspense>
      <CartButton />
    </main>
  );
}
Result (next dev, headless Chrome: load / then click Add to cart)
[browser] Download the React DevTools for a better development experience: https://react.dev/link/react-devtools
[browser] [client] CartButton mounted (src/app/cart-button.tsx:8:13)
[browser] [client] CartButton mounted (src/app/cart-button.tsx:8:13)
 GET /api/stock 200 in 45ms (next.js: 30ms, application-code: 15ms)
[server] Stock rendered, inStock=3
 GET / 200 in 432ms (next.js: 96ms, application-code: 336ms)
 │ GET http://localhost:4511/api/stock 200 in 289ms (cache skip)
 │ │ Cache skipped reason: (auto no cache)
[action] addToCart sku-42 x2
 POST / 200 in 7ms (next.js: 0.7ms, application-code: 6ms)
  └─ ƒ addToCart("sku-42", 2) in 1ms src/app/actions.ts

info: Download the React DevTools for a better development experience: …
log: [HMR] connected
log: [client] CartButton mounted
log: [client] CartButton mounted
log: [server] Stock rendered, inStock=3      ← shown with a grey "Server" badge
What’s happening
  1. Stock is a Server Component, so its console.log ran in the Node.js process: [server] Stock rendered, inStock=3 is in the terminal. Dev also replayed it in the browser console with a "Server" badge (the %c%s%c … Server styling arguments in the raw console call), so you can debug a page from one window.
  2. The request log line GET / 200 in 432ms (next.js: 96ms, application-code: 336ms) splits the time between the framework and your code, and the nested │ GET http://localhost:4511/api/stock 200 in 289ms (cache skip) line is the fetch made during that render, with the full URL because of fetches.fullUrl. "Cache skipped reason: (auto no cache)" says the fetch wasn't cached, which is the default since Next.js 15.
  3. CartButton's effect logged twice in the browser, and both copies were forwarded to the terminal with the source location (src/app/cart-button.tsx:8:13). Twice because React Strict Mode, on by default in the App Router, mounts, unmounts and re-mounts every component in development to expose effects that don't clean up.
  4. Clicking the button called the Server Action. Its own log line went to the terminal, and dev's Server Function logging added └─ ƒ addToCart("sku-42", 2) in 1ms src/app/actions.ts under POST /: name, arguments, duration and file, on by default since logging.serverFunctions defaults to on.
  5. The same app under next start logged only [server] Stock rendered, inStock=3 and [action] addToCart sku-42 x2 in the terminal (no request lines, no fetch lines, no [browser] lines), and the browser console had just one [client] CartButton mounted (no Strict Mode double mount, no replayed server logs). The dev conveniences are dev only.
AdvancedLogging config and the other files
next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  logging: {
    fetches: { fullUrl: true }, // dev: log every server fetch with its URL and cache result
    browserToTerminal: true, // dev: copy browser console output into the terminal
  },
};

export default nextConfig;
app/cart-button.tsx
"use client";

import { useEffect } from "react";
import { addToCart } from "./actions";

export function CartButton() {
  useEffect(() => {
    console.log("[client] CartButton mounted");
  }, []);
  return <button onClick={() => addToCart("sku-42", 2)}>Add to cart</button>;
}
app/actions.ts
"use server";

export async function addToCart(productId: string, qty: number) {
  console.log(`[action] addToCart ${productId} x${qty}`);
}
app/api/stock/route.ts
import { connection } from "next/server";

export async function GET() {
  await connection();
  return Response.json({ inStock: 3 });
}
next build
Route (app)
┌ ◐ /
├ ○ /_not-found
└ ƒ /api/stock


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand

The route table explains the fetch log: / is ◐ because the uncached fetch inside <Stock> can't run at build time, so "Product" and "Checking stock…" are the static shell and Stock renders per request.

Where does this log line appear?

In production (next start), a Server Component calls console.log("rendering product 42"). A teammate opens the page in Chrome with DevTools open. Do they see the line?

Show answer

No. It's printed only in the server's output (the terminal, or your platform's log stream).

What’s happening
  1. Server Components run only on the server, so console.log writes to the Node.js process's stdout.
  2. The browser replay with a "Server" badge is a development feature: under next start, the browser console for this app contained only the client component's own log.
  3. So production debugging of server code means server logs: whatever your host collects from stdout (Vercel's runtime logs, CloudWatch, Datadog), ideally as structured JSON with a request ID, plus the digests that error boundaries show.
  4. If they need the value in the browser, it has to be sent there: rendered into the page, or returned from an action. Don't send debugging data to the client in production code.
AdvancedThe dev overlay, and attaching a debugger

In next dev, the dev indicator (the "N" badge in the corner) turns red with an issue count when something fails, and opens the error overlay: the error, the stack mapped to your source files with a code frame, and whether it came from the server or the browser. Build errors and runtime errors that no boundary handles take over the screen; an error your error.tsx handled stays as the badge. In the dashboard example under next dev, the page showed my error.tsx with the real message and the badge read "1 Issue". devIndicators: false in next.config hides the badge (errors still show).

To step through server code, start the dev server with Node's inspector:

next dev -p 4511 --inspect (first lines of output)
Debugger listening on ws://127.0.0.1:9229/ec567dd7-b373-4832-a2a8-4457fb41f9b9
For help, see: https://nodejs.org/learn/getting-started/debugging
▲ Next.js 16.4.3 (Turbopack)
- Local:         http://localhost:4511
- Network:       http://192.168.53.23:4511
- Debugger port: 9229
What’s happening
  1. --inspect is passed to the Node.js process that runs your server code. Open chrome://inspect (or attach VS Code) and the Next.js server appears as a remote target; breakpoints in Server Components, actions and Route Handlers then pause the server.
  2. --inspect=0.0.0.0 allows attaching from outside the machine (for example into Docker); --inspect-brk must go through NODE_OPTIONS=--inspect-brk next dev instead.
  3. With the error overlay open, the Node.js icon under the version indicator copies the DevTools URL for the server process, a shortcut for the same thing.
  4. Client code is debugged in the browser's DevTools as usual (Sources panel, debugger statements, React DevTools). The docs also give a VS Code launch.json with "debug server-side", "debug client-side" and "debug full stack" configurations.
AdvancedProduction logging that helps
  • Log once, server-side, with context. next start already prints unhandled server errors with ⨯, the stack and the digest (as in every example in this chapter). Add onRequestError to send the same error with routePath and routeType to your tracker.
  • Structured, not chatty. Write JSON lines ({ level, msg, requestId, route, durationMs }) so your log platform can filter; don't leave console.log of whole objects in hot paths, since they're synchronous writes to stdout.
  • Correlate. The digest connects a user's screenshot to an error; a request ID (set in proxy.ts as a header, or from your platform) connects all log lines of one request; a trace ID (OpenTelemetry) connects services.
  • Turn down dev noise with logging.incomingRequests: { ignore: [/\/api\/health/] }, logging.fetches, logging.serverFunctions: false, or logging: false. These options only affect development.
  • browserToTerminal was experimental.browserDebugInfoInTerminal from 15.4 and became logging.browserToTerminal in 16.2; its default forwards only warnings and errors ('warn'), so set true to see console.log too, as in this example.
AdvancedDebugging traps

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.