Next.js NotesRohit’s interview study guide
Chapter 01

Next.js Fundamentals

What Next.js adds to React, the two routers, the files that make a route, what next dev, next build and next start each do (with every route-table symbol explained), how one request becomes HTML, an RSC payload and a hydrated page, and the version changes interviewers still mix up.

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

#What Next.js is and why teams use it

Added

React is a library for describing UI: you give it components and state, it gives you DOM. It has no opinion on which URL shows which component, where your code runs, how data reaches a component, how files are bundled, or how the app is deployed. A plain React app has to answer every one of those questions with extra tools: a router, a bundler, a data library, an API server, a hosting setup.

Next.js is a React framework that answers those questions for you. It gives you file-system routing (a folder is a URL), server rendering (React Server Components, HTML on the first request, prerendering at build time), data fetching and caching on the server, bundling (Turbopack), API endpoints in the same project (Route Handlers), and one production server (next start) or other deployment targets. You still write React components; Next.js decides when and where each one runs.

A mental model that holds for the whole site: every component in a Next.js app runs in one of three places: at build time, on the server for a request, or in the browser. React alone only has the third. Most interview questions about Next.js ("is this page static?", "why is my console.log not in the browser console?", "why did this stop updating?") are really "which of the three places does this code run in, and when?".

JobPlain React (Vite + React Router)Next.js
RoutingYou declare routes in codeFolders and files under app/ are the routes
First HTMLAn empty <div id="root">; the browser renders everythingReal HTML from the server or from the build
DataFetched in the browser after the JS loads (or in a loader)Fetched on the server, in the component, before the HTML is sent
APIA separate server (Express, …)route.ts files and Server Actions in the same project
BundlingViteTurbopack (built in, default since 16)
Running in productionStatic files on a CDNA Node.js server (next start), or static export, or a platform adapter

The example is a tiny shop that uses four of those jobs at once: a route from a file, data read on the server, an interactive button, and an API endpoint.

app/page.tsx
import { getProducts } from "@/lib/products";
import { AddToCart } from "@/components/add-to-cart";

export default async function Home() {
  const products = await getProducts();
  return (
    <main>
      <h1>Shop</h1>
      <ul>
        {products.map((p) => (
          <li key={p.id}>
            {p.name}, £{p.price} <AddToCart name={p.name} />
          </li>
        ))}
      </ul>
    </main>
  );
}
What’s happening
  1. app/page.tsx is the route / only because of where it sits: a page.tsx directly in app/. There is no router configuration anywhere. app/api/stock/route.ts becomes /api/stock the same way (see Project structure and special files).
  2. Home is an async Server Component (the default in the App Router): it awaits getProducts() directly, with no useEffect, no loading state and no API call from the browser. Its code never reaches the browser.
  3. The data doesn't depend on the request, so next build rendered / once, at build time, and saved the HTML: that's the ○ / row. Every visitor gets the same prerendered file.
  4. AddToCart starts with "use client", so it's a Client Component: it's still rendered to HTML on the server (the button text Add Desk lamp (0) is in the HTML), and its JavaScript is also sent to the browser so React can attach onClick there.
  5. The GET handler calls new Date(), a value that must be fresh per request, so the build couldn't prerender it: ƒ /api/stock means it runs on every request. A handler that returned a constant would have been ○.
  6. /_not-found is the built-in 404 page Next.js always adds (the route table is under "The rest of the app", with the other three files). Together: one file per route, server-side data with no client fetching, interactivity only where you opt in, and the build tells you what it could do ahead of time.
AdvancedShop app: data, button, API and route table

The rest of the app

The data module, the Client Component and the API endpoint, then the route table next build printed for the whole app:

lib/products.ts
export type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: "1", name: "Desk lamp", price: 39 },
  { id: "2", name: "Notebook", price: 6 },
];

// In a real app this is a database query or an API call.
export async function getProducts(): Promise<Product[]> {
  return products;
}
components/add-to-cart.tsx
"use client";

import { useState } from "react";

