How Next.js Works

Lesson 1 of 5

From URL to Rendered Page

"In Next.js, routes are rendered on the server by default. This often means the client has to wait for a server response before a new route can be shown." — Next.js Documentation, "Linking and Navigating"

Most people learn Next.js as a list of conventions. Put a page.tsx in a folder and you get a route; add layout.tsx and it wraps; write async and you can await a database. It works, which is the problem — it works for about eighteen months, and then something behaves in a way the conventions do not explain, and there is nothing underneath the conventions to reason from.

This volume starts underneath. Before any file convention, there is a sequence of things that happen between a URL arriving at a server and pixels appearing in a browser, and every feature in the rest of this book is a decision about one of those stages. Knowing the sequence turns "why is my page not updating" from a search-engine question into a question you can answer by elimination.

Two facts up front, because they are the ones most often carried in wrong from older versions or from React-without-a-framework. Rendering happens on the server by default — a component in the app directory runs on a machine you control, not in the browser, unless you say otherwise. And what the server sends on a navigation is not HTML; it is a serialised description of the React tree, which the browser already has the code to interpret. Those two together explain most of what feels strange about the App Router.

We will walk the stages once in prose, then build a miniature of each so you can watch a URL turn into a response with nothing hidden.

The big question

You click a link and the new page appears without a full reload, yet the components that produced it ran on a server. What crossed the network to make that possible — and why is it not HTML?

The five stages

Every request goes through the same sequence. The names matter less than the order, because the order is what tells you where a problem can and cannot be.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The order is the useful part. Proxy runs before the router, which is why it can rewrite a request to a different route entirely. Serialisation runs after your component finishes, which is the real reason a function cannot be handed to a client component — by that point everything has to survive being written down and sent.

And hydration is last, which is why hydration bugs are so unpleasant: the symptom appears at the final stage, and the cause is almost always in something that ran during the first.

A URL is matched against a tree, not a list

The app directory is not a list of routes; it is a tree of segments, and matching walks it. Here is that walk, small enough to read.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

Three of those four forms look similar and behave differently at exactly one boundary: whether they match when there is nothing left. [slug] needs one segment, [...slug] needs at least one, [[...slug]] is happy with none — which is why the optional form is the one you use for a docs root that is also a docs page.

Route groups are the odd one out because they affect the file tree and not the URL at all. app/(marketing)/pricing/page.tsx serves /pricing. That is how two sections of a site get different layouts without either of them appearing in an address.

Everyday analogy

A postal address and a building's internal structure are two different things, and confusing them is where routing intuition usually goes wrong. The address — 12 Marina Road, third floor, flat C — is a path, read left to right, each part narrowing the search. The building has floors, stairwells and a lobby, and some of that structure is not in the address at all: nobody writes "east stairwell" on an envelope, but the stairwell is how you actually get there and it is shared by every flat above it. A route group is the stairwell. It organises the building and appears nowhere on the letter. A layout is the lobby you pass through on the way to any flat above it, which is also why the lobby does not get rebuilt each time you visit a different flat — you walk through it, you do not reconstruct it. And a dynamic segment is the flat number: the address has a slot there, and which flat you land in depends on what was written in it.

Prerendered or dynamic — decided per render, not per file

The same page file can produce a response built at deploy time or one built for this request. Nothing in the filename says which.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The distinction is not static-versus-server-rendered in the old sense; it is whether the render depended on something only this request knows. params does not qualify, because the set of possible URLs can be enumerated ahead of time. searchParams does, because ?q= is unbounded.

The last line is the one that costs people a weekend. Reading a cookie in a root layout is a single line, and it makes every route beneath that layout dynamic, because the layout participates in all of their renders.

A little history

Next.js has changed its rendering story roughly every three years, and each change is legible as a response to the previous one's limits. The original 2016 release did server-side rendering per request. Next 9 (2019) added getStaticProps and getServerSideProps, which made the choice explicit but bound it to the whole page: a route was static or it was not. Next 9.5 added Incremental Static Regeneration so a static page could be refreshed without a redeploy — the first crack in the binary. The App Router (13, 2022) moved rendering to React Server Components and dropped the two data functions entirely, which is why so much older Next.js advice is not merely outdated but describes APIs that no longer exist. And Next 16 completes the arc: with Cache Components the boundary is per component, not per route, so a single page can have a prerendered shell and dynamic holes. Reading old tutorials is genuinely hazardous here — not because the ideas are wrong, but because the names moved. getServerSideProps has no App Router equivalent to look up; the answer is "just await it in the component", which is not a rename anyone would guess.

What actually crosses the network

Here is the fact that explains client-side navigation: the server does not send HTML on a navigation. It sends a serialised React tree.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The payload is a tree with holes. Server components are already resolved to their output; client components appear as a reference plus props, because their code is going to run in the browser and the server has no business pretending otherwise.

