Next.js NotesRohit’s interview study guide
Chapter 02

Routing

How folders become URLs in the App Router: pages and nested layouts, links and redirects, dynamic segments, route groups, loading and not-found states, parallel and intercepting routes, templates, and what the router prefetches before you click.

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

#Pages and layouts

Added

The App Router uses file-system routing: every folder under app/ is a route segment, and the folder path is the URL path. A folder on its own isn't a page, though. It becomes reachable only when it contains a page.tsx, the UI for exactly that URL. A layout.tsx in the same folder wraps that page and every page below it, receiving them as children. Layouts nest: the root layout wraps the shop layout, which wraps the shirts page.

The mental model is picture frames on a wall. Each layout is a frame, the page is the photo inside the innermost frame. When you move from /shop/shirts to /shop/hats, Next.js doesn't take the frames down: it swaps the photo. The root layout and the shop layout stay mounted, keep their React state (a cart counter, an open menu, a half-typed search) and are not re-rendered or re-fetched. Only the part of the tree below the deepest layout the two URLs share changes. That one rule explains most of the App Router's navigation behaviour.

The other files you'll meet (loading, error, not-found, template, default, route) are covered later in this chapter and in Project structure and special files. Everything in a segment folder that isn't one of these special file names is not routable, so components, helpers and CSS can live next to the page that uses them.