export function AddToCart({ name }: { name: string }) {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount((c) => c + 1)}>
      Add {name} ({count})
    </button>
  );
}
app/api/stock/route.ts
export async function GET() {
  return Response.json({ inStock: 12, checkedAt: new Date().toISOString() });
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /api/stock


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
AdvancedWhat the browser actually receives

I ran this app with next build && next start -p 4501 and fetched the page with curl. The HTML already contains the products and the buttons (the <!-- --> comments are React's text separators):

curl -s localhost:4501/ (the <main> element)
<main><h1>Shop</h1><ul><li>Desk lamp<!-- -->, £<!-- -->39<!-- --> <button>Add <!-- -->Desk lamp<!-- --> (<!-- -->0<!-- -->)</button></li><li>Notebook<!-- -->, £<!-- -->6<!-- --> <button>Add <!-- -->Notebook<!-- --> (<!-- -->0<!-- -->)</button></li></ul></main>

The response was 7,055 bytes with Cache-Control: s-maxage=31536000 (a prerendered page). Besides the markup it carried 7 <script src> tags for JavaScript chunks and 7 inline <script>self.__next_f.push(…)</script> tags: the RSC payload, the serialized Server Component tree that React uses in the browser. Two lines from it:

inside the inline payload
19:I[5865,["$2","$18"],"AddToCart"]
["$","$L19",null,{"name":"Desk lamp"}]

19:I[…,"AddToCart"] is a client reference: "the component called AddToCart, from these JS chunks". ["$","$L19",null,{"name":"Desk lamp"}] is the element that uses it, with the props the server passed. There's no reference to Home or getProducts: Server Components appear only as their output. How a request becomes a page follows this payload all the way through.

In headless Chrome the buttons started at Add Desk lamp (0) and showed Add Desk lamp (2) after two clicks, so the server HTML had been hydrated. Two calls to /api/stock one second apart returned different times, as ƒ predicts:

curl -s localhost:4501/api/stock (twice)
{"inStock":12,"checkedAt":"2026-10-25T18:16:20.563Z"}
{"inStock":12,"checkedAt":"2026-10-25T18:16:21.582Z"}

For contrast, I built the same list as a Vite + React single-page app (Vite 8.3) in the same sandbox. Its whole dist/index.html is this, and the products exist only after a 219.73 kB script runs:

dist/index.html (vite build)
<!doctype html>
<html lang="en">
  <head><meta charset="UTF-8" /><title>Shop</title>  <script type="module" crossorigin src="/assets/index-BdT6AlK-.js"></script>
</head>
  <body>
    <div id="root"></div>
  </body>
</html>
AdvancedWhen Next.js is worth it, and when it isn't

Teams pick Next.js for:

  • Content that must be in the HTML: marketing pages, blogs, shops, docs. Search engines and link previews get real markup, and users see content before any JavaScript runs.
  • Fast first loads on slow devices: Server Components send HTML and a small payload instead of the code that produced it, so libraries used only on the server (Markdown parsers, date libraries, database clients) add nothing to the bundle.
  • One project for UI and backend-for-frontend: data access, mutations (Server Actions) and small APIs (Route Handlers) live next to the pages that use them.
  • Static where possible, dynamic where needed, per component: with Cache Components a single page can be a prerendered shell with live parts streamed in (see Partial Prerendering).
  • Conventions: a new engineer knows where routes, layouts, loading states and error boundaries live.

You probably don't need it for:

  • An app that lives behind a login and doesn't care about SEO (an internal dashboard, an admin tool) where a Vite SPA with a separate API is simpler to host and reason about.
  • Hosting with no server at all. output: 'export' gives you static files, but Server Actions, request-time rendering, proxy and on-demand revalidation need a running server.
  • A widget embedded in someone else's page, a browser extension, or React Native.
  • A team that doesn't want two runtimes in one codebase. The server/client boundary, the caching model and the version churn (big changes in 13, 15 and 16) are real costs.
AdvancedGotcha: Next.js is not SSR on every request

#App Router vs Pages Router

Added

Next.js ships two routers, and you'll meet both. The Pages Router (the original, since Next.js 1.0 in October 2016) maps files in pages/ to URLs. Each page is a normal React component that is rendered on the server and then fully hydrated in the browser. Data comes from special exported functions that run on the server before the component: getServerSideProps (every request), getStaticProps/getStaticPaths (build time, with ISR), both added in 9.3 (March 2020).

The App Router (app/, introduced in 13.0 in October 2022 and stable in 13.4 in May 2023) is built on React Server Components. Components are server-only by default and can await data themselves; layouts nest and stay mounted across navigations; loading and error states are files; the response streams; mutations are Server Actions; and since 16 it has the Cache Components model. It's what create-next-app sets up by default, and the big features since 13 (Server Components, Server Actions, streaming, Cache Components) are App Router only.

The mental model: in the Pages Router, a page is a client React app whose props the server computes first. In the App Router, a page is a server-rendered tree with client islands in it. Pages Router code ships every component to the browser; App Router code ships only the 'use client' parts.

The example puts the same product page in both routers, in one app. That's allowed: the two directories can coexist (it's how teams migrate one route at a time), as long as they don't claim the same URL.

app/products/[id]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { getProduct } from "@/lib/products";

export default async function ProductPage(props: PageProps<"/products/[id]">) {
  const { id } = await props.params;
  const product = await getProduct(id);
  if (!product) notFound();
  return (
    <main>
      <h1>{product.name}</h1>
      <p>£{product.price}</p>
      <Link href={`/legacy/${id}`}>Same product in the Pages Router</Link>
    </main>
  );
}
What’s happening
  1. This is the App Router version, app/products/[id]/page.tsx: the folder [id] is a dynamic segment, and page.tsx makes /products/1, /products/2, … public. The Pages Router version of the same page and the rest of the app are below.
  2. ProductPage is an async Server Component: it runs only on the server, reads the product itself (no getServerSideProps, no client fetch) and returns JSX. Its code isn't sent to the browser; only <Link>, a Client Component inside next/link, is.
  3. params is a Promise (since Next.js 15), so it's awaited. If there's no product, notFound() throws and the nearest not-found UI renders instead of the page.
  4. Because the page needs params, known only per request, and the folder has a loading.tsx, the build prerendered a static shell containing the loading UI, and the product streams into it per request: ◐ in the route table below. I deleted loading.tsx and rebuilt: the build failed with Route "/products/[id]": Next.js encountered uncached or runtime data during prerendering. With Cache Components, reading request data needs a boundary (or caching, or an explicit opt-out).
  5. The build printed two route tables, Route (app) and Route (pages): the quickest way to see which router a project uses, and here it uses both. Both pages import the same lib/products.ts; what doesn't cross between the routers is client-side navigation (shown under "What each router puts in the page").
AdvancedThe same page in the Pages Router

pages/legacy/[id].tsx serves /legacy/1. Same data, same markup, the Pages Router way:

pages/legacy/[id].tsx
import Link from "next/link";
import type { GetServerSideProps } from "next";
import { getProduct, type Product } from "@/lib/products";

type Props = { product: Product };

export const getServerSideProps: GetServerSideProps<Props, { id: string }> = async ({ params }) => {
  const product = await getProduct(params!.id);
  if (!product) return { notFound: true };
  return { props: { product } };
};

export default function LegacyProductPage({ product }: Props) {
  return (
    <main>
      <h1>{product.name}</h1>
      <p>£{product.price}</p>
      <Link href={`/products/${product.id}`}>Same product in the App Router</Link>
    </main>
  );
}
What’s happening
  1. A file in pages/ is a route by itself: pages/legacy/[id].tsx is /legacy/[id], no page.tsx naming, no folder per route.
  2. getServerSideProps runs on the server for every request, before the component. It gets params as a plain object (the Pages Router never made it a Promise), and whatever it returns as props is passed to the component.
  3. The same props are also serialised into the page (the __NEXT_DATA__ script shown below) so the browser can hydrate with exactly what the server used. Anything you return there is public, even fields the component doesn't render.
  4. { notFound: true } makes Next.js answer with a real 404, because nothing has been sent yet when getServerSideProps runs (compare the quiz below). The route is ƒ in Route (pages): rendered on demand.
  5. LegacyProductPage is an ordinary component, and all of its code ships to the browser and hydrates the whole page. There's no 'use client' in the Pages Router because every page component is client code that is also rendered on the server.
AdvancedShared data, loading.tsx and both route tables

The rest of the app

The shared data module, the App Router page's loading.tsx, and the build output with both route tables:

lib/products.ts
export type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: "1", name: "Desk lamp", price: 39 },
  { id: "2", name: "Notebook", price: 6 },
];

export async function getProduct(id: string): Promise<Product | undefined> {
  return products.find((p) => p.id === id);
}
app/products/[id]/loading.tsx
export default function Loading() {
  return <p>Loading product…</p>;
}
next build
Route (app)
┌ ○ /_not-found
└   /products/[id]
  └ ◐ /products/[id]

Route (pages)
─ ƒ /legacy/[id]

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
AdvancedWhat each router puts in the page

With the app running on next start, the two URLs return different machinery. The App Router page streamed: the first bytes had the loading.tsx UI, and the product arrived later in the same response, in a hidden <div> that a small inline script ($RC("B:0","S:0")) swapped into place. Its data travels as the RSC payload in self.__next_f.push(…) scripts:

curl -sD - localhost:4501/products/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
…
<p>Loading product…</p> … <div hidden id="S:0"> … <h1>Desk lamp</h1> … $RC("B:0","S:0")

The Pages Router page arrived complete, with its props as JSON in __NEXT_DATA__, and no self.__next_f at all:

curl -s localhost:4501/legacy/1 (the data script)
<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"product":{"id":"1","name":"Desk lamp","price":39}},"__N_SSP":true},"page":"/legacy/[id]","query":{"id":"1"},"buildId":"9ZO9d9v-C5bqnwlzUg0vl","isFallback":false,"isExperimentalCompile":false,"gssp":true,"scriptLoader":[]}</script>

So "View source" tells you the router of any Next.js site: __NEXT_DATA__ means Pages Router, self.__next_f means App Router.

I also checked the cross-router link in headless Chrome: I set window.__marker on /products/1, clicked "Same product in the Pages Router", and on /legacy/1 the marker was gone, so the browser had loaded a new document. The same happened in the other direction. The migration guide says it plainly: navigating between the routers is a hard navigation, and <Link> doesn't prefetch across them.

Predict the status codes

With the app above running on next start, what HTTP status does each request return: /products/99 and /legacy/99? Neither product exists.

Show answer

/products/99 returns 200 and /legacy/99 returns 404. I checked both with curl -s -o /dev/null -w '%{http_code}'.