That shape is what makes client-side navigation possible. HTML would have to replace the document; a tree can be merged into the one already there, which is why a layout survives a navigation with its state intact, and why only the components that actually changed re-render.

The engineer's notebook

Things worth checking directly rather than believing. Look at the payload: open DevTools, Network, click a client-side navigation and find the request with ?_rsc= in it — the response is the serialised tree, and reading it once permanently fixes the mental model. Watch what JavaScript is actually fetched on that navigation; if a component you thought was server-only appears in the bundle, something above it has a "use client". Add console.log to a component and see where it prints — terminal means server, browser console means client, both means it rendered on the server and hydrated. That one trick answers more questions than any documentation page. Check whether a route is prerendered by running a production build and reading the route table it prints; the symbols beside each route tell you what the framework decided, and disagreeing with that table is the start of most caching investigations. Remember that next dev lies about caching by design — development skips most of it so your edits appear, so anything cache-related must be confirmed against next build && next start. And be suspicious of any Next.js answer older than 2023; the App Router removed the APIs most tutorials are built on.

First load and navigation are different journeys

The same page reaches a browser two different ways depending on how you got there, and the difference explains a class of bug where "it works on refresh".

TypeScript — Try it Yourself
Loading editor…
Type check + Console

A first load carries the whole apparatus: markup so the page is visible immediately, the payload so React can take over, and the runtime plus this route's client code. A navigation carries a fraction of it, because everything else is already in memory.

That asymmetry produces a recognisable bug report — it works when I refresh but not when I click through — and the cause is nearly always state or a side effect living at module scope, which a refresh re-creates and a navigation does not.

In the real world

The most common way a team gets the mental model wrong is by treating the App Router as "the same thing with different filenames". It is not, and the tell arrives about three weeks in, when somebody adds authentication. The instinct carried over from the Pages Router — or from any client-side React app — is to read the session in a top-level component and pass the user down. In the App Router that innocuous line usually lands in the root layout, calls cookies(), and quietly makes every route in the application dynamic, because the layout is part of every render. Nothing breaks. The build still succeeds. What changes is that the marketing pages which used to be served from a CDN in ten milliseconds are now rendered per request, the deploy's route table shows dynamic where it used to show static, and nobody looks at the route table. It usually surfaces weeks later as "the site feels slower since the auth work", by which point the change is buried in a large pull request. The habit that prevents it costs nothing: read the route table the production build prints, every time, and treat a route that moved from prerendered to dynamic as something to explain rather than something to accept.

Common Mistakes

  1. Assuming components run in the browser. In the app directory they run on the server by default. A console.log printing in your terminal rather than DevTools is not a bug.

  2. Reading old tutorials. getServerSideProps and getStaticProps do not exist in the App Router, and their replacement is "await it in the component", which no rename would have suggested.

  3. Thinking the file decides static or dynamic. The render decides, based on whether it read something only this request knows — cookies, headers, searchParams.

  4. Reading a cookie in the root layout. One line makes every route under it dynamic, because the layout participates in every one of those renders.

  5. Confusing params with searchParams. URLs can be enumerated ahead of time so params still allows prerendering; ?q= is unbounded and does not.

  6. Expecting [slug] to match an empty segment. It requires exactly one. [...slug] requires at least one. Only [[...slug]] matches nothing.

  7. Expecting a route group to appear in the URL. (marketing) organises files and contributes zero path segments — that is its entire purpose.

  8. Believing a navigation sends HTML. It sends a serialised React tree, which is why layouts keep their state and only changed components re-render.

  9. Putting state or side effects at module scope. A refresh re-evaluates the module; a client-side navigation does not, which is the "works on refresh" bug.

  10. Trusting next dev for caching behaviour. Development deliberately skips most caching so edits show up. Anything cache-related must be confirmed with next build && next start.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