Files app/ ├ layout.tsx ├ page.tsx └ shop/ ├ layout.tsx ├ page.tsx ├ shirts/page.tsx └ hats/page.tsx wraps all / wraps /shop/* /shop /shop/shirts /shop/hats Rendered for /shop/shirts RootLayout <html> <body> <nav> · kept ShopLayout aside with "Cart: 3" · kept, state survives page.tsx for the URL · swapped /shop/shirts → ShirtsPage /shop/hats → HatsPage
Layouts are frames that stay up; the page is the photo that changes.
app/shop/layout.tsx
import { CartCounter } from "./cart-counter";

export default function ShopLayout({ children }: LayoutProps<"/shop">) {
  return (
    <section id="shop-layout">
      <aside>
        <h2>Shop</h2>
        <CartCounter />
      </aside>
      <main>{children}</main>
    </section>
  );
}
What’s happening
  1. This is app/shop/layout.tsx, the layout for the shop segment. The app around it (all seven files are below) has four folders with a page.tsx: app/, shop/, shop/shirts/ and shop/hats/. So it has exactly four public URLs: /, /shop, /shop/shirts and /shop/hats. cart-counter.tsx sits in shop/ too, but it isn't a special file name, so it's only a module this layout imports; /shop/cart-counter doesn't exist.
  2. A request for /shop/shirts renders the chain of layouts from the root down to the matching page: RootLayout (app/layout.tsx) gets <ShopLayout> as its children, and ShopLayout gets <ShirtsPage> as its children, which it places inside <main>. You never import a page into a layout; the router passes it in.
  3. The props are typed with LayoutProps<"/shop">, a global helper Next.js generates from your folders during next dev/next build (no import). The root layout uses LayoutProps<"/"> and is the one that must render <html> and <body>.
  4. CartCounter is a Client Component ("use client") rendered inside this Server Component. It holds items in useState, which is browser state that only survives while the component stays mounted.
  5. Click "Cart" three times, then click the Hats link. The URL becomes /shop/hats and the <h1> changes, but the button still says Cart: 3: ShopLayout is shared by both URLs, so the router left it alone and swapped only the page in its children slot. That's layout persistence, and the observation below shows it happening.
AdvancedShop app: root layout, pages, counter

The rest of the app

The root layout puts a nav of <Link>s around every page; the pages are one-liners so the only state in the app is the cart counter.

app/layout.tsx
import Link from "next/link";
import "./globals.css";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        <nav id="site-nav">
          <Link href="/">Home</Link> · <Link href="/shop">Shop</Link> ·{" "}
          <Link href="/shop/shirts">Shirts</Link> · <Link href="/shop/hats">Hats</Link>
        </nav>
        {children}
      </body>
    </html>
  );
}
app/page.tsx
export default function HomePage() {
  return <h1>Home</h1>;
}
app/shop/cart-counter.tsx
"use client";

import { useState } from "react";

export function CartCounter() {
  const [items, setItems] = useState(0);
  return (
    <button id="add" onClick={() => setItems(items + 1)}>
      Cart: {items}
    </button>
  );
}
app/shop/page.tsx
export default function ShopPage() {
  return <h1>All products</h1>;
}
app/shop/shirts/page.tsx
export default function ShirtsPage() {
  return <h1 id="shirts">Shirts</h1>;
}
app/shop/hats/page.tsx
export default function HatsPage() {
  return <h1 id="hats">Hats</h1>;
}
AdvancedShop app: route table and browser run

What the build and the browser showed

The build confirms that all four pages (and the built-in /_not-found) are plain static files:

next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /shop
├ ○ /shop/hats
└ ○ /shop/shirts


○  (Static)  prerendered as static content

Here is what headless Chrome showed against next start for that app: the cart was clicked three times on /shop/shirts, a marker attribute was put on the #shop-layout element, and then the nav links were clicked in turn (Hats, then Home, then Shirts). (hidden) means the element is in the DOM but not visible.

Output
on /shop/shirts      cart "Cart: 3"   h1s: Shirts
click Hats  → /shop/hats    cart "Cart: 3"   same #shop-layout node (marker kept)   h1s: Hats, Shirts (hidden)
             network requests for the click: none (the static page was already prefetched)
click Home  → /             #shop-layout still in the DOM, not visible            h1s: Home, Hats (hidden), Shirts (hidden)
click Shirts → /shop/shirts cart "Cart: 3"   same #shop-layout node (marker kept)   h1s: Shirts, Hats (hidden), Home (hidden)

The first two lines are the classic App Router behaviour. The last two are new: even after leaving the shop section entirely, coming back found the same DOM node with the cart still at 3, and the pages you left were still in the document, hidden. That's Next.js 16 with Cache Components (cacheComponents: true, the default for new apps) hiding routes you leave with React's <Activity> instead of unmounting them.

AdvancedLayout state with and without Cache Components

With Cache Components on, the router doesn't unmount the route you navigate away from. It wraps routes in React's <Activity> and switches them to hidden (the DOM stays, with display: none; effects are cleaned up as if unmounted, state is kept). The bundled guide "Preserving UI state" says Next.js keeps up to 3 routes this way and evicts the oldest beyond that. Going back, or clicking a link to a route that is still kept, shows it again with its state, scroll position and form inputs intact.

To see what an older app does, the same seven files were built with an empty next.config.ts (no cacheComponents, so the pre-16 behaviour) and the same clicks replayed:

Output
on /shop/shirts      cart "Cart: 3"   h1s: Shirts
click Hats  → /shop/hats    cart "Cart: 3"   same #shop-layout node   h1s: Hats
click Home  → /             #shop-layout not in the DOM               h1s: Home
click Shirts → /shop/shirts cart "Cart: 0"   a new #shop-layout node  h1s: Shirts

So the rule interviewers expect, and the rule in every Next.js 13–15 codebase, is: a layout keeps its state while you navigate between pages it wraps, and is unmounted (state lost) when you navigate to a URL outside it; the page is unmounted on every navigation. With Cache Components, add: "…unless the route is still one of the last few kept by Activity, in which case it comes back exactly as you left it." If something must reset each time, don't rely on unmounting; see Templates vs layouts.

AdvancedWhat a layout can't see

Because a layout isn't re-rendered when you move between its child pages, it is deliberately cut off from things that change per page:

  • No searchParams. Only page.tsx receives the searchParams prop. A layout that read ?q= would show a stale value after the next navigation, so it isn't given one. Read search params in a Client Component with useSearchParams() (Client Components do re-render on navigation).
  • No pathname. Same reason. An active-link nav is a Client Component using usePathname(), imported into the layout.
  • No access to its children's data. A layout can't pass props to children, and the page can't pass data up. If both need the current user, both call the same data function and let request memoisation or React cache() dedupe it (chapter 4, Sharing data between layouts and pages).
  • params only for segments at or above it. app/shop/[category]/layout.tsx gets params: Promise<{ category: string }>; it can't see a [slug] below it. Use useSelectedLayoutSegment() in a Client Component to read the active child segment.
AdvancedThe component hierarchy inside one segment

When a segment folder has several special files, Next.js nests them in a fixed order. Knowing it answers questions like "does loading.tsx cover a slow layout?" (no) or "does error.tsx catch errors in the layout next to it?" (no):

Rendered asFileWhat it is
outermostlayout.tsxthe frame; persists
↓template.tsxlike a layout, but re-created on navigation
↓error.tsxa React error boundary
↓loading.tsxa <Suspense> boundary with your fallback
↓not-found.tsxthe boundary for notFound()
innermostpage.tsx or a nested layout.tsxthe content, or the next segment down

Because error.tsx and loading.tsx sit inside the layout of the same folder, they can't catch an error thrown by that layout or show a fallback while that layout waits; the parent segment's boundaries do that.

AdvancedRoot layout rules
  • It must exist (in app/ or, with route groups, one per top-level group) and must render <html> and <body>.
  • Don't hand-write <head>, <title> or <meta> there: export metadata or generateMetadata and Next.js manages <head> (chapter 10).
  • In Next.js 15 and later the params prop of every layout and page is a Promise; Next.js 14 and earlier passed a plain object. Next.js 15 still allowed synchronous access with a warning; 16 removed it.
Which components re-render?

With the app above (Cache Components on), you're on /shop/shirts and click the Hats link. Which of RootLayout, ShopLayout, CartCounter, ShirtsPage and HatsPage render again, and what does the cart show?

Show answer

Only HatsPage renders (it's new); RootLayout, ShopLayout and CartCounter are untouched, ShirtsPage is hidden, and the cart still shows its old count.

What’s happening
  1. The router compares the old and new URL segment by segment: / matches, shop matches, shirts vs hats differs. The deepest shared layout is ShopLayout.
  2. Everything at and above ShopLayout is reused as it is: no server render, no RSC request for those segments, no re-render on the client. CartCounter keeps items because its component instance never left.
  3. Below the shared layout, the hats page segment is rendered. It's static and was prefetched when its link entered the viewport, so the observed click made no network request at all.
  4. ShirtsPage isn't destroyed: with Cache Components it's put in a hidden <Activity>, which is why the observed DOM still had Shirts (hidden). Without Cache Components it would be unmounted.
  5. This is the whole point of nested layouts: shared UI is rendered once, keeps its state, and navigation only pays for the part that changed.

#Linking and navigating

Added

There are two ways to move between pages. A hard navigation is what the browser does with a plain <a href>: throw the document away, request new HTML, re-download and re-run JavaScript, lose every bit of client state. A soft navigation (client-side navigation) is what next/link and useRouter do: intercept the click, fetch only the RSC payload for the segments that change (or use one that was already prefetched), render it into the existing React tree and update the URL with the History API. The document, the layouts and their state all survive.

<Link> is the main tool. It renders a real <a href> (so it works without JavaScript, can be opened in a new tab and is crawlable) and adds three things on top: prefetching when the link scrolls into view (production only), client-side transitions on click, and scroll management after the transition. useRouter() from next/navigation is the imperative version for event handlers (push, replace, back, forward, refresh, prefetch). redirect() and permanentRedirect() are for server code that decides during rendering that the user should be somewhere else.

A good way to hold it: <Link> for things the user clicks, useRouter for things your code decides after an event, redirect for things the server decides while rendering. All three end in the same soft navigation when the app is already running in the browser.

app/ui/nav-links.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

const links = [
  { href: "/", label: "Home" },
  { href: "/about", label: "About" },
  { href: "/articles/one", label: "Article one" },
  { href: "/old-team", label: "Old team page" },
];

export function NavLinks() {
  const pathname = usePathname();
  return (
    <nav>
      {links.map((link) => (
        <Link key={link.href} href={link.href} aria-current={pathname === link.href ? "page" : undefined}>
          {link.label}
        </Link>
      ))}
      <Link href="/about" replace id="about-replace">
        About (replace)
      </Link>
      <a href="/about" id="plain-a">
        About (plain a)
      </a>
    </nav>
  );
}
What’s happening
  1. NavLinks is a Client Component because it calls usePathname(), a hook that re-renders on every navigation. The root layout itself never re-renders, so this is how a persistent nav shows the active link: on /about, pathname is "/about" and that link gets aria-current="page".
  2. Each <Link> renders an ordinary <a href>. When it's clicked, Next.js cancels the browser's navigation, renders the new route into the existing tree and calls history.pushState. In the observed run, clicking About from / took history.length from 2 to 3, and a window marker set on load was still there: same document.
  3. The replace prop swaps pushState for replaceState. Clicking About (replace) while on / left history.length at 3, and pressing back afterwards skipped / entirely, because that entry had been overwritten with /about.
  4. Old team page links to /old-team, whose page only calls redirect("/about"). The click landed on /about with exactly one new history entry (2 → 3), and back returned to /: the router followed the redirect inside the soft navigation and never put /old-team in the history.
  5. About (plain a) is a raw <a>, so it's a hard navigation: the marker was gone (new document), the network log showed a Document request for /about, and the fresh page then prefetched the nav's links again from scratch.
  6. If you replaced every <Link> with <a>, the app would still work (it's server-rendered), but every click would reload the page, re-run all JavaScript and reset every layout's state.
AdvancedNav app: layout, buttons, redirect pages

The rest of the app

The root layout renders NavLinks above every page. GoButtons calls the router imperatively; /articles/[id] is a long page for the scroll test; the two old-* pages do nothing but redirect.

app/layout.tsx
import "./globals.css";
import { NavLinks } from "./ui/nav-links";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        <NavLinks />
        {children}
      </body>
    </html>
  );
}
app/page.tsx
import { GoButtons } from "./ui/go-buttons";

export default function HomePage() {
  return (
    <main>
      <h1>Home</h1>
      <GoButtons />
    </main>
  );
}
app/ui/go-buttons.tsx
"use client";

import { useRouter } from "next/navigation";

export function GoButtons() {
  const router = useRouter();
  return (
    <p>
      <button id="push" onClick={() => router.push("/about")}>push /about</button>
      <button id="replace" onClick={() => router.replace("/about")}>replace /about</button>
      <button id="back" onClick={() => router.back()}>back</button>
    </p>
  );
}
app/about/page.tsx
import { GoButtons } from "../ui/go-buttons";

export default function AboutPage() {
  return (
    <main>
      <h1>About</h1>
      <GoButtons />
    </main>
  );
}
app/articles/[id]/page.tsx
import Link from "next/link";

export function generateStaticParams() {
  return [{ id: "one" }, { id: "two" }];
}

export default async function ArticlePage({ params }: PageProps<"/articles/[id]">) {
  const { id } = await params;
  return (
    <main>
      <h1>Article {id}</h1>
      <div style={{ height: 3000 }}>A long article…</div>
      <Link href="/articles/two" id="next">Next article</Link>{" "}
      <Link href="/articles/two" scroll={false} id="next-keep-scroll">Next article (scroll=false)</Link>
    </main>
  );
}
app/old-team/page.tsx
import { redirect } from "next/navigation";

export default function OldTeamPage() {
  redirect("/about");
}
app/old-blog/page.tsx
import { permanentRedirect } from "next/navigation";

export default function OldBlogPage() {
  permanentRedirect("/articles/one");
}
AdvancedNav app: history, scroll and redirect codes

What the build and the browser showed

Both redirecting pages are static, like the pages they point to. /articles/[id] lists its two generateStaticParams pages as ○ plus a ◐ fallback shell for ids that weren't listed (that's Dynamic routes):

next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├   /articles/[id]
│ ├ ◐ /articles/[id]
│ ├ ○ /articles/one
│ └ ○ /articles/two
├ ○ /old-blog
└ ○ /old-team


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

The clicks, replayed in headless Chrome against next start (history.length counts the tab's starting about:blank entry; "same document" means a window variable set on load survived):

Output
load /                                   history 2   same document
Link About (push)            → /about    history 3   same document
router.back()                → /         history 3
Link About (replace)         → /about    history 3   (the / entry was overwritten)
Link Home                    → /         history 3
router.push('/about')        → /about    history 4
Link Home                    → /         history 5
router.replace('/about')     → /about    history 5
router.back()                → /about    (the entry before was the pushed /about)
Link Old team page           → /about    history +1, /old-team never appears; back → /
on /articles/one, scrollY 2260
Link Next article            → /articles/two   scrollY 0
Link Next article scroll={false} → /articles/two   scrollY 2260
plain <a href="/about">      → /about    NOT the same document; network: GET /about (Document)

And the two redirect pages over plain HTTP (curl -sD - -o /dev/null http://localhost:4502/old-team, then /old-blog, headers trimmed):

Output
HTTP/1.1 307 Temporary Redirect
x-nextjs-cache: HIT
x-nextjs-prerender: 1
location: /about
Cache-Control: s-maxage=31536000

HTTP/1.1 308 Permanent Redirect
x-nextjs-cache: HIT
x-nextjs-prerender: 1
location: /articles/one
Cache-Control: s-maxage=31536000

Two things to notice. redirect() gives 307 and permanentRedirect() gives 308, not 302/301: 307/308 tell the client to repeat the request with the same method, so a redirected POST stays a POST. And x-nextjs-cache: HIT with x-nextjs-prerender: 1 means the redirect itself was prerendered at build time: the page called redirect() during the build's render, so the static output for /old-team is a cached 307. No code runs per request.

AdvancedThe useRouter API

useRouter (import it from next/navigation in the App Router) only works in Client Components and gives you:

MethodWhat it does
router.push(href, { scroll })soft navigation, adds a history entry
router.replace(href, { scroll })soft navigation, overwrites the current entry
router.back() / router.forward()history.back() / history.forward()
router.refresh()re-requests the current route's Server Components and merges the result, keeping client state and scroll; doesn't invalidate server caches
router.prefetch(href, { onInvalidate })prefetches like a <Link> would (see Prefetching and instant navigation)
router.bfcacheIdan id that changes on fresh push/replace navigations; a key to reset state (see Templates vs layouts)

Use <Link> whenever the user is clicking something that goes somewhere: it's a real link (middle-click, "open in new tab", crawlers, no JavaScript) and it prefetches. Use router.push when navigation follows logic: after a form's onSubmit succeeds, after a keyboard shortcut, after a timeout.

Advancedredirect and permanentRedirect

Both are called while rendering (Server Components, Client Components during render, Route Handlers, Server Actions) and both work by throwing a special NEXT_REDIRECT error that Next.js catches. That's why their return type is never (no return needed, and TypeScript narrows after them), and why you must not call them inside a try block whose catch would swallow the throw.

redirect(path)permanentRedirect(path)
HTTP status (normal request)307308
From a Server Action, with JavaScriptsoft navigationsoft navigation
From a Server Action, without JavaScript (progressive form)303303
After streaming has started<meta> tag redirect in the HTML (status already sent)same
Default history behaviourreplace (push in Server Actions)same

Pick permanentRedirect only when the old URL is gone for good (a renamed slug, a moved section): browsers and search engines cache 308s. For "you need to log in first" use redirect. For redirects you know without rendering anything (old URLs → new URLs), put them in next.config.ts redirects() or in proxy.ts, which run before any page code; both are in not-found, redirects and status codes. redirect() can't be called in an event handler (it's a render-time throw); use router.push there.

AdvancedScroll and history behaviour
  • Scroll. After a soft navigation to a new page, Next.js scrolls to the top of the new page's first element if it isn't visible; if it is already in view, it leaves the scroll position alone. The observed run went from scrollY 2260 to 0. scroll={false} on <Link> (or { scroll: false } on push/replace) keeps the current position (2260 stayed 2260). A #hash in the href scrolls to that element.
  • Sticky headers. The router skips sticky and fixed elements when choosing what to scroll to, so content can land under a sticky header; set scroll-padding-top on html.
  • Back and forward restore the previous route (and, as the Pages and layouts observation showed, with Cache Components its preserved state and DOM).
  • scroll-behavior: smooth. Up to Next.js 15 the router briefly switched a global smooth scroll off during navigations so they felt instant; Next.js 16 no longer does unless you put data-scroll-behavior="smooth" on <html>.
  • Native History API. window.history.pushState / replaceState are integrated with the router: calling them updates usePathname() and useSearchParams() without a server request. That's the cheap way to put UI state such as a sort order in the URL.
AdvancedHooks around a navigation
  • onNavigate on <Link> (Next.js 15.3+) runs only for client-side navigations (not for Ctrl/Cmd-click into a new tab, external URLs or download links) and its event has preventDefault(). That's how you build an "unsaved changes, leave anyway?" guard.
  • useLinkStatus() from next/link (inside a <Link>'s children) returns { pending }, true until the navigation commits. Use it for a small inline spinner when a link wasn't prefetched; the bundled docs recommend a fixed-size hint that fades in after ~100ms so fast navigations don't flash.
  • usePathname(), useSearchParams(), useParams(), useSelectedLayoutSegment() are the client-side ways to read "where am I"; the App Router has no router.events, so "run something on every route change" is a useEffect on pathname and searchParams.
AdvancedHow it used to work

#Dynamic routes

Added

When you can't list the URLs ahead of time (a post per slug, a product per id, a docs tree of any depth), you name the folder with square brackets and the router captures that part of the URL. app/blog/[slug]/page.tsx matches /blog/hello-world, /blog/routing and any other single segment under /blog, and hands your page params with { slug: "hello-world" }. Three bracket forms cover every case:

FolderMatchesDoesn't matchparams for the example
[slug]/blog/a/blog, /blog/a/b{ slug: "a" }
[...slug] (catch-all)/docs/a, /docs/a/b/c/docs{ slug: ["a", "b", "c"] }
[[...slug]] (optional catch-all)/shop, /shop/a/b{ slug: undefined } for /shop, { slug: ["a", "b"] }

Think of a dynamic segment as a function parameter in the URL: the folder is the function, the bracket name is the parameter name, and the request supplies the argument. Like a function argument, its value isn't known until someone calls (requests) the page, and that's the thing to keep in mind with Next.js 16: params is request data, delivered as a Promise, and a page that waits for it can't be fully prerendered unless you tell the build which values to expect (generateStaticParams) or put the wait behind a <Suspense> boundary.

app/blog/[slug]/page.tsx
import { ShareButton } from "./share-button";

export function generateStaticParams() {
  return [{ slug: "hello-world" }, { slug: "routing" }];
}

export default async function BlogPostPage({ params }: PageProps<"/blog/[slug]">) {
  const { slug } = await params;
  return (
    <article>
      <h1>Post: {slug}</h1>
      <ShareButton />
    </article>
  );
}
What’s happening
  1. The folder name [slug] makes this page match any one segment after /blog/. The page's props are typed with PageProps<"/blog/[slug]">, which Next.js generates from the folder name, so params is typed as Promise<{ slug: string }>.
  2. params is a Promise (Next.js 15+), so the page is async and awaits it: for /blog/hello-world, slug is "hello-world". Next.js 14 and earlier passed a plain object; 15 still allowed params.slug with a warning; 16 only allows the Promise.
  3. generateStaticParams returns the values to prerender at build time: hello-world and routing. With Cache Components, awaiting params at the top of the page (outside <Suspense>) is only allowed because these sample values exist: the build renders the page with each of them and checks nothing else blocks.
  4. A slug that wasn't listed, like /blog/brand-new-post, still works: it's rendered on its first request and then served from the cache (the curl output below shows the first response streamed, the second a cache HIT). That's the default dynamicParams = true; chapter 6 covers turning it off.
  5. <ShareButton> is a Client Component that reads the same value with useParams(), without the page passing it down. On the client, params are plain values, not Promises.
AdvancedCatalog app: catch-all and optional pages

The rest of the app

/blog/archive is a plain static folder next to [slug]; the docs page uses a catch-all and the shop page an optional catch-all. Neither has generateStaticParams, so both read params inside <Suspense>.

app/blog/[slug]/share-button.tsx
"use client";

import { useParams } from "next/navigation";

export function ShareButton() {
  const { slug } = useParams<{ slug: string }>();
  return <button id="share">Share “{slug}”</button>;
}
app/blog/archive/page.tsx
export default function ArchivePage() {
  return <h1>Blog archive</h1>;
}
app/docs/[...slug]/page.tsx
import { Suspense } from "react";

export default function DocsPage({ params }: PageProps<"/docs/[...slug]">) {
  return (
    <main>
      <h1>Docs</h1>
      <Suspense fallback={<p>Loading breadcrumbs…</p>}>
        <Breadcrumbs params={params} />
      </Suspense>
    </main>
  );
}

async function Breadcrumbs({ params }: Pick<PageProps<"/docs/[...slug]">, "params">) {
  const { slug } = await params;
  return (
    <p id="crumbs">
      slug = {JSON.stringify(slug)} → {slug.join(" / ")}
    </p>
  );
}
app/shop/[[...filters]]/page.tsx
import { Suspense } from "react";

export default function ShopPage({ params }: PageProps<"/shop/[[...filters]]">) {
  return (
    <main>
      <h1>Shop</h1>
      <Suspense fallback={<p>Loading filters…</p>}>
        <ActiveFilters params={params} />
      </Suspense>
    </main>
  );
}

async function ActiveFilters({ params }: Pick<PageProps<"/shop/[[...filters]]">, "params">) {
  const { filters } = await params;
  return <p id="filters">filters = {filters === undefined ? "undefined" : JSON.stringify(filters)}</p>;
}
AdvancedCatalog app: route table and curl output

What the build and the requests showed

next build
Route (app)
┌ ○ /_not-found
├   /blog/[slug]
│ ├ ◐ /blog/[slug]
│ ├ ○ /blog/hello-world
│ └ ○ /blog/routing
├ ○ /blog/archive
├   /docs/[...slug]
│ └ ◐ /docs/[...slug]
└   /shop/[[...filters]]
  └ ◐ /shop/[[...filters]]


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

Read it top to bottom. The two slugs from generateStaticParams are fully static (○). Each dynamic route also gets a ◐ entry: a prerendered shell (everything outside the Suspense boundaries, plus the fallbacks) with a hole that is filled per request. For the docs and shop pages that shell is the <h1> plus "Loading…"; for /blog/[slug] it's what unlisted slugs get on their first request. /blog/archive sits next to [slug] and wins for that URL: a static segment always beats a dynamic one at the same level.

The rendered text of a few URLs (curl -s http://localhost:4502<path>, tags and scripts stripped). The streamed HTML contains the fallback first and the resolved content later in the same response, so both appear:

Output
/docs/a                                 Docs · Loading breadcrumbs… · slug = ["a"] → a
/docs/getting-started/install/macos     Docs · Loading breadcrumbs… · slug = ["getting-started","install","macos"] → getting-started / install / macos
/docs                                   404 Not Found (a catch-all needs at least one segment)
/shop                                   Shop · Loading filters… · filters = undefined
/shop/shoes/red                         Shop · Loading filters… · filters = ["shoes","red"]
/blog/hello-world                       Post: hello-world · Share “hello-world”
/blog/archive                           Blog archive
/docs/hello%20world/caf%C3%A9           slug = ["hello%20world","caf%C3%A9"]

And the response headers for a slug that wasn't in generateStaticParams, requested twice (curl -sD - -o /dev/null http://localhost:4502/blog/third-post, trimmed):

Output
request 1   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
request 2   HTTP/1.1 200 OK
            x-nextjs-cache: HIT
            x-nextjs-prerender: 1
            Cache-Control: s-maxage=31536000

The first request was served from the ◐ shell and resumed on the server (x-nextjs-postponed: 1, not cacheable). The second was a cache HIT with the same long s-maxage as the build-time pages: the page rendered for third-post was kept and reused.

AdvancedWhy params needs <Suspense> with Cache Components

With cacheComponents: true, the build tries to prerender every route into a static shell. params without generateStaticParams is unknown at build time, so reading it is runtime data, like cookies(). The rule is the same for all runtime data: read it inside a <Suspense> boundary (so the shell can be built around it) or the build fails. Here's the docs page with the boundary removed, built in a copy of the app:

app/docs/[...slug]/page.tsx (a broken variant, not on this page's app)
export default async function DocsPage({ params }: PageProps<"/docs/[...slug]">) {
  const { slug } = await params;
  return (
    <main>
      <h1>Docs</h1>
      <p>{slug.join(" / ")}</p>
    </main>
  );
}
Output
Error: Route "/docs/[...slug]": Next.js encountered uncached or runtime data during prerendering.

`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.

Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route
What’s happening
  1. The page awaits params before rendering anything, so there is no part of the page that can be rendered without a real URL.
  2. At build time there is no URL (no generateStaticParams), so the prerender of /docs/[...slug] hits runtime data with no <Suspense> above it, and the build stops with the error above.
  3. Fix 1 (what the app on this page does): pass the params Promise down without awaiting it to a child inside <Suspense>. The <h1> and the fallback become the static shell; the child awaits and streams in.
  4. Fix 2: add generateStaticParams so the build has sample values (the blog page). Fix 3: export const instant = false accepts a route that blocks until the server has rendered it.
  5. Without Cache Components (Next.js 15 apps, or 16 with the flag off) there is no such error: reading params simply makes the route dynamic (ƒ) and it renders per request.
AdvancedgenerateStaticParams in one paragraph

generateStaticParams runs at build time and returns an array of params objects; the build prerenders one page per entry, exactly as if you had made a folder for each. For nested dynamic segments return all of them ({ category: "shoes", id: "1" }), and for catch-alls return arrays ({ slug: ["a", "b"] }). fetch calls in it are deduplicated with the same calls made by the pages. What happens to values you didn't return (dynamicParams), revalidation and fallbacks are covered in chapter 6, generateStaticParams and dynamicParams. It replaced getStaticPaths from the Pages Router (chapter 13).

AdvancedTyping and validating params
  • Use the generated helpers: PageProps<"/blog/[slug]"> and LayoutProps<"/shop/[category]"> (global, created by next dev, next build or next typegen). Values are typed as string, string[] or string[] | undefined for an optional catch-all, because users can type anything into the address bar.
  • Narrow with a runtime check, not a cast. If only some values are valid (a [locale] of "en" | "fr"), check and call notFound(), which throws and narrows the type after it: if (!isLocale(locale)) notFound();.
  • In a Client Component page, params is still a Promise; unwrap it with React's use(params). Anywhere else on the client, useParams() returns the current values directly.
  • params is the path. Query strings are searchParams, which only page.tsx receives (also a Promise, also runtime data).
Which page renders?

The catalog app above has app/blog/[slug]/page.tsx and app/blog/archive/page.tsx side by side. What do /blog/archive, /blog/Archive and /blog/archive/2024 render?

Show answer

/blog/archive renders the archive page, /blog/Archive renders the [slug] page with slug = "Archive", and /blog/archive/2024 is a 404.

What’s happening
  1. Matching goes segment by segment. For /blog/archive, the archive folder matches the literal segment exactly, and a static match always wins over [slug] at the same level. That's what the curl output showed: "Blog archive".
  2. Static segments match exactly and case-sensitively, so Archive doesn't match the archive folder; it falls through to the dynamic [slug], which accepts any single segment. slug is "Archive".
  3. /blog/archive/2024 has two segments after /blog. archive has no child folder 2024 or [something], and [slug] takes exactly one segment, so nothing matches and the root not-found renders with a 404.
  4. All three were checked against the running app: 200 "Blog archive", 200 "Post: Archive", 404 "This page could not be found."
  5. To match any depth you need a catch-all, [...slug]; to also match /blog itself, [[...slug]]. An optional catch-all can't sit next to a page.tsx in its parent, since both would claim the bare URL; adding app/shop/page.tsx to this app fails the build with You cannot define a route with the same specificity as an optional catch-all route ("/shop" and "/shop/[[...filters]]").

#Route groups and private folders

Added

File-system routing has one awkward side: the folder tree is the URL, so organising code changes URLs. Two folder-name conventions break that link. A folder in parentheses, (shop), is a route group: it exists for organisation and layouts, and is left out of the URL. A folder starting with an underscore, _components, is a private folder: the router ignores it and everything inside it, so it can never become a route.

The mental model: parentheses are invisible in the URL, underscores are invisible to the router. app/(shop)/cart/page.tsx serves /cart; app/(shop)/_components/cart-badge.tsx serves nothing. The reason route groups matter more than they look is layouts: a layout applies to every page below its folder, so a group is how you give some sibling URLs a shared layout (/cart and /account get the shop header) while others (/about, /checkout) don't, without inventing a /shop/ prefix. Groups are also how an app gets several root layouts.

app/(shop)/layout.tsx
import { CartBadge } from "./_components/cart-badge";

export default function ShopLayout({ children }: { children: React.ReactNode }) {
  return (
    <div id="shop-shell">
      <header>
        Shop section · <CartBadge />
      </header>
      {children}
    </div>
  );
}
What’s happening
  1. The folder (shop) is a route group, so this layout wraps the pages under it ((shop)/cart and (shop)/account) but adds nothing to their URLs: they are /cart and /account, not /(shop)/cart. The build table below lists exactly that.
  2. CartBadge is imported from ./_components/cart-badge. The underscore makes _components a private folder: it's ordinary code next to the layout that uses it, and the router doesn't even look inside it.
  3. The sibling group (marketing) has its own layout wrapping / and /about, and app/checkout/page.tsx sits outside both groups, so it gets only the root layout. Three different shells at the same URL depth, with no URL prefix.
  4. In the browser, clicking from /cart to /account kept "2 in cart" (both pages share this layout); clicking to /about replaced the shop header with the marketing one, and to /checkout showed neither. All of them were soft navigations: the root layout is shared by everything, so no document was ever reloaded.
  5. app/_drafts/page.tsx has a real page.tsx, yet /_drafts returns 404: the private folder hides it from routing entirely.
AdvancedRoute groups app: other layouts and pages

The rest of the app

The root layout links to every URL; the marketing group has its own small layout; checkout and _drafts sit outside both groups.

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="/about">About</Link> <Link href="/cart">Cart</Link>{" "}
          <Link href="/account">Account</Link> <Link href="/checkout">Checkout</Link>
        </nav>
        {children}
      </body>
    </html>
  );
}
app/(shop)/_components/cart-badge.tsx
"use client";

import { useState } from "react";

export function CartBadge() {
  const [count, setCount] = useState(0);
  return <button id="badge" onClick={() => setCount(count + 1)}>{count} in cart</button>;
}
app/(shop)/cart/page.tsx
export default function CartPage() {
  return <h1>Cart</h1>;
}
app/(shop)/account/page.tsx
export default function AccountPage() {
  return <h1>Account</h1>;
}
app/(marketing)/layout.tsx
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
  return (
    <div id="marketing-shell">
      <header>Marketing section</header>
      {children}
    </div>
  );
}
app/(marketing)/page.tsx
export default function HomePage() {
  return <h1>Home</h1>;
}
app/(marketing)/about/page.tsx
export default function AboutPage() {
  return <h1>About</h1>;
}
app/checkout/page.tsx
export default function CheckoutPage() {
  return <h1>Checkout</h1>;
}
app/_drafts/page.tsx
export default function DraftPage() {
  return <h1>This is never routable</h1>;
}
AdvancedRoute groups: route table and browser run

What the build and the browser showed

No group names and no _drafts in the route table:

next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├ ○ /account
├ ○ /cart
└ ○ /checkout


○  (Static)  prerendered as static content

Headless Chrome against next start, after clicking the badge twice on /cart (visible headers and <h1> only), then curl for the two URLs that shouldn't exist:

Output
/cart       headers: nav, "Shop section · 2 in cart"   h1: Cart
→ /account  headers: nav, "Shop section · 2 in cart"   h1: Account    documents requested: none
→ /about    headers: nav, "Marketing section"          h1: About      documents requested: none
→ /checkout headers: nav                               h1: Checkout   documents requested: none
→ /cart     headers: nav, "Shop section · 2 in cart"   h1: Cart       documents requested: none

curl -s -o /dev/null -w '%{http_code}' http://localhost:4502/_drafts       → 404
curl -s -o /dev/null -w '%{http_code}' 'http://localhost:4502/(shop)/cart' → 404

The last browser line shows Cache Components again: the shop layout was hidden, not destroyed, while you were on /about and /checkout, so its badge came back at 2. In an app without Cache Components it would have been unmounted when you left the group.

AdvancedMultiple root layouts

Delete app/layout.tsx and give each top-level group its own layout with <html> and <body>, and each group gets a root layout of its own: completely different documents (fonts, global CSS, analytics, <html lang>) for, say, a marketing site and a logged-in app in one deployment. The catch is navigation between them. A soft navigation can only swap things inside a shared layout, and two root layouts share nothing, not even <html>, so crossing from one root layout to another is a full page load.

The checker builds every app with a root app/layout.tsx, so this variant was built by hand from a copy of the app above: app/layout.tsx, checkout and _drafts removed, and the two group layouts below put in (the pages are the same):

app/(shop)/layout.tsx (two-root-layouts variant)
import Link from "next/link";
import "../globals.css";
import { CartBadge } from "./_components/cart-badge";

export default function ShopRootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className="shop">
        <nav>
          Shop · <CartBadge /> · <Link href="/cart">Cart</Link> <Link href="/account">Account</Link>{" "}
          <Link href="/about">About (marketing)</Link>
        </nav>
        {children}
      </body>
    </html>
  );
}
app/(marketing)/layout.tsx (two-root-layouts variant)
import Link from "next/link";
import "../globals.css";

export default function MarketingRootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className="marketing">
        <nav>
          Marketing · <Link href="/">Home</Link> <Link href="/about">About</Link>{" "}
          <Link href="/cart">Cart (shop)</Link>
        </nav>
        {children}
      </body>
    </html>
  );
}
Output
/cart (badge clicked twice)   body.shop        "Shop · 2 in cart"
→ /account                    same document    "Shop · 2 in cart"   documents requested: none
→ /about (marketing)          NEW document     body.marketing       documents requested: /about
→ /                           (soft)           body.marketing       documents requested: none
→ /cart (shop)                NEW document     "Shop · 0 in cart"   documents requested: /cart
What’s happening
  1. With no app/layout.tsx, the layouts in (shop) and (marketing) are both top-level, so both are root layouts and both must render <html> and <body>. The variant's build still listed /, /about, /account and /cart as ○ static.
  2. /cart → /account stays inside the shop root layout: a normal soft navigation, the badge keeps 2.
  3. /account → /about crosses into the marketing root layout. The <Link> still intercepted the click, but the router saw there was no shared layout to render into and fell back to a full document load: the network log has a Document request for /about, and the window marker set on the first page was gone.
  4. Inside marketing, /about → / is soft again (no document request). Going back to /cart is another full load, and the badge is back to 0: a new document means new React state, Cache Components or not.
  5. So multiple root layouts are right when the sections really are separate apps that users rarely cross between; if users go back and forth, the reloads are visible and slow.

Two rules come with multiple root layouts. The home page / must live inside one of the groups (app/(marketing)/page.tsx here), because there's no top-level layout to wrap app/page.tsx. And there is no single layout to compose an app-wide 404 from, which is why global-not-found.tsx exists (see not-found, redirects and status codes).

AdvancedMore uses for route groups
  • Opting some routes into a layout. Move account and cart into (shop) and they share its layout; checkout stays outside with only the root layout (that's the app above).
  • A loading.tsx for one page only. loading.tsx covers its folder and everything below. To give /dashboard a skeleton but not /dashboard/settings, move the dashboard page and its loading.tsx into app/dashboard/(overview)/; the URL doesn't change.
  • Organising by team or feature. (admin), (marketing), (docs) folders for code ownership, without URL changes.
AdvancedPrivate folders in detail

_folder opts the folder and all its subfolders out of routing, even if they contain page.tsx or route.ts. Files in app/ are already safe to colocate (only special file names route), so private folders aren't required; they are useful to separate UI and helpers from routing files, to keep a consistent _components/_lib convention, and to avoid clashing with a special file name Next.js adds in the future. If you really need a URL segment that starts with an underscore, name the folder %5Fname (the URL-encoded underscore). Code doesn't have to live in app/ at all: many projects keep components/ and lib/ at src/ level and use app/ only for routes.

#Loading UI and streaming with loading.js

Added

A page that waits on slow data has a problem: the server can't send the page until it has the data, so the user stares at the old page (on a click) or a blank tab (on a visit). loading.tsx fixes that with one file. Put it in a route folder and Next.js wraps that folder's page.tsx (and everything below it) in a React <Suspense> boundary whose fallback is your loading component. The server sends everything outside the boundary straight away, with the fallback in place of the page, keeps the response open, and streams the real page in when it's ready, replacing the fallback.

Think of it as a restaurant that brings the table setting and bread first: you're seated and see something immediately (layout, nav, skeleton), and the main course arrives on the same visit when the kitchen is done. Nothing is reloaded and you don't have to ask again. On a client-side navigation it's even faster: the fallback is part of what <Link> prefetched, so it appears the instant you click, before the server has done any work for the page.

Two consequences follow from where the boundary sits. It's per segment: app/dashboard/loading.tsx covers /dashboard and its children, and a nested loading.tsx adds a nested boundary. And it sits inside the segment's layout, so the layout (and the nav in it) stays visible and interactive while the page loads, but a slow layout is not covered by its own loading.tsx.

Direct visit: one streamed HTML response GET /dashboard response stays open while getRevenue() runs ~50 ms: 200, layout + "Loading dashboard…" ~2,050 ms: revenue + swap Client navigation from / (soft) shell prefetched click RSC request: server renders the page +30 ms: skeleton already showing ~2 s: content replaces it 0 0.5 s 1 s 1.5 s 2 s
The same loading.tsx on a direct visit (top) and a client navigation (bottom), with the timings measured below.
app/dashboard/loading.tsx
export default function DashboardLoading() {
  return <p id="skeleton">Loading dashboard…</p>;
}
What’s happening
  1. This file is the whole loading UI for /dashboard: any component, here one <p>. Because it's in app/dashboard/, Next.js renders <Suspense fallback={<DashboardLoading />}> around page.tsx, inside DashboardLayout (both files are below).
  2. The page calls await connection(), which says "this needs a real request", then waits 2 seconds in getRevenue(). With Cache Components that makes the page a dynamic hole, and loading.tsx is the boundary that lets the build make a static shell around it, which is why the route is ◐ in the build output below. Without loading.tsx (or a <Suspense> of your own), the build fails with the "uncached or runtime data during prerendering" error shown in Dynamic routes.
  3. A direct request gets status 200 and the first chunk of HTML after about 50 ms, already containing the dashboard nav (#dash-nav) and the skeleton. The response then stays open.
  4. About 2,000 ms later (the setTimeout), the server sends the rendered <h1 id="revenue"> inside a hidden <div> plus a tiny inline script that moves it into place and removes the fallback, and closes the document with </html>.
  5. On a client navigation from /, the shell (layout + skeleton) had been prefetched, so 30 ms after the click the skeleton was on screen; the revenue appeared between 1.5 s and 2.6 s after the click, when the RSC response for the page had streamed in.
AdvancedLoading app: chunk timings and navigation

What the build, curl and the browser showed

next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ◐ /dashboard
└ ○ /dashboard/settings


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

The response for a direct visit, chunk by chunk, measured with a small Node script that fetches http://localhost:4502/dashboard and logs when each chunk of the body arrives and which markers it contains:

Output
   49ms  HTTP 200  content-type: text/html; charset=utf-8
         cache-control: private, no-cache, no-store, max-age=0, must-revalidate   x-nextjs-postponed: 1
   50ms  chunk 1     1886 bytes  contains: id="skeleton", id="dash-nav"
   51ms  chunks 2–10 (scripts, the RSC payload of the shell)
 2052ms  chunk 11     128 bytes
 2052ms  chunk 12      73 bytes
 2052ms  chunk 13    1161 bytes  contains: id="revenue"
 2052ms  chunk 14      14 bytes  contains: </html>
2053ms  end of response, 14336 bytes

The two pieces of HTML that do the swap (excerpts of the same response). In the first chunk, the fallback sits in a marked Suspense slot; in the last chunks, the real content arrives hidden, to be moved into that slot by the inline script that follows it:

Output
…<aside id="dash-nav">…</aside><!--$?--><template id="B:0"></template><p id="skeleton">Loading dashboard…</p><!--/$-->…
…<div hidden id="S:1"><h1 id="revenue">Revenue: $<!-- -->48,200</h1></div><script>…</script>…</body></html>

The client navigation in headless Chrome: the prefetches made after / loaded, then what was visible at fixed times after clicking "Open the dashboard", the requests the click made, and finally Overview → Settings → Overview inside the dashboard:

Output
prefetch  GET /dashboard?_rsc=…  next-router-prefetch: 1  next-router-segment-prefetch: /_tree
prefetch  GET /dashboard?_rsc=…  next-router-prefetch: 1  next-router-segment-prefetch: /dashboard/__PAGE__

click +30ms    /dashboard  visible: "Loading dashboard…"
click +500ms   /dashboard  visible: "Loading dashboard…"
click +1500ms  /dashboard  visible: "Loading dashboard…"
click +2600ms  /dashboard  visible: "Revenue: $48,200"
request   GET /dashboard?_rsc=…  rsc: 1  next-router-state-tree: […]   (no prefetch header: the real render)

Settings           /dashboard/settings  visible: "Settings"   (static, prefetched: instant)
Overview +30ms     /dashboard  visible: "Loading dashboard…"
Overview +1000ms   /dashboard  visible: "Loading dashboard…"
Overview +2600ms   /dashboard  visible: "Revenue: $48,200"
What’s happening
  1. Before any click, the home page's <Link> prefetched /dashboard in two small requests: the route tree (/_tree) and the page segment's shell (/dashboard/__PAGE__), which for this page is the loading fallback. Both carry next-router-prefetch: 1.
  2. On click, the router could render immediately from the prefetch: the URL changed and the skeleton was visible at +30 ms, inside the dashboard layout.
  3. In parallel it sent the real navigation request: the same ?_rsc URL, without the prefetch header but with next-router-state-tree, which tells the server which segments the browser already has so it only renders what's missing (the page).
  4. The server streamed that RSC response; the revenue replaced the skeleton somewhere between the +1500 ms and +2600 ms samples, matching the 2-second setTimeout.
  5. Coming back to Overview from Settings showed the skeleton again and waited another 2 seconds: the result of a dynamic page isn't kept in the client cache between navigations (staleTimes.dynamic is 0 since Next.js 15), so each visit renders it again behind the same instant fallback.
AdvancedWhat loading.tsx doesn't do
  • It doesn't cover its own layout. loading.tsx renders inside layout.tsx of the same folder. If the layout awaits something slow, the fallback can't show until the layout is done. With Cache Components, uncached or runtime data in a layout must be wrapped in its own <Suspense> (the build tells you); without Cache Components the navigation simply blocks. Keep slow fetches in pages, or wrap the slow part of the layout in <Suspense>.
  • It isn't granular. One loading.tsx replaces the whole page with one fallback. To show the page's static parts and stream several slow widgets independently, put <Suspense> boundaries around each widget in the page instead (chapter 4, Streaming with Suspense); loading.tsx is just the convenient page-level case.
  • It doesn't change the status code. A streamed response has already sent 200 with the first chunk. If the page later calls notFound() or redirect(), Next.js can't change the status any more (see the next topic).
  • It doesn't re-show on every navigation inside a layout unless the boundary is re-created: Suspense shows a fallback when it first mounts, and a layout's boundaries stay mounted. A template.tsx re-creates them, see Templates vs layouts. (The page-level boundary of loading.tsx is re-created when you move to a different page, which is why it showed again above.)
AdvancedStreaming in production
  • SEO. The streamed HTML is complete by the end of the response, so crawlers that read the whole document see everything. For bots that only read static HTML (the docs name Twitterbot), Next.js resolves generateMetadata before streaming so <head> is complete; otherwise metadata can stream too.
  • Proxies and CDNs can buffer. Streaming only helps if every hop passes chunks through. An nginx in front of next start, for example, buffers by default; the self-hosting guide covers turning that off. Some browsers also buffer until the first 1,024 bytes, which only matters for tiny demo pages.
  • Slow networks. If the prefetch hasn't arrived when the user clicks, even the fallback has to wait for the server. useLinkStatus() (see Linking and navigating) gives the link itself a pending state for that case.
Does the skeleton show the second time?

In the app above you go / → /dashboard (skeleton, then revenue) → /dashboard/settings → back to /dashboard with the Overview link. Do you see the skeleton again, the old revenue immediately, or a frozen Settings page for two seconds?

Show answer

The skeleton again, then the revenue after about 2 seconds. That's what the browser run recorded.

What’s happening
  1. The click on Overview is a soft navigation to /dashboard. The dashboard layout is shared, so it stays as it is; only the page segment below it is needed.
  2. The router looks for that page in its client cache. Its shell (the fallback) is there from the prefetch, but its dynamic content isn't reusable: since Next.js 15, page segments from dynamic renders aren't reused across navigations (staleTimes.dynamic defaults to 0).
  3. So the router renders the cached shell immediately, which is the loading fallback, and sends a navigation request for the page. Nothing is frozen: the URL and the UI change at once.
  4. The server renders the page again (another getRevenue(), another 2 seconds) and streams it in, replacing the fallback.
  5. In Next.js 14 the same page would have been reused for 30 seconds from the client cache, so you'd see the old revenue immediately, possibly stale. That default is one of the 15 changes interviewers ask about.

#not-found, redirects and status codes

Added

Two things go wrong with URLs: the thing doesn't exist, or it lives somewhere else. Next.js gives each a function you call from server code, notFound() and redirect() (with permanentRedirect()), a file for the UI (not-found.tsx), and config for redirects you know in advance (redirects in next.config.ts). The functions work by throwing: rendering stops at the call, and Next.js catches the throw and renders the nearest not-found.tsx or performs the redirect.

The part that trips people up is the HTTP status. Status codes travel in the response headers, and headers go out with the first byte of the body. If notFound() runs before anything has been sent, the response is a real 404. If the page has already started streaming (a shell or a Suspense fallback is on its way), the status is already 200 and can't be taken back; Next.js still renders the not-found UI and adds <meta name="robots" content="noindex"> so search engines don't index it, but tools that look at status codes see a 200. Same for redirects: before streaming you get a 307/308 with a Location header; after, you get a <meta http-equiv="refresh"> tag in the HTML.

So the mental model is a letter whose envelope is already posted: the status is written on the envelope, and once the envelope is in the post you can only add notes to the letter inside. Decide "missing" or "moved" as early as possible (in next.config.ts, in proxy.ts, or before the first Suspense boundary) when the status code matters.

app/posts/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";

export function generateStaticParams() {
  return [{ slug: "hello" }, { slug: "routing" }];
}

export default async function PostPage({ params }: PageProps<"/posts/[slug]">) {
  const { slug } = await params;
  const post = getPost(slug);
  if (!post) notFound();
  return <h1>{post.title}</h1>;
}
What’s happening
  1. generateStaticParams lists hello and routing, so those two pages are prerendered at build time: ○ /posts/hello and ○ /posts/routing in the build output below.
  2. For any slug, the page awaits params, looks the post up in lib/posts.ts and, if it isn't there, calls notFound(). That throws (its return type is never, so TypeScript knows post is defined on the next line); rendering of this segment stops and the nearest not-found.tsx, the one in this folder, renders instead: "No such post".
  3. /posts/hello is a cache HIT with 200. /posts/missing is not in generateStaticParams, so its first request is served from the route's ◐ shell and resumed on the server: the shell has already gone out with 200 by the time notFound() runs. The observed response was 200 with noindex.
  4. Next.js stores the not-found result, and the second request for /posts/missing came back as a cached 404 Not Found (x-nextjs-cache: HIT). The browser shows "No such post" both times.
  5. If you need the first response to be a 404 too (analytics, strict crawlers, an API-like contract), check the slug before rendering, in proxy.ts, and rewrite to a not-found route or return a 404 there; the bundled loading.js docs recommend exactly that and warn to keep the check fast.
AdvancedStatus app: products, account, config

The rest of the app

/products/[id] checks the id inside a Suspense boundary, /account redirects from inside one, /login is the redirect target, app/not-found.tsx is the app-wide 404 UI, and next.config.ts keeps the template's two flags and adds two redirects.

app/posts/[slug]/not-found.tsx
export default function PostNotFound() {
  return <h1 id="post-404">No such post</h1>;
}
lib/posts.ts
const posts: Record<string, { title: string }> = {
  hello: { title: "Hello, world" },
  routing: { title: "Routing in the App Router" },
};

export function getPost(slug: string) {
  return posts[slug];
}
app/products/[id]/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";

export default function ProductPage({ params }: PageProps<"/products/[id]">) {
  return (
    <main>
      <h1>Product</h1>
      <Suspense fallback={<p>Loading product…</p>}>
        <ProductDetails params={params} />
      </Suspense>
    </main>
  );
}

async function ProductDetails({ params }: Pick<PageProps<"/products/[id]">, "params">) {
  const { id } = await params;
  if (id !== "1") notFound();
  return <p id="details">Product 1: a blue mug</p>;
}
app/account/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

export default function AccountPage() {
  return (
    <main>
      <h1>Account</h1>
      <Suspense fallback={<p>Checking your session…</p>}>
        <AccountDetails />
      </Suspense>
    </main>
  );
}

async function AccountDetails() {
  const session = (await cookies()).get("session")?.value;
  if (!session) redirect("/login");
  return <p id="account">Signed in as {session}</p>;
}
app/login/page.tsx
export default function LoginPage() {
  return <h1>Log in</h1>;
}
app/not-found.tsx
import Link from "next/link";

export default function NotFound() {
  return (
    <main>
      <h1 id="root-404">Page not found</h1>
      <Link href="/">Go home</Link>
    </main>
  );
}
next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  async redirects() {
    return [
      { source: "/blog/:slug", destination: "/posts/:slug", permanent: true },
      { source: "/sale", destination: "/products/1", permanent: false },
    ];
  },
};

export default nextConfig;
AdvancedStatus app: status codes per URL

What the build, curl and the browser showed

/products/[id] and /account are ◐: a static shell (the <h1> and the fallback) with a hole. The config redirects don't appear in the table at all; they aren't routes.

next build
Route (app)
┌ ○ /_not-found
├ ◐ /account
├ ○ /login
├   /posts/[slug]
│ ├ ◐ /posts/[slug]
│ ├ ○ /posts/hello
│ └ ○ /posts/routing
└   /products/[id]
  └ ◐ /products/[id]


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

Each URL with curl -s -D - http://localhost:4502<path> (status, relevant headers, and the <h1>/<p>/robots and redirect tags found in the HTML), then what headless Chrome ended up showing:

Output
/posts/hello            200  x-nextjs-cache: HIT                    <h1>Hello, world
/posts/missing  (1st)   200  x-nextjs-postponed: 1, no-store        <meta name="robots" content="noindex">
/posts/missing  (2nd)   404  x-nextjs-cache: HIT                    <meta name="robots" content="noindex">
/products/1             200  x-nextjs-postponed: 1                  Product · Loading product… · Product 1: a blue mug
/products/2             200  x-nextjs-postponed: 1                  Product · Loading product… · <meta name="robots" content="noindex">
/account   (no cookie)  200  x-nextjs-postponed: 1                  Account · Checking your session…
                                                                    <meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/login">
/account   (session=rohit)  200                                     Account · Checking your session… · Signed in as rohit
/nowhere                404                                         <meta name="robots" content="noindex"> · Page not found
/blog/hello             308  location: /posts/hello                 (from next.config.ts)
/sale                   307  location: /products/1                  (from next.config.ts)

Chrome  /posts/missing4  visible: "No such post"     robots: noindex
Chrome  /products/2      visible: "Page not found"   robots: noindex
Chrome  /account         ended on /login, visible: "Log in"
Chrome  /nowhere         visible: "Page not found"   robots: noindex
What’s happening
  1. /products/2: the page's shell (<h1>Product</h1> and "Loading product…") is static and was sent first, with 200. Only then did ProductDetails await params and call notFound(). The status couldn't change, so Next.js added noindex and rendered the not-found UI into the page. There's no not-found.tsx in products/[id]/, so the nearest boundary is the root app/not-found.tsx, which replaced everything below the root layout: Chrome showed only "Page not found".
  2. /account without a session cookie: same story for redirect(). The shell went out with 200, the redirect happened inside Suspense, so instead of a 307 the HTML got a <meta http-equiv="refresh"> tag (and the client router followed it): the browser ended on /login. With the cookie, the same page streamed "Signed in as rohit".
  3. /nowhere matches no route at all, so Next.js renders app/not-found.tsx with a real 404 (and noindex). The root not-found.tsx is what handles every unmatched URL.
  4. /blog/hello and /sale never reach a page: redirects() in next.config.ts is checked before the file system, and permanent: true gives 308, permanent: false gives 307.
  5. The general rule visible in all of these: what's decided before the first byte gets a real status code; what's decided inside a Suspense boundary gets the right UI but a 200.
AdvancednotFound() and not-found.tsx in detail
  • notFound() throws an error with the digest NEXT_HTTP_ERROR_FALLBACK;404. Call it in the render path (a component, or a function a component awaits). Like redirect(), it's typed never, so no return is needed, and a try/catch around it swallows it; if you must catch errors near the call, re-throw Next.js's own errors first with unstable_rethrow(error).
  • The nearest not-found.tsx above the segment that threw renders in its place (not-found.tsx sits inside the segment's layout, just above the page, in the component hierarchy). Put one next to a dynamic route to say "no such post" rather than a generic 404.
  • app/not-found.tsx (the root one) also handles every URL that matches no route. Without it you get Next.js's built-in 404 page.
  • not-found.tsx takes no props. It's a Server Component by default and can be async and fetch data; for usePathname() you need a Client Component.
  • global-not-found.tsx (experimental since 15.4, enable experimental.globalNotFound) is a 404 page that bypasses layouts and must render its own <html> and <body>. It exists for apps with multiple root layouts or a root layout under a dynamic segment like app/[country]/layout.tsx, where there's no single layout to render a 404 inside.
  • notFound() also works in Route Handlers, where it returns a plain 404 to the caller. Its relatives forbidden() (403) and unauthorized() (401) behave the same way with forbidden.tsx and unauthorized.tsx; they're in chapter 9, forbidden(), unauthorized() and error statuses.
AdvancedThe four places redirects happen

Where redirects can happen, in the order they run

WhereWhen it runsStatusUse it for
redirects() in next.config.tsbefore proxy and before the file system308 (permanent: true) or 307URL changes known at build time: renamed sections, old slugs
NextResponse.redirect() in proxy.tsafter config redirects, before renderingany you choosedecisions per request: auth gates, locale, A/B, thousands of redirects from a store
redirect() / permanentRedirect() while renderingduring the render of a page, layout, Route Handler or Server Action307/308 before streaming; <meta> refresh after; 303 from a Server Action without JavaScript"this data says go elsewhere": missing team, not logged in
router.push() / router.replace()in the browser, in an event handlernone (client navigation)after a client-side event

A few details about next.config.ts redirects that interviews and real configs use: source supports named params (/blog/:slug), wildcards (/blog/:slug*) and regex (/post/:id(\\d+)); has and missing match on headers, cookies, query values or host; the basePath and locale options control prefixing; and they're static, so changing one needs a rebuild (the bundled guide also notes Vercel's limit of 1,024 config redirects, beyond which you move them to proxy and a fast lookup). Next.js also redirects trailing slashes for you: with the catalog app from Dynamic routes running, curl on /docs/a/b/ returned 308 to /docs/a/b (the default trailingSlash: false).

Redirects from Server Actions after a mutation (with revalidatePath/updateTag first) are in chapter 7, Revalidating and redirecting after a mutation; proxy redirects are in chapter 8, Redirects, rewrites and headers in proxy.

Predict the status codes

Using the app above: what status does each request get? (a) /products/1, (b) /products/9, (c) /blog/routing, (d) /posts/routing, (e) /posts/nope requested for the very first time.

Show answer

(a) 200, (b) 200 with noindex and the not-found UI, (c) 308 to /posts/routing, (d) 200, (e) 200 with noindex (then 404 on later requests).

What’s happening
  1. (a) /products/1: the shell streams with 200, then ProductDetails renders the mug. Observed.
  2. (b) /products/9: same shell and 200, then notFound() inside Suspense. The status is fixed, so you get the root not-found UI and noindex (observed with /products/2).
  3. (c) /blog/routing: the next.config.ts rule /blog/:slug → /posts/:slug, permanent: true, runs before any route is matched: 308 with location: /posts/routing (observed with /blog/hello).
  4. (d) /posts/routing is in generateStaticParams, so it's a prerendered static page: 200 from the cache.
  5. (e) /posts/nope isn't prerendered; the first request is served from the ◐ shell, which has already sent 200 when notFound() runs, so 200 + noindex; Next.js then keeps the not-found result and later requests get 404 (observed with /posts/missing).

#Parallel routes

Added

Normally a layout has one hole, children, and one page fills it. Parallel routes give a layout several named holes, called slots, each filled by its own page tree at the same time. A folder named with @, like @team or @analytics, defines a slot; the layout receives it as a prop with that name, next to children, and places it wherever it likes. Slots are not URL segments: app/dashboard/@team/settings/page.tsx serves /dashboard/settings, not /dashboard/@team/settings.

The mental model: one URL, several independent panes, like a TV showing a programme and a news ticker. Each slot matches the current URL against its own folder tree, has its own loading.tsx and error.tsx, streams on its own, and can even have its own sub-navigation. children is simply the implicit slot (app/dashboard/page.tsx is the same as app/dashboard/@children/page.tsx).

The question that makes parallel routes interesting is what a slot shows when the URL has no matching page in that slot. The answer depends on how you got there:

  • Soft navigation (a <Link> click): the router keeps showing whatever the slot showed before. It remembers each slot's active page, even though it doesn't match the URL.
  • Hard navigation (refresh, typed URL, shared link): there's no "before", so the slot renders its default.tsx. In Next.js 16 every slot needs one where some URL lacks a page; the build checks it.
At /dashboard/settings children @team @analytics Soft navigation Link from /dashboard page.tsx, kept "dashboard home" settings/page.tsx matches the URL page.tsx, kept "1,204 visits" Hard navigation refresh or direct visit default.tsx nothing to remember settings/page.tsx matches the URL default.tsx nothing to remember Blue: the slot has a page for this URL. Plain: kept from the previous URL. Violet: fallback.
Only @team has a settings page. What the other two slots show depends on how you arrived, as observed below.
app/dashboard/layout.tsx
import Link from "next/link";

export default function DashboardLayout({ children, team, analytics }: LayoutProps<"/dashboard">) {
  return (
    <div>
      <nav>
        <Link href="/dashboard">Dashboard</Link> · <Link href="/dashboard/settings">Settings</Link>
      </nav>
      <section id="children-slot">{children}</section>
      <section id="team-slot">{team}</section>
      <section id="analytics-slot">{analytics}</section>
    </div>
  );
}
What’s happening
  1. Because app/dashboard/ contains the folders @team and @analytics, this layout receives props named team and analytics in addition to children. LayoutProps<"/dashboard"> knows about them (it's generated from the folders), so they're typed without any extra work.
  2. On /dashboard, each slot matches its own page.tsx: children shows "dashboard home", team shows "4 members", analytics shows "1,204 visits today". The analytics page is slow (1.5 s) and has its own @analytics/loading.tsx, so it streams in after the other two (measured below at 29 ms vs 1,534 ms).
  3. Only @team has a settings/page.tsx. After clicking the Settings link (soft navigation), team switched to "settings form" while children and analytics kept their previous content, although the URL is now /dashboard/settings.
  4. After a reload of /dashboard/settings (hard navigation), the router has no previous state, so children rendered app/dashboard/default.tsx and analytics rendered @analytics/default.tsx; team still matched its settings page.
  5. Clicking back to Dashboard re-rendered the slot pages: analytics showed its own loading state ("analytics: loading…") at +300 ms and the data at +2.3 s, while the other slots were already there. Each slot is its own Suspense boundary.
AdvancedSlots app: pages and defaults per slot

The rest of the app

The pages and defaults for each slot. The analytics page awaits connection() and a 1.5-second timer, which makes it the only dynamic part of /dashboard.

app/dashboard/page.tsx
export default function DashboardHome() {
  return <p>children: dashboard home</p>;
}
app/dashboard/default.tsx
export default function DashboardDefault() {
  return <p>children: default.tsx</p>;
}
app/dashboard/@team/page.tsx
export default function TeamPage() {
  return <p>team: 4 members</p>;
}
app/dashboard/@team/settings/page.tsx
export default function TeamSettingsPage() {
  return <p>team: settings form</p>;
}
app/dashboard/@team/default.tsx
export default function TeamDefault() {
  return <p>team: default.tsx</p>;
}
app/dashboard/@analytics/page.tsx
import { connection } from "next/server";

export default async function AnalyticsPage() {
  await connection();
  await new Promise((resolve) => setTimeout(resolve, 1500)); // a slow analytics query
  return <p id="visits">analytics: 1,204 visits today</p>;
}
app/dashboard/@analytics/loading.tsx
export default function AnalyticsLoading() {
  return <p>analytics: loading…</p>;
}
app/dashboard/@analytics/default.tsx
export default function AnalyticsDefault() {
  return <p>analytics: default.tsx</p>;
}
AdvancedSlots app: soft vs hard navigation run

What the build and the browser showed

/dashboard is ◐ because one slot (analytics) is dynamic; /dashboard/settings is fully static, since on a direct visit its analytics slot is the static default.tsx:

next build
Route (app)
┌ ○ /_not-found
├ ◐ /dashboard
└ ○ /dashboard/settings


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

Headless Chrome against next start (visible text of each slot), then the chunk timings of a direct request for /dashboard:

Output
load /dashboard            children=[dashboard home]  team=[4 members]       analytics=[1,204 visits today]
Link → /dashboard/settings children=[dashboard home]  team=[settings form]   analytics=[1,204 visits today]
reload /dashboard/settings children=[default.tsx]     team=[settings form]   analytics=[default.tsx]
Link → /dashboard  +300ms  children=[dashboard home]  team=[4 members]       analytics=[loading…]
Link → /dashboard  +2.3s   children=[dashboard home]  team=[4 members]       analytics=[1,204 visits today]

GET /dashboard     29ms  first chunk: "team: 4 members", "children: dashboard home", "analytics: loading…"
                 1534ms  chunk with id="visits", then </html>
AdvancedMissing default.tsx fails the build

Every slot needs a page or a default.tsx for every URL

The Next.js 16 upgrade guide says every slot now needs a default.tsx. What the 16.4.3 build actually enforces (it calls this strict route matching, on by default) is narrower: every URL one slot matches must have a page or a default.tsx in every sibling slot. A copy of the role-slots app below with no default.tsx at all still built, because its slots only have pages for /console. Removing @analytics/default.tsx from the app above:

Output
Error: Parallel route slots cannot render the same URLs
The following layouts have parallel route slots that cannot render the same URLs:
app/dashboard/layout.tsx
- /dashboard/settings is missing a matching page or default.tsx in @analytics

Every URL matched by one slot must have a matching page or default.tsx in every sibling slot.

Error: Unmatched app pages
The following page files do not match any complete route:
- app/dashboard/@team/settings/page.tsx

The build also printed a warning that this validation is new and can temporarily be relaxed with deprecated.looseRouteMatching: true. Before 16, a missing default.tsx built fine and the slot rendered a 404 on a hard navigation, which is why older tutorials say "add default.js or you'll get a 404 on refresh". To keep that old behaviour deliberately, make default.tsx call notFound(); to render nothing, return null.

AdvancedConditional slots are not access control

Conditional slots, and why they're not a security boundary

Because the layout receives slots as props, it can choose between them: show @admin to admins and @user to everyone else. The bundled docs warn that both slots render on the server regardless of which one the layout returns. That claim is easy to test:

app/console/layout.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

export default function ConsoleLayout({ admin, user }: LayoutProps<"/console">) {
  return (
    <Suspense fallback={<p>Loading console…</p>}>
      <ByRole admin={admin} user={user} />
    </Suspense>
  );
}

async function ByRole({ admin, user }: { admin: React.ReactNode; user: React.ReactNode }) {
  const role = (await cookies()).get("role")?.value;
  return role === "admin" ? admin : user;
}
app/console/page.tsx
export default function ConsolePage() {
  return null;
}
app/console/@admin/page.tsx
export default function AdminPanel() {
  return <p id="admin">Admin panel: payroll export (SECRET-PAYROLL-2026)</p>;
}
app/console/@admin/default.tsx
export default function AdminDefault() {
  return null;
}
app/console/@user/page.tsx
export default function UserPanel() {
  return <p id="user">Your orders: 3</p>;
}
app/console/@user/default.tsx
export default function UserDefault() {
  return null;
}
next build
Route (app)
┌ ○ /_not-found
└ ◐ /console


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

curl -s -b 'role=user' http://localhost:4502/console, then the same with role=admin, counting what's in the response body:

Output
role=user    visible <p>: "Your orders: 3"
             "SECRET-PAYROLL-2026" appears 2 times in the response
             e.g. …{"s":"__PAGE__","h":34,"d":{"r":["$","$1","c",{"children":[["$","p",null,{"id":"admin","children":"Admin panel: payroll export (SECRET-PAYROLL-2026)"}]…
role=admin   visible <p>: "Admin panel: payroll export (SECRET-PAYROLL-2026)"

curl -s -H 'rsc: 1' -b 'role=user' 'http://localhost:4520/console?_rsc'   (the client-navigation payload, from a re-run on port 4520)
             200 text/x-component, "SECRET-PAYROLL-2026" appears 2 times, in the same ["$","p",null,{"id":"admin",…}] element
What’s happening
  1. The console layout reads the role cookie inside <Suspense> (runtime data with Cache Components) and returns either the admin or the user slot.
  2. With role=user the browser shows only "Your orders: 3". The admin panel is never put in the DOM.
  3. But the response still carries the admin page's rendered output, not just a reference to it: the <p id="admin"> element with its full text, inside the RSC payload in the <script> tags of the HTML, and again in the text/x-component response a client navigation gets. Both slot pages are rendered for the route as their own segments, and the layout's choice only decides which one React places on screen. Anyone can read it with View Source or the network tab.
  4. That isn't only because this admin page is static and was rendered at build time. In a copy where @admin/page.tsx awaited connection() and logged (plus an @admin/loading.tsx for its Suspense boundary), every role=user request (document or navigation) logged server: rendering AdminPanel once and its response contained the admin text: the slot page runs per request for every user, as the bundled docs warn ("its output is included in the response sent to the browser").
  5. So a conditional slot is a UI choice, not access control. Check the role inside the admin slot's own data access (the Data Access Layer, chapter 4 Data security: the Data Access Layer), so the admin page renders nothing sensitive for a non-admin even when it runs.
AdvancedWhere parallel routes are used
  • Dashboards with independent panels that each load, fail and stream on their own (@analytics/loading.tsx, @analytics/error.tsx).
  • Tabs inside a slot. Give a slot its own layout.tsx with links (@analytics/page-views, @analytics/visitors), and that panel navigates without disturbing its siblings.
  • Modals that have their own URL, combined with intercepting routes: the next topic.
  • Reading a slot's state. useSelectedLayoutSegment("team") in a Client Component returns the active segment of a named slot ("settings" here), handy for highlighting tabs.
What does a refresh show?

In the dashboard app, you load /dashboard, click Settings, then press the browser's refresh button. What do the three slots show before and after the refresh?

Show answer

Before: dashboard home · settings form · 1,204 visits. After: children default · settings form · analytics default.

What’s happening
  1. Clicking Settings is a soft navigation. Only @team has a page for /dashboard/settings, so team switches to the settings form.
  2. children and analytics have no settings page. During a soft navigation the router keeps each slot's last active page, so they keep showing "dashboard home" and "1,204 visits", even though those pages belong to /dashboard.
  3. Refresh is a hard navigation: the server renders /dashboard/settings from scratch and has no memory of what the slots showed before.
  4. For each slot without a match it renders that slot's default.tsx: children: default.tsx and analytics: default.tsx. This is exactly the observed run, and why default.tsx usually renders null, a sensible placeholder, or notFound().

#Intercepting routes and modals

Added

Think of Instagram on the web: click a photo in the feed and it opens in a modal over the feed, but the address bar changes to the photo's URL. Copy that URL into a new tab and you get the photo's own full page, not a modal. Press back and the modal closes; press forward and it opens again. Intercepting routes are how the App Router does this: when you navigate inside the app to a URL, a route elsewhere in the tree can intercept it and render its own version in the current layout, while the URL still says /photos/2. Hard navigations to the same URL aren't intercepted and get the real page.

An intercepting folder is named after the route it intercepts, prefixed with a relative marker that works like ../ in import paths, but counted in route segments (slots and groups don't count):

MarkerIntercepts a route…Example
(.)at the same levelapp/@modal/(.)photos intercepts /photos/… (the @modal slot isn't a segment)
(..)one level upapp/feed/(..)photo intercepts /photo/… from inside /feed
(..)(..)two levels up
(...)from the root appapp/shop/cart/(...)login intercepts /login

On its own, interception would replace the page. For a modal you combine it with a parallel route: an @modal slot in the layout holds the intercepted page (rendered as a modal), while children keeps showing the gallery. The mental model is two doors to the same room: a side door (soft navigation from inside the app) that opens the room as an overlay, and the front door (a hard navigation) that takes you into the room itself. Both doors have the same address.

Click Photo 2 on / soft navigation RSC request, next-url: / (.)photos matches: intercept children: gallery (kept) @modal: (.)photos/[id] modal over the gallery Refresh /photos/2 hard navigation document request no interception children: photos/[id] @modal: default.tsx (null) the full photo page Same URL, two renders: the router decides by how you arrived.
The photo-modal pattern: an @modal slot plus an intercepting route.
app/@modal/(.)photos/[id]/page.tsx
import { Modal } from "@/app/_components/modal";
import { Photo } from "@/app/_components/photo";

export function generateStaticParams() {
  return [{ id: "1" }, { id: "2" }, { id: "3" }];
}

export default async function PhotoModal({ params }: PageProps<"/photos/[id]">) {
  const { id } = await params;
  return (
    <Modal>
      <Photo id={id} />
    </Modal>
  );
}
What’s happening
  1. The folder path is app/@modal/(.)photos/[id]/. @modal is a slot of the root layout, so it isn't a route segment; (.)photos therefore sits at the same level as the real app/photos/, and (.) intercepts /photos/[id]. The build lists it as its own route, /(.)photos/[id], next to /photos/[id].
  2. When you click "Photo 2" in the gallery, the router sends an RSC request for /photos/2 with next-url: / (the observed header): the server knows you're coming from /, sees that the @modal slot has an intercepting route for /photos/[id], and renders this page into the modal slot. children keeps the gallery, because the gallery's slot has nothing new to show.
  3. This page wraps the shared <Photo> in the client <Modal>, so the user sees the photo in a dialog over the gallery, with /photos/2 in the address bar and one new history entry (2 → 3 in the run below).
  4. Close calls router.back(): the URL returns to / and the router restores what each slot showed there: the gallery, and default.tsx (null) in @modal, so the modal disappears. Browser forward re-opened it, because forward is a soft navigation to /photos/2 from / again.
  5. Reloading on /photos/2 (or opening it in a new tab) is a hard navigation: nothing is intercepted, children renders app/photos/[id]/page.tsx ("Photo 2 (full page)") and the @modal slot renders default.tsx, so there is no modal and no gallery.
AdvancedPhoto app: modal on soft and hard navigation

What the build and the browser showed

next build
Route (app)
┌ ○ /
├ ○ /_not-found
├   /(.)photos/[id]
│ ├ ◐ /(.)photos/[id]
│ ├ ○ /(.)photos/1
│ ├ ○ /(.)photos/2
│ └ ○ /(.)photos/3
├ ○ /about
└   /photos/[id]
  ├ ◐ /photos/[id]
  ├ ○ /photos/1
  ├ ○ /photos/2
  └ ○ /photos/3


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

Headless Chrome against next start (which parts are visible, the visible <h1>, and whether it's still the same document):

Output
load /                    /           history 2  gallery ✓  modal ✗  full page ✗  h1: Gallery
click Photo 2             /photos/2   history 3  gallery ✓  modal ✓  full page ✗  h1: Gallery
   request: GET /photos/2?_rsc=…  next-url: /   (plus prefetches for the modal's /about link)
Close (router.back())     /           history 3  gallery ✓  modal ✗  full page ✗
browser forward           /photos/2   history 3  gallery ✓  modal ✓  full page ✗
reload                    /photos/2   NEW document  gallery ✗  modal ✗  full page ✓  h1: Photo 2 (full page)
browser back              /           gallery ✓  modal ✗
click Photo 3             /photos/3   gallery ✓  modal ✓
modal's Link to /about    /about      gallery ✗  modal ✗  h1: About the photographer

curl -s http://localhost:4502/photos/2   →   <h1>Photo 2 (full page)</h1> <figure class="photo">[ image 2 ]</figure>   (no modal)

That last browser line depends on app/@modal/[...catchAll]/page.tsx. The same app built without it:

Output
click Photo 3             /photos/3   gallery ✓  modal ✓
modal's Link to /about    /about      gallery ✗  modal ✓  h1: About the photographer
What’s happening
  1. The modal's "About the photographer" link is a soft navigation to /about. children has an about page, so it switches to it.
  2. The @modal slot has no page for /about. In a soft navigation a slot without a match keeps its previous content (Parallel routes), and the previous content was the open photo modal. So without the catch-all, the modal stayed open on top of the about page.
  3. @modal/[...catchAll]/page.tsx matches every URL with at least one segment and renders null, so the slot now has a match for /about (and anything else) and that match is "no modal". With it, the modal closed as expected.
  4. @modal/default.tsx covers the other cases: the bare / (a catch-all needs a segment) and any hard navigation. Both files are needed for a modal that behaves.
  5. router.back() is the right "close" for a modal opened from a link: it restores the previous URL and history entry. A <Link href="/"> close works too, but it pushes a new entry, so back would reopen the modal.
AdvancedWhen to use it
  • Photo or product quick-view from a grid, with a shareable URL (the example).
  • Login or sign-up modal from a nav bar, while /login still exists as a full page for direct visits and redirects (the bundled parallel-routes docs build exactly this).
  • Cart drawer or side panel that has a URL, so a refresh keeps the user on the cart.

Keep the modal's content in a shared component (<Photo> here) used by both the intercepted and the real page, so they can't drift apart. And keep the <Modal> wrapper a small Client Component taking children, so the content inside stays a Server Component.

Refresh, then back

You're on /, click Photo 2 (the modal opens), then press refresh, then press the browser's back button. What do you see after the refresh, and after back?

Show answer

After refresh: the full photo page, no gallery, no modal. After back: the gallery, no modal.

What’s happening
  1. The click is a soft navigation from /, intercepted by (.)photos/[id]: modal over the gallery, URL /photos/2, history now […, /, /photos/2].
  2. Refresh throws the document away and requests /photos/2 directly. The server has no "previous page" for this request, so nothing is intercepted: children renders app/photos/[id]/page.tsx, the @modal slot renders default.tsx (null). Observed: "Photo 2 (full page)", gallery and modal both absent.
  3. Back goes to the history entry for /. The app is running again after the reload, so the router does a soft navigation to /: children renders the gallery, and @modal has no match for / beyond default.tsx, so no modal. Observed: gallery visible, no modal, still the same document.
  4. The interception is a property of the navigation, not of the URL. That's why the same /photos/2 can be a modal, a full page, or a modal again after browser forward.

#Templates vs layouts

Added

A layout's persistence is usually what you want, and occasionally exactly what you don't: a feedback form that should be empty on every topic, an entrance animation that should play on every page, a useEffect that logs a page view each time, a Suspense fallback that should show on every navigation. template.tsx is the escape hatch. It's written exactly like a layout (it receives children) and sits between the layout and the page, but Next.js renders it with a key derived from the segment below it, so whenever that segment changes, React treats it as a different component: the template and everything inside it are unmounted and mounted fresh, state reset, DOM recreated, effects run again.

The mental model: a layout is a picture frame that stays on the wall; a template is the mount (passe-partout) inside the frame that is replaced with every new photo. Put things that should survive navigation in the layout and things that should start over in the template. The bundled docs give the rule precisely: a template remounts when its own segment's child (including dynamic params) changes; navigating deeper doesn't remount higher templates; search params never remount anything.

Next.js 16 with Cache Components adds a twist worth knowing for interviews. Routes you leave are kept in React <Activity> (see Pages and layouts), and a template instance belongs to its route. Going to a new topic gives a fresh template, but going back to a topic you visited recently can bring its old template back, state and all. The run below shows both, and the old behaviour without Cache Components.

app/feedback/template.tsx
import { Counter } from "./counter";
import { MountLog } from "./mount-log";

export default function FeedbackTemplate({ children }: { children: React.ReactNode }) {
  return (
    <div className="template">
      <Counter label="template" />
      <MountLog name="template" />
      {children}
    </div>
  );
}
What’s happening
  1. app/feedback/template.tsx wraps every page under /feedback, inside app/feedback/layout.tsx. Both render a <Counter> (a tiny useState button, below), and the page renders two more: a plain one and one wrapped in <ResetOnPush>.
  2. Next.js renders it roughly as <FeedbackLayout><FeedbackTemplate key="a">…</FeedbackTemplate></FeedbackLayout> on /feedback/a; the key comes from the [topic] value. The layout has no key that changes.
  3. Click every counter twice on /feedback/a (all show 2), then the Topic b link. The layout counter stays 2. The template key is now "b", a different element to React, so the template counter is 0, and MountLog reports template cleanup on /feedback/a; template effect ran on /feedback/b.
  4. With Cache Components, the Topic a link then brought back template: 2 and page: 2: the /feedback/a route had been hidden with <Activity>, not destroyed, and its template instance came back with it. Its effects still re-ran (Activity runs cleanups when hiding and effects when showing).
  5. The keyed counter was 0 after that same click: <ResetOnPush> keys its children on useRouter().bfcacheId, which changes on every push/replace navigation. After clicking it to 2, browser back and forward kept it at 2, because bfcacheId stays the same for back/forward.
  6. After visiting c, d and b, Topic a came back with everything at 0: three other routes had been visited since, Next.js keeps at most three, so the kept /feedback/a had been evicted and rendered fresh.
AdvancedFeedback app: layout, page, counters

The rest of the app

The layout with the topic links and its own counter, the page with a plain counter and a bfcacheId-keyed one, the counter itself, an effect logger, and the ResetOnPush wrapper.

app/feedback/layout.tsx
import Link from "next/link";
import { Counter } from "./counter";

export default function FeedbackLayout({ children }: LayoutProps<"/feedback">) {
  return (
    <section>
      <nav>
        {["a", "b", "c", "d"].map((t) => (
          <Link key={t} href={`/feedback/${t}`}>Topic {t}</Link>
        ))}
      </nav>
      <Counter label="layout" />
      {children}
    </section>
  );
}
app/feedback/[topic]/page.tsx
import { Counter } from "../counter";
import { ResetOnPush } from "../reset-on-push";

export function generateStaticParams() {
  return ["a", "b", "c", "d"].map((topic) => ({ topic }));
}

export default async function TopicPage({ params }: PageProps<"/feedback/[topic]">) {
  const { topic } = await params;
  return (
    <article>
      <h1>Topic {topic}</h1>
      <Counter label="page" />
      <ResetOnPush>
        <Counter label="keyed" />
      </ResetOnPush>
    </article>
  );
}
app/feedback/counter.tsx
"use client";

import { useState } from "react";

export function Counter({ label }: { label: string }) {
  const [count, setCount] = useState(0);
  return (
    <button className={`count-${label}`} onClick={() => setCount(count + 1)}>
      {label}: {count}
    </button>
  );
}
app/feedback/mount-log.tsx
"use client";

import { useEffect } from "react";
import { usePathname } from "next/navigation";

declare global {
  interface Window {
    mountLog?: string[];
  }
}

export function MountLog({ name }: { name: string }) {
  const pathname = usePathname();
  useEffect(() => {
    (window.mountLog ??= []).push(`${name} effect ran on ${pathname}`);
    return () => {
      window.mountLog?.push(`${name} cleanup on ${pathname}`);
    };
  }, [name, pathname]);
  return null;
}
app/feedback/reset-on-push.tsx
"use client";

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

export function ResetOnPush({ children }: { children: React.ReactNode }) {
  const { bfcacheId } = useRouter();
  return <Fragment key={bfcacheId}>{children}</Fragment>;
}
AdvancedFeedback app: counters across navigations

What the build and the browser showed

next build
Route (app)
┌ ○ /_not-found
└   /feedback/[topic]
  ├ ◐ /feedback/[topic]
  ├ ○ /feedback/a
  ├ ○ /feedback/b
  └ ◐ [+2 more paths]


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

([+2 more paths] is the build abbreviating /feedback/c and /feedback/d.) Headless Chrome against next start, counters as layout · template · page · keyed, with the effect log of MountLog after each step:

Output
load /feedback/a           0 · 0 · 0 · 0   template effect ran on /feedback/a
click each counter twice   2 · 2 · 2 · 2
Link → b                   2 · 0 · 0 · 0   template cleanup on /feedback/a; template effect ran on /feedback/b
Link → a                   2 · 2 · 2 · 0   template cleanup on /feedback/b; template effect ran on /feedback/a
click keyed twice          2 · 2 · 2 · 2
browser back  → b          2 · 0 · 0 · 0   template cleanup on /feedback/a; template effect ran on /feedback/b
browser forward → a        2 · 2 · 2 · 2   template cleanup on /feedback/b; template effect ran on /feedback/a
Link → c, Link → d, Link → b   (all 2 · 0 · 0 · 0)
Link → a                   2 · 0 · 0 · 0   template cleanup on /feedback/b; template effect ran on /feedback/a

The same seven files built without Cache Components (empty next.config.ts), same clicks. That build listed the four topics as ● (SSG) instead of ○, the pre-Cache-Components symbol for pages prerendered from generateStaticParams:

Output
load /feedback/a           0 · 0 · 0 · 0
click each counter twice   2 · 2 · 2 · 2
Link → b                   2 · 0 · 0 · 0
Link → a                   2 · 0 · 0 · 0
click keyed twice          2 · 0 · 0 · 2
browser back  → b          2 · 0 · 0 · 0
browser forward → a        2 · 0 · 0 · 0

Read the two runs together:

LayoutTemplatePageKeyed on bfcacheId
Link to a new segmentkeptfreshfreshfresh
Link back to a recent segment, Cache Componentskeptrestoredrestoredfresh
Back/forward, Cache Componentskeptrestoredrestoredrestored
Link or back/forward, no Cache Componentskeptfreshfreshfresh
Effects in the templatenever re-runre-run on every change of segment (both builds)

So "a template resets on every navigation" is exactly true without Cache Components and a simplification with them. What is always true is that effects in a template re-run whenever its segment changes, and that a template never keeps state across different segment values that weren't visited recently.

AdvancedWhen to reach for a template
  • Per-page effects: a page-view log or an "on enter" focus that must run every time the segment changes. (Effects re-run with Activity too, as the effect log shows.)
  • Entrance animations that should replay on each page, since the DOM inside the template is recreated.
  • Suspense fallbacks on every navigation. A <Suspense> in a layout shows its fallback only when it first mounts; the bundled docs point out that inside a template it shows on every navigation, because the boundary itself is new.
  • Resetting a form or a piece of UI per page. Here, with Cache Components on, prefer the patterns from the bundled guide "Preserving UI state": reset in the event handler (setName("") before router.push), derive state from the URL (?edit=true), clear transient UI in a useLayoutEffect cleanup (it runs when Activity hides the route), or key the component on data such as a draft id. bfcacheId as a key is the documented last resort, mainly for migrating apps that relied on unmounting.
Which counters reset?

With Cache Components on, on /feedback/a every counter shows 2. You click Topic b and then Topic a. What do the layout, template and page counters show at the end? What would they show without Cache Components?

Show answer

With Cache Components: 2 · 2 · 2. Without: 2 · 0 · 0.

What’s happening
  1. Topic b is a different value of [topic], so the template's key changes from "a" to "b": React mounts a new template and a new page, both counters 0. The layout is above the changing segment and keeps 2.
  2. With Cache Components the /feedback/a route isn't destroyed by that navigation; it's hidden with <Activity>, along with its template instance (key "a") and its page.
  3. Clicking Topic a within the next few routes reveals that same kept route: template and page come back with 2, and their effects run again. The layout was never touched. Observed: 2 · 2 · 2.
  4. Without Cache Components the route for a was unmounted when you left, so coming back mounts everything below the layout from scratch: 2 · 0 · 0. Observed in the second run.
  5. If the requirement is "the form on a topic page is always empty when you arrive by a link", key it on bfcacheId (the keyed counter: 0 after the link in both builds) or reset it explicitly, instead of relying on the template.

#Prefetching and instant navigation

Added

A server-rendered app has a built-in delay: on click, the client has to ask the server for the next page. Prefetching moves that request to before the click. In production, every <Link> that enters the viewport asks the server, in the background, for what it will need to show its destination, and keeps the answer in an in-memory client cache (the "router cache"). When the user clicks, the router can render the new page from that cache straight away and only fetch what couldn't be prepared. Prefetching never runs in next dev, so every observation here is from next start.

What gets prefetched changed a lot between versions, and that's what interviews probe:

  • Without Cache Components (Next.js 13–15, and 16 apps with the flag off): a static route is prefetched in full; a dynamic route is skipped, or prefetched only down to its nearest loading.tsx (the "partial prefetch" of those versions). prefetch={true} fetched the full dynamic page too.
  • With Cache Components and Partial Prefetching (cacheComponents: true plus partialPrefetching: true, the defaults of a new 16.4 app, partialPrefetching added in 16.3): a default <Link> prefetches the route's App Shell, one per route and shared by every link to it. The shell holds everything that doesn't depend on the URL: static output, cached ('use cache') output with a stale time of at least 5 minutes, and the Suspense fallbacks around URL data. Routes that read cookies() or headers() get a shell that includes that session data, cached per session. <Link prefetch={true}> asks for more: a per-link prefetch that also resolves the link's own params and searchParams and the cached content behind them. Uncached content always streams in after the click.

The bundled docs call these the navigation stages: App Shell → per-link prefetch → navigation. The mental model is a restaurant that preps by menu item, not by table: the base sauce for "product page" is made once for everyone who might order it (the App Shell); a table that has already said exactly what it wants gets its dish plated in advance (prefetch={true}); anything made to order is cooked when the order arrives (the navigation request).

/products/[id] in the browser's cache, by stage 1 · App Shell default <Link>, once per route "Product page" heading + "Loading product…" fallback shared by the links to /products/1, /2 and /3: one request 2 · Per-link prefetch <Link prefetch={true}> + id from this link's URL, + cached getProduct("4") "Product 4: $14" is ready before the click (a request per link) 3 · Navigation on click renders what's still missing and streams it in Product 2: fallback at +26 ms, "Product 2: $12" at +425 ms
What each stage contributes for the product route in the app below (timings with 400 ms of emulated latency).
app/page.tsx
import Link from "next/link";

export default function HomePage() {
  return (
    <main>
      <h1>Home</h1>
      <ul>
        <li><Link href="/about">About (static)</Link></li>
        <li><Link href="/products/1">Product 1</Link></li>
        <li><Link href="/products/2">Product 2</Link></li>
        <li><Link href="/products/3">Product 3</Link></li>
        <li><Link href="/products/4" prefetch={true}>Product 4 (prefetch=true)</Link></li>
        <li><Link href="/contact" prefetch={false}>Contact (prefetch=false)</Link></li>
        <li><Link href="/account">Account (reads a cookie)</Link></li>
      </ul>
      <div style={{ height: 2000 }} />
      <Link href="/terms" id="terms">Terms (far below the fold)</Link>
    </main>
  );
}
What’s happening
  1. On load, only the links in the viewport start prefetching: About, Products 1–4 and Account. Contact has prefetch={false} and is never prefetched. Terms is 2,000 px down and was prefetched only after the page was scrolled to it (the bundled guide describes a small queue: viewport links first, then links the user hovers or touches, newer links replacing older ones, links that scroll out discarded).
  2. For each URL the router first fetches a tiny route tree (next-router-segment-prefetch: /_tree, ~690 bytes each), then the segments it doesn't have. The /products/[id] page segment was fetched once, through /products/1, and reused for Products 2 and 3: that's the shared App Shell. It contains "Product page" and the "Loading product…" fallback, because ProductInfo reads params.
  3. Product 4 has prefetch={true}, so it got a separate request (next-router-prefetch: 2) that resolved its id and the cached getProduct("4"). Account reads a cookie, and it too got a request of its own (next-router-prefetch: 3) instead of a shared segment: its shell includes session data, which the bundled docs say is cached per session, and it already contained "Hello, guest".
  4. The clicks, with 400 ms of latency emulated after the prefetches: About, Product 4 and Account rendered complete about 25 ms after the click with no request. Product 2 showed the shell (heading and fallback) at +26 ms and its content at +425 ms, after one navigation request. Contact stayed on Home until +434 ms: nothing was prefetched, so the click had to wait for the server.
  5. So the work to make every navigation instant is in the destination's structure (cache what you can, wrap URL- and request-dependent parts in <Suspense>), not in the link. prefetch={true} is the per-link upgrade for the few links whose URL-dependent content you want ready before the click, at the cost of a server request per link.
AdvancedPrefetch app: product, account, static pages

The rest of the app

The product page caches its data with 'use cache' and reads params inside <Suspense>; the account page reads a cookie inside <Suspense>; the other pages are static one-liners.

app/products/[id]/page.tsx
import { Suspense } from "react";

async function getProduct(id: string) {
  "use cache";
  return { name: `Product ${id}`, price: 10 + Number(id) };
}

export default function ProductPage({ params }: PageProps<"/products/[id]">) {
  return (
    <main>
      <h1>Product page</h1>
      <Suspense fallback={<p id="product-fallback">Loading product…</p>}>
        <ProductInfo params={params} />
      </Suspense>
    </main>
  );
}

async function ProductInfo({ params }: Pick<PageProps<"/products/[id]">, "params">) {
  const { id } = await params;
  const product = await getProduct(id);
  return (
    <p id="product-info">
      {product.name}: ${product.price}
    </p>
  );
}
app/account/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

export default function AccountPage() {
  return (
    <main>
      <h1>Account</h1>
      <Suspense fallback={<p>Loading account…</p>}>
        <Greeting />
      </Suspense>
    </main>
  );
}

async function Greeting() {
  const user = (await cookies()).get("user")?.value ?? "guest";
  return <p id="greeting">Hello, {user}</p>;
}
app/about/page.tsx
export default function AboutPage() {
  return <h1>About</h1>;
}
app/contact/page.tsx
export default function ContactPage() {
  return <h1>Contact</h1>;
}
app/terms/page.tsx
export default function TermsPage() {
  return <h1>Terms</h1>;
}
AdvancedPrefetch app: requests and click timings

What the build and the browser showed

next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├ ◐ /account
├ ○ /contact
├   /products/[id]
│ └ ◐ /products/[id]
└ ○ /terms


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

Every ?_rsc request headless Chrome made against next start (request headers next-router-segment-prefetch and next-router-prefetch, response size):

Output
after load, links in the viewport
  GET /about?_rsc=…        segment /_tree                      prefetch 1   693 B
  GET /products/1?_rsc=…   segment /_tree                      prefetch 1   687 B
  GET /products/2?_rsc=…   segment /_tree                      prefetch 1   687 B
  GET /products/3?_rsc=…   segment /_tree                      prefetch 1   687 B
  GET /account?_rsc=…      segment /_tree                      prefetch 1   692 B
  GET /products/1?_rsc=…   segment /products/$d$id/__PAGE__    prefetch 1   1848 B   ← the shared App Shell
  GET /about?_rsc=…        segment /about/__PAGE__             prefetch 1   1791 B
  GET /products/4?_rsc=…   (with the state tree)               prefetch 2            ← prefetch={true}
  GET /account?_rsc=…      (with the state tree)               prefetch 3            ← session shell
  (nothing for /contact: prefetch={false}; nothing for /terms yet)
after scrolling to the bottom
  GET /terms?_rsc=…        segment /_tree                      prefetch 1   693 B
  GET /terms?_rsc=…        segment /terms/__PAGE__             prefetch 1   1789 B

Then each link clicked (and the browser sent back to / in between), with Chrome's network emulation adding 400 ms of latency to every request, and the DOM sampled on every animation frame after the click:

Output
About (static)               +26ms /about       "About"                                no request
Product 2                    +26ms /products/2  "Product page", "Loading product…"     GET /products/2?_rsc=… (navigation)
                            +425ms /products/2  "Product page", "Product 2: $12"
Product 4 (prefetch=true)    +24ms /products/4  "Product page", "Product 4: $14"       no request
Contact (prefetch=false)      +1ms /            "Home"  (still)                        GET /contact?_rsc=… (navigation)
                            +434ms /contact     "Contact"
Account (reads a cookie)     +27ms /account     "Account", "Hello, guest"              no request
About (static), 2nd time     +23ms /about       "About"                                no request

Without the emulated latency (plain localhost) the same clicks all finished within about 30 ms, Product 2 included: on a fast local server the navigation request returns before the fallback is even painted. That's why prefetching behaviour has to be checked with throttling, or on a real network.

AdvancedControlling prefetching
prefetch valueWithout Cache ComponentsWith Cache Components + Partial Prefetching
omitted, "auto" or null (default)static route: full; dynamic route: down to the nearest loading.tsx, or nothingthe route's App Shell, shared by all links to it
truethe full route, dynamic data includedApp Shell + this link's URL data and the cached content behind it (a request per link)
falsenever, not even on hovernever
  • Many links (an infinite list, a footer): prefetch={false}, or prefetch on hover only. The bundled guide's pattern is a wrapper with prefetch={active ? null : false} and onMouseEnter={() => setActive(true)}; with Partial Prefetching it suggests prefetch={active ? true : "auto"} so the shared shell is still prefetched before intent.
  • Programmatic: router.prefetch("/pricing"), e.g. on hover of a whole card. Its onInvalidate callback (15.4+) tells you when the prefetched data went stale.
  • Expensive cached work you don't want in prefetches: await prefetch() or await navigation() from next/cache above it defers that subtree to a per-link prefetch or to the navigation (bundled guide "Optimizing prefetching").
  • Per route: export const prefetch = "partial" opts one route into Partial Prefetching while the global flag is still off, the documented path for migrating an existing app.
  • Prefetching runs your Server Components, so layouts and pages must be free of side effects: analytics belong in a Client Component effect.
AdvancedThe client cache (router cache)

Prefetched and visited RSC payloads live in memory in the browser, keyed by route segment. Layouts and loading states are reused across navigations; that's why the second About click above made no request, and why sibling navigations only fetch the changed leaf. How long page data is reused changed twice:

  • Next.js 13 and 14: visited dynamic pages were reused for 30 seconds and static ones for 5 minutes, so data could look stale after a mutation unless you called router.refresh(). experimental.staleTimes (14.2) made it configurable.
  • Next.js 15: staleTimes.dynamic defaults to 0, so dynamic page segments are fetched on every navigation (the loading UI quiz showed it); back/forward still restores them, and static data is kept for 5 minutes.
  • Next.js 16 with Partial Prefetching: App Shells are cached per route; invalidations such as revalidateTag or revalidatePath silently refresh the affected prefetches. router.refresh() clears the client cache for the current route and re-renders its Server Components without touching server caches.
Who waits for the server?

In the app above, with a slow network, the user clicks four links that were all in the viewport on load: Product 3, Product 4 (prefetch={true}), Contact (prefetch={false}) and About. For which clicks does the URL change at once, and for which is the full content there at once?

Show answer

URL at once: Product 3, Product 4 and About. Full content at once: Product 4 and About. Contact waits for the server before anything changes.

What’s happening
  1. About is a static route: its tree and page segment were prefetched and it has no URL-dependent part. The click renders it completely from the cache, with no request (observed at +26 ms).
  2. Product 3 has no prefetch of its own, but it shares the /products/[id] App Shell fetched through Product 1. The router can show the heading and the "Loading product…" fallback at once; the part that reads params needs a navigation request (observed with Product 2: fallback at +26 ms, content at +425 ms).
  3. Product 4 has prefetch={true}, so its per-link prefetch already resolved id = "4" and the cached getProduct("4"): complete at +24 ms, no request.
  4. Contact has prefetch={false}: nothing is cached for it, so the router can't render anything until the navigation response arrives. The old page (Home) stays on screen, which is the "unresponsive app" feeling; observed at +434 ms. A loading.tsx wouldn't help here because it would never have been prefetched; useLinkStatus could at least show a pending hint on the link.
  5. Lesson: the default link gives instant structure; prefetch={true} gives instant content for URL-dependent pages; prefetch={false} gives nothing instant.

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.