What’s happening
  1. /legacy/99: getServerSideProps runs before anything is sent, finds no product and returns { notFound: true }. Next.js hasn't written a byte yet, so it can send a real 404.
  2. /products/99: the route is ◐. Its static shell (the layout plus loading.tsx) is sent first, so the 200 OK status line and headers are already on the wire when the page component runs.
  3. The page then calls notFound(). The status can't change any more, so Next.js streams the not-found UI into the same response and adds <meta name="robots" content="noindex"> (it was in the HTML I fetched), so search engines don't index it.
  4. The docs describe exactly this: before streaming starts you get a 404; after it starts the status stays 200. If a real 404 matters (analytics, compliance), check the slug before anything streams, for example in proxy.ts, or don't put a Suspense boundary above the lookup.
AdvancedHow to tell which router a codebase uses
SignalPages RouterApp Router
Directorypages/ (or src/pages/)app/ (or src/app/)
A route filepages/blog/[slug].tsxapp/blog/[slug]/page.tsx
Shared shellpages/_app.tsx, pages/_document.tsxapp/layout.tsx (the root layout has <html> and <body>)
DatagetServerSideProps, getStaticProps, getStaticPaths, getInitialPropsasync Server Components, 'use cache', generateStaticParams
Router hooksuseRouter from next/router (router.query)useRouter, usePathname, useSearchParams from next/navigation
Head tagsnext/headmetadata / generateMetadata exports
APIpages/api/*.ts with (req, res)app/**/route.ts with Web Request/Response, and Server Actions
Directivesnone'use client', 'use server', 'use cache'
In the page source<script id="__NEXT_DATA__">self.__next_f.push(…)
AdvancedThe same concepts in both
Pages RouterApp Router equivalent
getServerSidePropsAn async Server Component that reads request data (cookies(), headers(), searchParams)
getStaticPropsThe default: a Server Component with no request data is prerendered; cache data with 'use cache'
getStaticProps + revalidate (ISR)'use cache' + cacheLife(...), or revalidate in the previous model
getStaticPathsgenerateStaticParams
_app.tsxThe root layout.tsx (plus nested layouts, which _app couldn't do)
_document.tsxThe <html> and <body> in the root layout
pages/404.tsx, _error.tsxnot-found.tsx, error.tsx, global-error.tsx
API RoutesRoute Handlers (route.ts)

The Pages Router chapter covers that router properly; this site teaches the App Router as the main model.

AdvancedDetails that come up in migrations
  • React versions differ. The Pages Router uses the React version in your package.json; the App Router uses the React canary build bundled with Next.js (the docs' "React version handling" note). That's why App Router features can use React APIs before they're in a stable React release.
  • The same URL can't be in both. I added app/about/page.tsx and pages/about.tsx to one project, and next build stopped with: Error: App Router and Pages Router both match path: /about.
  • src/ is all or nothing. If both app/ and src/app/ exist, the root one wins and src/app is ignored (the src folder docs).
  • Server-only libraries don't help the Pages Router bundle. In getServerSideProps they stay on the server, but anything a page component imports is bundled for the browser. Moving such a page to a Server Component is often where the bundle-size win of a migration comes from.

#Project structure and special files

Added

In the App Router the folder tree is the route table. Every folder under app/ is a URL segment, and a folder becomes a public route only when it contains a page.tsx (a UI route) or a route.ts (an API endpoint). Everything else in the folder is private: you can keep components, helpers, tests and styles right next to the route that uses them (this is called colocation) and none of it is reachable by URL.

Inside a segment, a handful of special file names plug into a fixed wrapper stack. Think of each segment as a set of nested boxes that Next.js builds for you: the layout on the outside, then the template, an error boundary, a Suspense boundary for loading, a not-found boundary, and the page in the middle. You don't write the boundaries; you provide the files and Next.js wires them up in this order (from the project-structure docs):

layout.tsx stays mounted across navigations template.tsx re-mounted on every navigation error.tsx error boundary ('use client') loading.tsx Suspense fallback, part of the static shell not-found.tsx shown when notFound() is called page.tsx this route's UI, or a nested layout.tsx makes the folder a public URL
One segment's special files, outermost first. A nested segment repeats the whole stack inside the parent's page slot.

The example is a small blog that uses each of them, plus a route group, a private folder, a colocated data file and an RSS endpoint.

The blog app's files
app/
├── layout.tsx                 the root layout (the project template's)
├── (marketing)/
│   └── about/
│       └── page.tsx           → /about
└── blog/
    ├── layout.tsx             wraps every /blog page
    ├── template.tsx
    ├── loading.tsx
    ├── error.tsx
    ├── not-found.tsx
    ├── page.tsx               → /blog
    ├── posts.ts               a data module, not a route
    ├── _components/
    │   └── post-list.tsx      a private folder, not a route
    ├── [slug]/
    │   └── page.tsx           → /blog/hello-world, /blog/layouts, …
    └── rss.xml/
        └── route.ts           → /blog/rss.xml
What’s happening
  1. The route table next build printed (it's below, after the files) lists exactly the URLs marked →: the only public URLs are the folders that have a page.tsx or a route.ts. posts.ts and _components/post-list.tsx sit inside app/blog/ and are not routes: a plain file is never routable (colocation), and a folder starting with _ is a private folder, opted out of routing with everything under it.
  2. app/(marketing)/about/page.tsx became /about, not /(marketing)/about: parentheses make a route group, a folder that organises files (and can carry its own layout) without adding a URL segment.
  3. app/blog/layout.tsx wraps every page under /blog and stays mounted when you move between them, so its <nav> isn't re-rendered or reset. template.tsx sits just inside it and gets a fresh instance whenever the segment below it changes, for example /blog/hello-world → /blog/layouts (when you want state to reset; with Cache Components a recently visited page can bring its old template back, see Templates vs layouts).
  4. loading.tsx wraps the segment's content in <Suspense fallback={<Loading />}>. That's why /blog/[slug] could be prerendered as a ◐ shell: for a slug not in generateStaticParams, the shell is the layout plus "Loading post…", and the post streams in. The two known slugs had all their data at build time, so they're full ○ pages.
  5. error.tsx must be a Client Component (error boundaries are a client feature in React). In Next.js 16.3+ it receives retry(), which refreshes the route from the server (router.refresh()) and then resets the boundary, so Client Component state such as a layout's counter survives; older code uses reset(), which only re-renders the boundary without asking the server again (see Error handling with error.js).
  6. not-found.tsx renders when notFound() is called in this segment: /blog/nope matches [slug], getPost returns undefined, and the page shows "No post with that name." inside the blog layout. rss.xml/route.ts is an endpoint: a folder can be named like a file, and the handler returns any Response.
AdvancedThe blog app files and its route table

The files

app/blog/posts.ts
export type Post = { slug: string; title: string; body: string };

export const posts: Post[] = [
  { slug: "hello-world", title: "Hello, world", body: "The first post." },
  { slug: "layouts", title: "Why layouts persist", body: "They stay mounted." },
];

export function getPost(slug: string): Post | undefined {
  return posts.find((p) => p.slug === slug);
}
app/blog/_components/post-list.tsx
import Link from "next/link";
import type { Post } from "../posts";

export function PostList({ posts }: { posts: Post[] }) {
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.slug}>
          <Link href={`/blog/${p.slug}`}>{p.title}</Link>
        </li>
      ))}
    </ul>
  );
}
app/blog/layout.tsx
import Link from "next/link";