What happens between a user requesting a page and seeing it, in a Next.js app?
Model answer — The request hits the server first. Proxy runs before anything else, so it can look at the URL, headers and cookies and redirect or rewrite. Then the router matches the URL against the app directory — folders are segments, so app/blog/[slug]/page.tsx matches /blog/hello and gives the component a slug param. Then the components render, and the important thing is that they render on the server by default, so a page component can await a database call directly rather than fetching from the browser. The output is serialised and sent, and on a first load that comes with server-rendered HTML so the user sees content immediately, plus the JavaScript for any client components. Finally React hydrates on the client, attaching event handlers to the markup that is already there. The stage that surprises people is that rendering is on the server by default — if I console.log in a page component it prints in my terminal, not the browser console, and that is normal rather than a bug.
Mid
A navigation does not send HTML. What does it send, and why does that matter?
Model answer — It sends the React Server Component payload — a serialised description of the rendered tree rather than markup. Server components appear already resolved to their output; client components appear as a reference to a chunk plus their serialised props, because their code runs in the browser and the server does not pretend to render it. That shape buys three things HTML could not. It can be merged into the tree already on the page, so a layout does not get rebuilt and anything stateful inside it survives the navigation. Only the parts that actually changed re-render. And it can arrive in pieces, which is what streaming a page means — the shell first, slower sections as they resolve. It also explains a rule people find arbitrary: props passed to a client component must be serialisable, so you cannot pass a function, because by the time the boundary is crossed the value has to survive being written down and sent over a network. Practically, I would open DevTools and look at the request with _rsc in the query string — reading one payload fixes the mental model faster than any explanation.
Senior
A page you expected to be static is being rendered per request. How do you find out why?
Model answer — First I would confirm it rather than assume it, by running a production build and reading the route table it prints — that table is the framework telling you what it decided, and development mode is not evidence because it deliberately skips caching. Then I would work out what made it dynamic, which is always the same class of cause: something in that render read data that only exists per request. In practice that is cookies(), headers(), searchParams, or draft mode. The subtlety is that it need not be in the page — layouts participate in every render beneath them, so a single cookies() call in a root layout makes the entire application dynamic while looking completely harmless in review. That is the most common real cause, and it usually arrives with authentication work. Note that params is not in that list: the set of URLs can be enumerated ahead of time, so a dynamic segment can still be prerendered per value. Once I have found it, the fix is usually to move the request-bound read down to the smallest component that needs it and let the rest of the tree stay prerendered — which in Next 16 is the whole point of the model, since the static/dynamic boundary is per component rather than per route. I would also add the route table to what we look at in review, because this regresses silently and nobody notices a page moving from static to dynamic until the site feels slower months later.
Company lens — how the same topic gets asked
micro1Concept: the five stages, and which of them run on the server.
GoogleDepth: the RSC payload's shape, and why it is not HTML.
MetaDesign: where the static/dynamic boundary should sit in a real app.
AmazonReality: one cookies() call in a root layout making a whole site dynamic.
Hands-on

Build the router's matcher. matchRoute must walk a segment tree and return the matched path plus its params — handling the four bracket forms that differ only in how they treat an empty remainder, and route groups which consume no URL segment at all. classifyRender must decide whether a render can be prerendered, which is the question the whole rendering model turns on. The checks that matter are the boundary ones: [slug] refusing an empty segment, [[...slug]] accepting one, and a group contributing nothing to the path. The checklist reports each requirement as PASS, FAIL or ERR; run it before writing anything and watch the lines flip.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

Summary

  • Five stages, in order: proxy, route match, render, serialise, hydrate. The order is what tells you where a problem can be — proxy runs before routing, serialisation after your component finishes, hydration last.

  • Components in app run on the server by default. A console.log appearing in the terminal is the system working correctly.

  • The app directory is a tree that a URL is matched against, not a list of routes.

  • The bracket forms differ at one boundary: [slug] needs exactly one segment, [...slug] at least one, [[...slug]] accepts none — and (group) contributes no URL segment at all.

  • Static or dynamic is decided by the render, not the filename — specifically by whether it read something only this request knows.

  • params does not force a dynamic render; searchParams does. URLs can be enumerated ahead of time; query strings cannot.

  • A cookies() call in a root layout makes every route beneath it dynamic, because the layout participates in all of those renders.

  • A navigation sends a serialised React tree, not HTML — which is why layouts keep their state and only changed components re-render.

  • Client components appear in the payload as a reference plus props, which is the real reason those props must be serialisable.

  • First load and navigation are different journeys. "Works on refresh but not on navigate" means something lives at module scope that only a refresh re-evaluates.

The tree we matched against in this chapter was handed to us as data. In the next chapter, The File-System Router, we build it — turning a directory listing into that routing table, working out how the framework decides which of two overlapping routes wins, what the private and parallel conventions do to the tree, and why a file named route.ts and one named page.tsx cannot live in the same folder.

Editorial reflection

What strikes me about the App Router is how much of its difficulty is vocabulary rather than concept. The underlying idea — render on the server, send a description rather than markup, let the client merge it — is not hard, and once you have watched a payload go past in DevTools it stops being mysterious. What is hard is that the words moved. getServerSideProps did not get renamed; it got deleted, and its replacement is so much simpler that people assume they have misunderstood. Middleware became Proxy in 16. Caching stopped being a route-level flag and became a directive you put on a function. Each change is defensible on its own and the cumulative effect is that a large fraction of the Next.js writing on the internet describes a framework that no longer exists — while being confident, well-ranked and superficially plausible. I think that is worth naming explicitly rather than treating as an inconvenience, because it changes how you should learn this particular framework: the documentation shipped in your own node_modules is the only source guaranteed to match the version you are running, and for anything to do with rendering or caching it is worth reading in preference to an article, however good the article looks. That is not usually true of technologies. It is emphatically true of this one.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.