export default function BlogLayout({ children }: LayoutProps<"/blog">) {
  return (
    <section>
      <nav>
        <Link href="/blog">All posts</Link> · <Link href="/about">About</Link>
      </nav>
      {children}
    </section>
  );
}
app/blog/template.tsx
export default function BlogTemplate({ children }: { children: React.ReactNode }) {
  return <div data-template="blog">{children}</div>;
}
app/blog/loading.tsx
export default function Loading() {
  return <p>Loading post…</p>;
}
app/blog/error.tsx
"use client";

export default function BlogError({ retry }: { error: unknown; retry: () => void }) {
  return (
    <div>
      <p>Something went wrong loading the blog.</p>
      <button onClick={() => retry()}>Try again</button>
    </div>
  );
}
app/blog/not-found.tsx
export default function PostNotFound() {
  return <p>No post with that name.</p>;
}
app/blog/page.tsx
import { posts } from "./posts";
import { PostList } from "./_components/post-list";

export default function BlogIndex() {
  return (
    <main>
      <h1>Blog</h1>
      <PostList posts={posts} />
    </main>
  );
}
app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPost, posts } from "../posts";

export function generateStaticParams() {
  return posts.map((p) => ({ slug: p.slug }));
}

export default async function PostPage(props: PageProps<"/blog/[slug]">) {
  const { slug } = await props.params;
  const post = getPost(slug);
  if (!post) notFound();
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}
app/blog/rss.xml/route.ts
import { posts } from "../posts";

export function GET() {
  const items = posts.map((p) => `<item><title>${p.title}</title></item>`).join("");
  return new Response(`<rss version="2.0"><channel>${items}</channel></rss>`, {
    headers: { "Content-Type": "application/rss+xml" },
  });
}
app/(marketing)/about/page.tsx
export default function About() {
  return <h1>About this blog</h1>;
}
next build
Route (app)
┌ ○ /_not-found
├ ○ /about
├ ○ /blog
├   /blog/[slug]
│ ├ ◐ /blog/[slug]
│ ├ ○ /blog/hello-world
│ └ ○ /blog/layouts
└ ○ /blog/rss.xml


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedWhat the running app returned

I ran it with next start -p 4501 and requested each URL with curl (-w '%{http_code} %{content_type}'):

curl, one request per URL
/about                        200 text/html; charset=utf-8
/blog                         200 text/html; charset=utf-8
/blog/hello-world             200 text/html; charset=utf-8
/blog/rss.xml                 200 application/rss+xml
/blog/nope                    200 text/html; charset=utf-8   (noindex)
/blog/posts                   200 text/html; charset=utf-8   (noindex)
/blog/_components             200 text/html; charset=utf-8   (noindex)
/blog/_components/post-list   404 text/html; charset=utf-8   (noindex)
/(marketing)/about            404 text/html; charset=utf-8   (noindex)
  • /blog/posts and /blog/_components are one segment after /blog, so they match [slug] and get the blog's not-found UI: the colocated posts.ts and the private folder aren't served, but the URL still matched the dynamic route. As in the quiz above, the shell had already streamed, so the status is 200 with noindex.
  • /blog/_components/post-list is two segments: nothing matches, and the root /_not-found page answers with a real 404.
  • In the HTML of /blog/nope the visible body was the layout, <template data-dgst="NEXT_HTTP_ERROR_FALLBACK;404"></template> and the "Loading post…" fallback. In headless Chrome the page then showed "All posts · About No post with that name.": React in the browser read that marker and rendered not-found.tsx in place of the fallback.
  • The HTML of /blog/hello-world shows the stack from the diagram: <section><nav>…</nav><div data-template="blog"><article><h1>Hello, world</h1>… (layout, then template, then the page; the boundaries leave only comment markers).
AdvancedEvery special file
FileWhat it isNotes
layout.tsxUI shared by a segment and everything below itStays mounted across navigations. The root app/layout.tsx is required and renders <html> and <body>
page.tsxThe UI of one routeMakes the folder public. Receives params and searchParams (both Promises)
loading.tsxLoading UI for the segmentWraps the page in <Suspense>; prerendered into the static shell
error.tsxError boundary for the segmentMust be 'use client'; gets error and retry() (16.3+), reset()
global-error.tsxError boundary around the root layoutMust render its own <html> and <body>
not-found.tsxUI for notFound() in the segmentThe root one also answers unmatched URLs
route.tsAn HTTP endpoint (GET, POST, …)Can't sit in the same folder as a page.tsx
template.tsxLike a layout, but re-mounted when the segment below it changesFor per-navigation state resets and effects
default.tsxFallback for a parallel route slotSince Next.js 16 the build fails if a slot has neither a page nor a default.tsx for a URL its siblings match
forbidden.tsx, unauthorized.tsxUI for forbidden() / unauthorized()Experimental in 16.4

Folder conventions: [id] dynamic segment, [...slug] catch-all, [[...slug]] optional catch-all, (group) route group, _folder private folder, @slot parallel route, (.)folder / (..)folder / (...)folder intercepting routes. A segment that must start with an underscore in the URL is written %5Ffolder. The Routing chapter goes through each.

Files outside app/:

File or folderPurpose
public/Static files served from / (public/logo.svg is /logo.svg), always at the project root
src/Optional; holds app/ (and pages/) to separate code from config. proxy.ts and instrumentation.ts go in src/ too
next.config.tsFramework configuration; stays at the root
proxy.tsRuns before routes on matched requests (it was middleware.ts before 16)
instrumentation.ts, instrumentation-client.tsMonitoring hooks (register, onRequestError) and early client code
.env, .env.local, .env.production, .env.developmentEnvironment variables; only NEXT_PUBLIC_* reach the browser
next-env.d.tsGenerated Next.js types; don't commit it

#The development and build workflow

Added

Three commands, three different jobs:

  • next dev compiles on demand with Turbopack (the default since 16), renders every request fresh (nothing is prerendered, Cache-Control: no-store), and adds Fast Refresh, the error overlay and extra checks. It writes to .next/dev, so it can run while a build runs.
  • next build compiles the whole app for production, type-checks it, prerenders everything it can, and prints a route table: one line per route saying how much of it was done ahead of time. It writes .next/.
  • next start serves what the build produced: prerendered files straight from .next, and on-demand rendering only for what wasn't prerendered.

The mental model: dev answers "does my code work?", build answers "how much of my app can be done ahead of time?", and start serves the answer. The route table is the build's report, and it's the most useful thing in the terminal for an interview question like "is this page static?".

Here is the table next build printed for an app built to produce every symbol the table can show with Cache Components on (the template's next.config.ts has cacheComponents: true): a plain page, a page with cached data, a page with a Suspense hole that reads cookies, a page that opts out of a static shell, two Route Handlers and a proxy. The files follow the walkthrough.

next build
Route (app)           Revalidate  Expire
┌ ○ /_not-found
├ ○ /about
├ ○ /api/hello
├ ƒ /api/time
├ ◐ /dashboard
├ ○ /pricing                  1h      1d
└ ƒ /search


ƒ Proxy (Middleware)

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
What’s happening
  1. ○ /about: no data, no request values, so the whole page was rendered to HTML at build time. Its console.log("rendering /about") appeared in the build output (twice, in fact), and never once when I requested the page from next start.
  2. ○ /pricing 1h 1d: getPlans is cached with 'use cache' and cacheLife("hours"), so its result could go into the prerendered HTML. The Revalidate and Expire columns show that profile: the page may be regenerated in the background after 1 hour, and a copy older than 1 day must not be served. A route shows the shortest values of all the caches it contains.
  3. ◐ /dashboard: <Greeting> reads cookies(), which only exists per request, but it sits inside <Suspense>. So the build prerendered a static shell (the <h1> and "Loading…") and left a hole that is rendered per request and streamed in. That's Partial Prerendering, the normal model with Cache Components.
  4. ƒ /search: the page awaits searchParams at the top, with no Suspense around it, so there's no static content to prerender. Without the instant line the build stops (I tried): Error: Route "/search": Next.js encountered uncached or runtime data during prerendering., followed by three fixes: [stream] add a <Suspense>, [cache] use "use cache", [block] set instant = false. export const instant = false is the third: it tells Next.js a blocking page is intended. The build accepted an empty prerender, which the table shows as ƒ.
  5. Route Handlers follow the same rules: /api/hello returns a constant, so its response was prerendered (○); /api/time calls new Date(), so prerendering stopped and it runs per request (ƒ).
  6. ƒ Proxy (Middleware) is printed below the table when a proxy.ts (or the old middleware.ts) exists: it runs on demand, before matching requests. /_not-found is the built-in 404, always static.
AdvancedThe seven files behind the route table

The app behind the table

app/about/page.tsx
export default function About() {
  console.log("rendering /about");
  return <h1>About us</h1>;
}
app/pricing/page.tsx
import { cacheLife } from "next/cache";

async function getPlans() {
  "use cache";
  cacheLife("hours");
  return [{ plan: "Pro", price: 20 }];
}

export default async function Pricing() {
  const plans = await getPlans();
  return (
    <ul>
      {plans.map((p) => (
        <li key={p.plan}>
          {p.plan}: ${p.price} a month
        </li>
      ))}
    </ul>
  );
}
app/dashboard/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

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

export default function Dashboard() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <Greeting />
      </Suspense>
    </main>
  );
}
app/search/page.tsx
export const instant = false;

export default async function Search(props: PageProps<"/search">) {
  const { q } = await props.searchParams;
  return <h1>Results for {q}</h1>;
}
app/api/hello/route.ts
export async function GET() {
  return Response.json({ hello: "world" });
}
app/api/time/route.ts
export async function GET() {
  return Response.json({ now: new Date().toISOString() });
}
proxy.ts
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  console.log(`proxy: ${request.method} ${request.nextUrl.pathname}`);
  return NextResponse.next();
}

export const config = { matcher: "/dashboard/:path*" };
AdvancedThe same table without Cache Components

Apps that haven't turned on Cache Components (most apps from Next.js 13–15, and any app with cacheComponents: false) use the previous model, and their tables have a different symbol set. This app switches it off in its own next.config.ts:

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

const nextConfig: NextConfig = {
  cacheComponents: false,
};

export default nextConfig;
app/about/page.tsx
export default function About() {
  return <h1>About us</h1>;
}
app/posts/[slug]/page.tsx
export function generateStaticParams() {
  return [{ slug: "hello" }, { slug: "world" }];
}

export default async function Post(props: PageProps<"/posts/[slug]">) {
  const { slug } = await props.params;
  return <h1>Post: {slug}</h1>;
}
app/news/page.tsx
export const revalidate = 60;

export default function News() {
  return <p>Built at {new Date().toISOString()}</p>;
}
app/search/page.tsx
export default async function Search(props: PageProps<"/search">) {
  const { q } = await props.searchParams;
  return <h1>Results for {q}</h1>;
}
next build
Route (app)         Revalidate  Expire
┌ ○ /_not-found
├ ○ /about
├ ○ /news                   1m      1y
├   /posts/[slug]
│ ├ ● /posts/hello
│ └ ● /posts/world
└ ƒ /search


○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand
What’s happening
  1. ● (SSG) only exists in this model: pages generated from generateStaticParams (or getStaticProps in the Pages Router). The unmarked /posts/[slug] line is a group heading for the paths below it.
  2. ○ /news 1m 1y: export const revalidate = 60 is the route segment config of the previous model (ISR): prerendered at build, regenerated in the background at most every 60 seconds. 1y is the default expireTime. Note that new Date() was allowed here: without Cache Components it's simply frozen into the HTML each time the page is generated.
  3. ƒ /search: reading searchParams makes the whole route dynamic. There's no ◐ in this model: a route is either all prerendered or all rendered on demand.
  4. With Cache Components on, revalidate, dynamic, dynamicParams and fetchCache exports are removed (the route segment config docs, 16.0.0 row); you use 'use cache' and cacheLife instead. The caching chapter covers both models.
AdvancedEvery symbol and column, in one place
In the tableMeansWhen you see it
○ StaticThe whole route was rendered at build time; served as a fileNo request data, no uncached I/O (or the I/O is cached)
◐ Partial PrerenderA static HTML shell was prerendered; the holes inside <Suspense> render per request and stream inCache Components on, request data or uncached data inside Suspense
ƒ DynamicRendered on demand; with Cache Components, the build accepted an empty prerenderRequest data with no boundary and instant = false, Route Handlers using request data or non-deterministic values, proxy
● SSGPrerendered from generateStaticParams / getStaticPropsWithout Cache Components only
Unmarked line with indented childrenA dynamic route pattern grouping its prerendered pathsAny [param] route with known paths; with Cache Components the first child is the fallback for unknown params
[+N more paths]More prerendered paths than fitLong generateStaticParams lists
Revalidate, Expire columnsThe shortest cache lifetime in the routecacheLife(...), or revalidate in the previous model
ƒ Proxy (Middleware)A proxy.ts / middleware.ts existsBelow the table
Route (pages) sectionPages Router routesWhen pages/ exists

Two columns that tutorials still show are gone: Size and First Load JS were removed from next build in 16.0 because, in the docs' words, they were inaccurate for Server Component apps. Measure real bundle cost with Lighthouse or next analyze instead.

AdvancedWhat next start sends for each symbol

I ran the app above with next start -p 4501 and requested each route with curl -sD - -o /dev/null (headers trimmed to the relevant ones):

curl -sD - -o /dev/null localhost:4501/<route>
/about       x-nextjs-cache: HIT   x-nextjs-prerender: 1   Cache-Control: s-maxage=31536000
/pricing     x-nextjs-cache: HIT   x-nextjs-prerender: 1   Cache-Control: s-maxage=3600, stale-while-revalidate=82800
/dashboard   x-nextjs-prerender: 1   x-nextjs-postponed: 1   Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
/search?q=…  x-nextjs-prerender: 1   x-nextjs-postponed: 1   Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
/api/hello   x-nextjs-cache: HIT   cache-control: s-maxage=31536000
/api/time    (no cache headers)
  • ○ pages come from the build's cache (x-nextjs-cache: HIT) with a CDN-friendly s-maxage. For /pricing the header carries the cacheLife("hours") profile: s-maxage=3600 (revalidate after 1 hour) and stale-while-revalidate=82800 (1 day minus that hour).
  • ◐ and ƒ pages say x-nextjs-postponed: 1: the server started from the prerendered shell (empty for /search) and resumed rendering for this request. They are private, no-store, because the streamed part can be per user.
  • The server log showed what actually ran on requests: proxy: GET /dashboard for each /dashboard request (the matcher kept it off the others) and rendering <Greeting> for rohit when I sent -b user=rohit. It printed the <Greeting> line twice per request. That's Partial Prefetching (partialPrefetching: true, the template's setting): after the normal render, Next.js 16.4.3 runs a second "runtime prerender" of the dynamic part and embeds the result in the RSC payload so the client can cache it (a comment in next/dist/server/app-render/app-render.js describes it; the docs don't). I confirmed it by rebuilding the same app with partialPrefetching: false: one <Greeting> line per request, three requests in a row. Two render passes per request in the data-fetching chapter measures it in detail. The lesson: a component can run more than once for one request, so render code should have no side effects you count on.
  • The same /about under next dev printed rendering /about on every request, with Cache-Control: no-store and a timing line: GET /about 200 in 14ms (next.js: 0.6ms, application-code: 13ms). Dev renders everything on demand, so it can't tell you what is static; only the build can.
AdvancedWhat's inside .next after a build

After next build of the app above (41 MB in total, most of it the compiler cache), the parts worth knowing:

ls .next (selected)
BUILD_ID                    the build's id; changes on every build
cache/turbopack/            Turbopack's file-system cache; makes the next build faster
server/app/about.html       the prerendered HTML of ○ /about
server/app/about.rsc        its RSC payload, served for client navigations
server/app/about.meta       status and headers to send with it (x-nextjs-prerender, cache tags)
server/app/about.segments/  per-segment payloads used for prefetching
server/app/dashboard.html   the ◐ static shell: 1,536 bytes
server/app/dashboard.meta   includes "postponed": the state React needs to resume rendering the holes
server/app/search.html      0 bytes: the empty prerender of ƒ /search
server/app/api/hello.body   the prerendered response of ○ /api/hello
server/chunks/, server/app/**/page.js   compiled server code for whatever renders on demand
server/middleware.js        the compiled proxy
static/                     hashed client JS, CSS and fonts, served from /_next/static/
prerender-manifest.json, routes-manifest.json, …   what the server reads to route requests

Files under /_next/static/ have content hashes in their names, so next start served a chunk with Cache-Control: public, max-age=31536000, immutable: a new build gets new names instead of overwriting old ones. next dev keeps its own state in .next/dev (I saw cache, lock, logs and its manifests there), which is why next dev and next build can run at the same time since Next.js 16. Deleting .next is safe: it only costs a slower next build or dev start.

AdvancedThe other commands
CommandWhat it does
next dev --webpack, next build --webpackUse webpack instead of Turbopack (needed if you keep a custom webpack config)
next build --debug-prerenderUnminified server code and source maps, so a prerender error points at your line
next build --debug-build-paths="app/blog/**/page.tsx"Build only some routes while debugging
next typegenGenerate the route types (PageProps<'/blog/[slug]'>, LayoutProps, RouteContext) without a build; added in 15.5
next infoSystem and package versions for bug reports
next upgradeUpgrade Next.js (16.1+), optionally with a coding agent
next analyzeTurbopack bundle analysis without producing a build (16.1 added it as next experimental-analyze; 16.4 added next analyze export)
next lintRemoved in 16: run ESLint (or Biome) directly; next build no longer lints

#How a request becomes a page

Added

This is the mental model for the rest of the site. When a browser asks for a URL, the App Router does three things, in this order:

  1. React renders the Server Components of the matched route (root layout, nested layouts, page) into the RSC payload: a compact text stream describing the rendered tree. Server Components appear in it only as their output; each Client Component appears as a reference (which JS chunk and export to load) plus the props passed to it.
  2. React renders HTML from that payload, running the Client Components on the server too, and streams it. Everything inside a <Suspense> that isn't ready is sent later in the same response. The RSC payload is also written into the HTML, in <script>self.__next_f.push(…)</script> tags.
  3. The browser shows the HTML at once (not yet interactive), downloads the JS chunks, and hydrates: React builds the tree from the inline payload, matches it to the existing DOM, and attaches event handlers to the Client Components. Server Components are never hydrated; there's no code for them in the browser.

After that first load, navigation never fetches HTML again. A <Link> that scrolls into view prefetches part of its route's RSC payload; a click asks the server only for the segments that change; React merges the new payload into the tree, keeps the layouts mounted, and updates the URL. A prerendered route (○) skips steps 1–2 on request: the build already did them and saved the HTML and the payload in .next. A ◐ route sends its saved shell and resumes rendering only the holes.

Browser Next.js server .next (build) FIRST LOAD GET /about (document) proxy.ts, if it matches match: layout → about/page prerendered? ○ whole page · ◐ shell + state Server Components → RSC payload Client Components → HTML (only the parts not prerendered) streamed HTML + inline payload self.__next_f.push(…) 1. paint the HTML 2. load the JS chunks 3. hydrate Client Components from payload CLIENT NAVIGATION Link visible: prefetch ?_rsc App Shell (static parts, fallbacks) click: ?_rsc + router state render changed segments text/x-component payload merge: layouts stay, no reload
First load: HTML plus the RSC payload, then hydration. After that, navigation exchanges only RSC payloads.

The example app makes each step observable: a counter in the root layout (below), a counter on the home page, a component that reports whether it has hydrated, and an /about page whose time is rendered per request inside <Suspense>. The walkthrough is what I saw with next start, curl and headless Chrome.

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

export default function RootLayout({ children }: LayoutProps<"/">) {
  console.log("server: rendering the root layout");
  return (
    <html lang="en">
      <body>
        <nav>
          <Link href="/">Home</Link> · <Link href="/about">About</Link> ·{" "}
          <a href="/about">About (plain a tag)</a>
        </nav>
        <Counter label="Layout counter" />
        {children}
      </body>
    </html>
  );
}
What’s happening
  1. Build. / has no request data, so next build ran the root layout and the home page and saved /'s HTML and RSC payload (○). The layout's console.log printed during the build and not on any request to /. /about got a static shell (layout, <h1>, "Loading the time…") with one hole for <ServerTime>, which calls connection() to say "only at request time" (◐).
  2. First request for /. The server sent the saved file: 6,186 bytes, x-nextjs-cache: HIT. In that HTML the counters read 0 and the status reads "server HTML, not hydrated yet": the Client Components were rendered on the server with their initial state. Six inline self.__next_f.push(…) scripts carried the RSC payload, including references like 5:I[91428,["$2","$4"],"Counter"].
  3. Hydration. In headless Chrome, a moment after load, the status read "hydrated in the browser": React ran in the browser, attached to the existing DOM using the inline payload (no extra request for it), and then ran the useEffect. The counters became clickable: three clicks on the layout counter and one on the home counter gave Layout counter: 3 and Home page counter: 1.
  4. Prefetch. As soon as the page loaded, with the links visible, the router made three fetches with rsc: 1 and next-router-prefetch: 1: the route trees of / and /about (115 and 166 bytes), and the /about/__PAGE__ segment (3,343 bytes) that contains the page's static part and the "Loading the time…" fallback, but not the time.
  5. Click on the <Link> to /about. One fetch: /about?_rsc=… with rsc: 1 and a next-router-state-tree header (what the browser already has), answered with 200 text/x-component (3,127 bytes) that contained the time. The server log printed server: rendering <ServerTime> for /about, and not the root layout line. On screen: the time, Layout counter: 3 unchanged, and a window.__marker I'd set before the click was still there, so the document never reloaded.
  6. Click on the plain <a href="/about">. A real document request: the marker was gone, Layout counter: 0, and the server log printed the root layout line along with <ServerTime>. Same URL, same server, but without <Link> you lose the client router: full HTML, full hydration, all state reset.
AdvancedCounters, pages and the request-flow route table

The rest of the app

The two Client Components, the two pages, and the route table:

components/counter.tsx
"use client";

import { useState } from "react";

export function Counter({ label }: { label: string }) {
  const [count, setCount] = useState(0);
  return (
    <button data-counter={label} onClick={() => setCount((c) => c + 1)}>
      {label}: {count}
    </button>
  );
}
components/hydration-status.tsx
"use client";

import { useEffect, useState } from "react";

export function HydrationStatus() {
  const [status, setStatus] = useState("server HTML, not hydrated yet");
  useEffect(() => setStatus("hydrated in the browser"), []);
  return <p id="status">{status}</p>;
}
app/page.tsx
import { Counter } from "@/components/counter";
import { HydrationStatus } from "@/components/hydration-status";

export default function Home() {
  return (
    <main>
      <h1>Home</h1>
      <HydrationStatus />
      <Counter label="Home page counter" />
    </main>
  );
}
app/about/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";

async function ServerTime() {
  await connection(); // wait for a real request: this part is never prerendered
  console.log("server: rendering <ServerTime> for /about");
  return <p id="time">Rendered on the server at {new Date().toISOString()}</p>;
}

export default function About() {
  return (
    <main>
      <h1>About</h1>
      <Suspense fallback={<p>Loading the time…</p>}>
        <ServerTime />
      </Suspense>
    </main>
  );
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
└ ◐ /about


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedThe RSC payload, as the browser received it

The start of the navigation response for /about (captured in the browser by wrapping fetch), cut to its first rows:

GET /about?_rsc=… with rsc: 1 → 200 text/x-component
4:"$Sreact.fragment"
5:"$Sreact.suspense"
7:"/_next/static/chunks/1gv5r4eninccq.js"
8:I[33960,["$7"],"OutletBoundary"]
…
3:[["children",{"s":"__PAGE__","h":49314,"d":{"r":["$","$4","c",{"children":[["$","main",null,{"children":[["$","h1",null,{"children":"About"}],["$","$5",null,{"fallback":["$","p",null,{"children":"Loading the time…"}],"children":"$L6"}]]}],…
2:[["children",{"s":"about","h":49250,"d":{"r":null,"p":true,"v":null},"c":"$Q3"}]]

How to read it:

  • Each line is a row: an id, a colon, then JSON. Rows can arrive in any order and refer to each other: $L6 is "a lazy reference to row 6", filled in when that row streams in (row 6 is <ServerTime>'s output, which came later in the response).
  • ["$","h1",null,{"children":"About"}] is a React element: type, key, props. I[33960,["$7"],"OutletBoundary"] is a client reference: a module id, the chunks to load, the export name.
  • The response is organised by segment: "s":"__PAGE__" is the page, "s":"about" the about folder. The root layout isn't in it at all: the browser already had it, so only the changed segments travelled. That's why its counter kept its state.
  • The format is an internal React and Next.js detail that changes between versions; you'll never parse it. What's worth knowing is what it contains (rendered output, client references, props) and what it doesn't (your Server Components' code). The RSC payload topic goes deeper.
AdvancedGoing back keeps the page's state (Activity)

After the <Link> to /about, the home page's elements were still in the DOM, hidden: checkVisibility() was false for "Home", the status line and Home page counter: 1. Clicking the <Link> back to / made no request at all, and the home page reappeared with Home page counter: 1: its state had survived the round trip.

That's new with Cache Components: instead of unmounting the page you leave, Next.js hides it with React's <Activity> (display: none), so its React state and DOM state (form drafts, scroll position, open <details>) are kept. The docs say up to 3 routes are preserved; older ones are evicted and render fresh. Before Cache Components (and in Next.js 13–15), leaving a page unmounted it, and a page counter would have been back at 0. If a piece of state should reset when the user leaves (an open dropdown), reset it in a useLayoutEffect cleanup, as the "Preserving UI state" guide shows.

Link or plain link?

In this app you click "Layout counter" three times, then follow a link to /about. What does the layout counter show afterwards if the link is <Link href="/about">, and if it is <a href="/about">?

Show answer

3 with <Link>, 0 with <a>. I checked both in headless Chrome.

What’s happening
  1. Both start from the same state: the document was hydrated, the counter's useState holds 3, and it lives in the root layout's Client Component.
  2. <Link> intercepts the click. The client router fetches only the RSC payload for the segments that change (here about and its page) and React applies it inside the existing tree. The root layout is part of both routes, so React keeps that subtree mounted, and the counter's state stays 3.
  3. A plain <a> isn't intercepted: the browser throws the page away and requests a new document. The server sends HTML, the browser hydrates from scratch, and every Client Component starts from its initial state: 0. A global marker on window was gone too.
  4. That's the reason to use <Link> (or router.push) for in-app navigation: it keeps layouts mounted, uses prefetched data, and only transfers what changed. Plain <a> tags are for leaving the app, or for when a full reload is what you want.
AdvancedGotcha: hydration mismatches

#Next.js versions that changed the rules

Added

Next.js has changed its defaults more than once, and interviewers often learned it at one of those points. The big shifts: 13 introduced the App Router and Server Components; 14 made Server Actions stable; 15 made request APIs asynchronous and stopped caching fetch by default; 16 made Turbopack the default, renamed middleware.ts to proxy.ts, and introduced Cache Components, which turn caching from "on unless you opt out" (13–14) and "off unless you opt in" (15) into "explicit, per function or component, with a static shell".

The mental model: every major version moved the defaults towards "explicit". 13–14 cached aggressively and implicitly; 15 removed the implicit caching; 16 replaced route-level switches with 'use cache' on the exact code you want cached. When someone describes Next.js behaviour, the first question is "which version?".

2023 2024 2025 2026 13.0 · Oct 2022 App Router (beta) Server Components 13.4 · May 2023 App Router stable 14.0 · Oct 2023 Server Actions stable next export removed 15.0 · Oct 2024 async params, cookies() fetch not cached by default 15.5 · Aug 2025 next typegen Node.js middleware stable 16.0 · Oct 2025 Turbopack default, proxy.ts Cache Components 16.3 · Aug 2026 Partial Prefetching error.tsx retry() 16.4 · Oct 2026 this site: 16.4.3
Release dates are the npm publish dates of each version. The details are in the table below.

The change interviewers hit most often is async request APIs. Here is the Next.js 15/16 way of reading a dynamic segment, with a proxy.ts (the 16 name) redirecting an old URL to it:

app/blog/[slug]/page.tsx
export function generateStaticParams() {
  return [{ slug: "hello" }];
}

export default async function Post(props: PageProps<"/blog/[slug]">) {
  const { slug } = await props.params; // a Promise since Next.js 15
  return <h1>Post: {slug}</h1>;
}
What’s happening
  1. props.params is a Promise<{ slug: string }>. Next.js 15 made it a Promise (along with searchParams, cookies(), headers() and draftMode()) so that a route only waits for request data at the exact point it reads it; everything before that can be prerendered or streamed.
  2. PageProps<"/blog/[slug]"> is a global type helper generated from your routes (by next dev, next build or next typegen, 15.5+): it knows this route has a slug, so a typo in the route literal or the param name is a type error.
  3. generateStaticParams lists hello, so /blog/hello was prerendered in full (○ in the route table below). Any other slug goes through the fallback row ◐ /blog/[slug]. On next start, the first request for /blog/other was rendered on demand (x-nextjs-postponed: 1, no-store); the second, a second later, came back x-nextjs-cache: HIT with s-maxage=31536000 (a second request sent immediately after the first said MISS, with the same s-maxage): the page had been rendered once and stored, which is ISR with Cache Components.
  4. proxy.ts (below) exports proxy and a matcher; the build lists it as ƒ Proxy (Middleware). It's the same feature as middleware.ts, renamed in 16 "to clarify network boundary and routing focus", and in 16 it always runs on the Node.js runtime.
  5. The real version check comes next: the same page written the Next.js 14 way still builds on 16.4.3, but renders the wrong thing.
AdvancedThe proxy and the build output

proxy.ts sits at the project root (in src/ here, next to app/). /old-blog answered 307 Temporary Redirect with location: /blog/hello on next start.

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

// Next.js 16 name. In 12–15 this file was middleware.ts exporting middleware().
export function proxy(request: NextRequest) {
  if (request.nextUrl.pathname === "/old-blog") {
    return NextResponse.redirect(new URL("/blog/hello", request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/old-blog"] };
next build
Route (app)
┌ ○ /_not-found
└   /blog/[slug]
  ├ ◐ /blog/[slug]
  └ ○ /blog/hello


ƒ Proxy (Middleware)

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedOld code on Next.js 16.4.3: what actually happens

I built three pieces of pre-16 code in my sandbox with Next.js 16.4.3. They aren't part of a checked app on this page (one fails to build), so here is what each did.

A Next.js 13–14 page with synchronous params:

Next.js 14 style (not built on this page)
export default function Post({ params }: { params: { slug: string } }) {
  return <h1>Post: {params.slug}</h1>;
}

next build passed, type check included, because the props type is the one I wrote. In production the page rendered <h1>Post: </h1> for /blog/hello: params is a Promise, and a Promise has no slug property. Only next dev complained, in the terminal:

next dev, on GET /blog/hello
Error: Route "/blog/[slug]" used `params.slug`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis

In Next.js 15 the same code still worked (synchronous access was a temporary compatibility path with a dev warning); 16 removed it. Typing pages with PageProps<'/route'> prevents this, because the generated type says params is a Promise.

middleware.ts instead of proxy.ts still works in 16, with a warning at the top of next build:

next build
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.

  To migrate automatically, run:
  npx @next/codemod@canary middleware-to-proxy .

The upgrade guide gives the one reason to keep it for now: proxy runs only on Node.js, so an app that needs the Edge runtime keeps middleware.ts.

revalidateTag("posts") with one argument fails the type check in 16: src/app/actions.ts(6,3): error TS2554: Expected 2 arguments, but got 1. The second argument is now a cacheLife profile (revalidateTag("posts", "max")), or you use updateTag in a Server Action for read-your-own-writes.

AdvancedThe timeline in detail

Dates are npm publish dates (npm view next time). Changes are from the version-history tables and upgrade guides in the docs bundled with 16.4.3.

VersionDateWhat changed
9.3Mar 2020Pages Router data functions: getStaticProps, getStaticPaths, getServerSideProps (ISR became stable in 9.5)
12.0Oct 2021Middleware (beta; stable in 12.2), on the Edge runtime
13.0Oct 2022App Router (beta) with Server Components, nested layouts, loading/error/not-found/template files, fetch caching; new next/image; next/link no longer needs a child <a>
13.2Feb 2023Route Handlers (route.ts), the Metadata API (metadata, generateMetadata)
13.4May 2023App Router stable
14.0Oct 2023Server Actions stable; minimum Node.js 18.17; next export removed (use output: 'export'); ImageResponse moved to next/og; unstable_cache
14.2Apr 2024staleTimes (experimental) to tune the client router cache
15.0Oct 2024Async request APIs (params, searchParams, cookies(), headers(), draftMode()), sync access still allowed with a warning; fetch and GET Route Handlers no longer cached by default; pages no longer reused from the client router cache on navigation (staleTimes.dynamic 30s → 0); App Router requires React 19 (a release candidate when 15.0 shipped; React 19.0 was published in Dec 2024); Turbopack for next dev stable; next.config.ts; 'use cache' experimental
15.1Dec 2024after() stable; forbidden() / unauthorized() (experimental)
15.2Feb 2025Streaming metadata; Node.js runtime for middleware (experimental)
15.3Apr 2025next build --turbopack (experimental); useLinkStatus
15.5Aug 2025next typegen; Node.js middleware stable; Turbopack builds beta
16.0Oct 2025Turbopack default for dev and build; middleware.ts → proxy.ts (Node.js only); Cache Components (cacheComponents: true replaces experimental.ppr, dynamicIO, useCache); 'use cache', cacheLife, cacheTag stable; revalidateTag needs a profile, new updateTag and refresh; sync request APIs removed; Node.js 20.9+, TypeScript 5.1+; parallel slots need a default.tsx for URLs they have no page for; next lint, AMP and runtime config (serverRuntimeConfig/publicRuntimeConfig) removed; Size / First Load JS gone from the build output; React Compiler option stable (off by default); next dev writes to .next/dev
16.1Dec 2025Turbopack file-system cache on by default in dev; next upgrade
16.2Mar 2026adapterPath stable (deployment adapters); unstable_retry for error boundaries
16.3Aug 2026partialPrefetching (prefetch one App Shell per route); file-system cache for builds; retry and catchError stable; instant and prefetch segment exports stable; next/root-params
16.4Oct 2026ensureStatic; prefetch() and navigation() replace their unstable_ canary names; next analyze export. 16.4.3 (23 Oct 2026) is the version this site is built with

create-next-app 16.4.3 turns on both cacheComponents and partialPrefetching for new apps (it's in this site's template, and the docs list Cache Components among the "recommended defaults"). The docs also say both will be on for everyone in the next major release, when the options are removed. I didn't verify in which 16.x release create-next-app started enabling them.

AdvancedWhat interviewers still assume (and the current answer)
They might sayTrue forNow (16.4)
"fetch is cached by default"13–14Not cached since 15. With Cache Components, nothing is cached unless you use 'use cache'
"params.slug"13–14 (15 with a warning)const { slug } = await params; sync access removed in 16
"cookies().get(...)"13–14(await cookies()).get(...)
"Use middleware.ts, it runs on the Edge"12–15proxy.ts on Node.js; middleware.ts deprecated
"Turn on PPR with experimental.ppr"14–15 canariescacheComponents: true; PPR is the model, ◐ in the build output
"export const dynamic = 'force-dynamic' / revalidate = 60"13–15, and 16 without Cache ComponentsWith Cache Components: removed; use Suspense for request data and cacheLife for lifetimes
"The router cache keeps pages for 30 seconds"13–14Pages aren't reused on navigation since 15 (layouts and loading states are); with Cache Components, visited pages stay mounted and hidden with <Activity>
"Run next lint" / "next export"≤15 / ≤13ESLint directly / output: 'export'
"Turbopack is experimental"≤14Default for dev and build since 16 (--webpack to opt out)
"revalidateTag('posts')"≤15revalidateTag('posts', 'max'), or updateTag('posts') in a Server Action
"The build shows First Load JS per route"≤15Removed in 16
AdvancedGotcha: old tutorials can be silently wrong

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.