Here's a test. Take this component:
async function PostList() {
const posts = await db.posts.findMany()
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}Answer four questions without looking anything up:
- 1.
Does that database query run when you deploy, or when a user visits?
- 2.
Does it produce HTML, or something else?
- 3.
If a user is already on your site and clicks a link to this page, does it produce the same thing?
- 4.
How many times does the
<li>get created?
Be honest. Or you won't learn.
I could name every piece of this system and still not answer question 3 with confidence. Knowing the parts isn't the same as knowing the schedule. This post is the schedule.
Three artifacts
Everything the system produces is one of three things. Get these straight and the rest follows.
HTML. Text the browser can paint without running any of your JavaScript. Final. Once it exists, the information about which component made it is gone.
The RSC payload. A text description of a component tree — what elements exist, what props they have, and where the client components go. It's data about a tree, not a rendered tree. React calls the format Flight. It looks like this:
1:I["./components/LikeButton.js",["chunks/main.js"],"default"]
2:["$","article",null,{"children":"$1"}]You will never write one and you will never parse one — you just need to recognise it in the Network tab when it shows up as text/x-component.
DOM mutations. What React does in the browser once it's running.
Two relationships between them, and you need both.
First: the payload comes first, and HTML is generated from it. Not the other way around, not in parallel. Server Components never produce HTML directly. They produce the payload, and a second renderer turns the payload into HTML.
Second: HTML is a lossy copy. Turning a payload into HTML throws information away permanently. <article><ul><li>First post</li></ul></article> doesn't record which component produced the <article>, where the boundaries between components are, or which props anything received. The payload has all of that; the HTML has none of it.
Those two facts together explain something that otherwise looks redundant. When you load a page fresh, you receive both artifacts. That seems like waste — if HTML is made from the payload, why send the source as well?
Because they do different jobs:
HTML can be painted without running any JavaScript. It's what you see before React exists.
The payload is the only copy that still describes the tree. React needs it to know what the tree is, so it can reconcile against it later.
Ship only HTML and React has a picture of a tree with no idea what tree it's a picture of. It couldn't hydrate, and it couldn't diff anything against it on the first navigation. HTML is what the page looks like. The payload is what the page is.
The same file runs at different times
This is the thing nobody says plainly.
PostList.tsx is not "a build-time component" or "a request-time component." It's a function. It runs whenever the framework decides to run it, and that decision is made per route, not per file. The exact same code can execute at build time on Monday and on every request on Tuesday, because you changed one line somewhere else.
So "when does this run" is never a property of the component. Stop looking for it there.
Then what decides? In Next.js 16, the default is request time — every route runs fresh unless you explicitly cache it with use cache. That's the whole answer for now. The details of how caching moves work from request time to build time are the last post in this series, deliberately, because you can't reason about the optimization until you can reason about the thing being optimized.Moment 1: next build
For a route Next decides to prerender:
First, generateStaticParams runs — if the route has dynamic segments. /[locale]/property/[id] can't be prerendered until something says which locales and which ids exist. This function returns that list, and it decides how many times everything below happens. No dynamic segments, no list needed, and this step doesn't exist.
When you run a build on Next 16, this is what you can expect:
ƒ Proxy (Middleware)
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demandThis is the whole difference between ○ (Static) and ● (SSG) in your build output.
Both are prerendered at build time, both write files to disk, both execute nothing on the server at request time. ● just means the route had holes in its path and you enumerated them, so it produced N outputs instead of one. Same mechanism, different arity. The "SSG" label is a leftover from the getStaticProps era.
Two things that trip people up here. The symbol describes the full route path, not the page you're looking at — /login in an app with a root [locale] segment is really /[locale]/login, so it's ● no matter how static that page feels. In an app with a dynamic root segment, nothing is ever ○.
And you may well have never written generateStaticParams while seeing ● all over your build output. It usually arrives as boilerplate: i18n libraries put it in app/[locale]/layout.tsx during setup and you never look at it again.
Your Server Components execute. In Node, on your machine or your CI runner. await db.posts.findMany() fires now. Whatever the database says at 2pm on deploy day is what gets baked in. Once per param combination, if there were params.
They produce a payload. Not HTML. The payload.
And where a client component was, the payload has a hole with an address on it.
This is the part that's easy to miss. When the server render hits <LikeButton /> and sees "use client", it does not execute the component. It writes down where to find it:
1:I["./components/LikeButton.js",["chunks/main.js"],"default"]A module path, the chunk that contains it, and an export name. That's the whole entry. No code, no output, no props resolved — an address, and a slot in the tree where whatever lives at that address belongs.
Something else has to turn that address into a real component, and that something is a manifest — a lookup table the bundler wrote at build time, mapping module IDs to the chunks that hold them. For now the only thing that matters is that the payload is full of addresses, and resolving them is a separate job from producing them.
Then a second renderer runs. It reads that payload, and every time it hits one of those addresses, it looks the module up in the server manifest, loads the real component, and executes its render function to produce markup.
Read that again, because it's the most counterintuitive fact in this entire model:
Your Client Components run on the server.
"use client" does not mean "client only." It means also client. The directive marks a boundary, not a location — and everything past that boundary still renders on the server first.
Don't confuse it with "state and effect" running on the server.
You have already seen the proof of this, probably many times:
ReferenceError: window is not definedin a"use client"component. That's Node telling you it just tried to execute your component.Hydration mismatch errors. A mismatch means two renders disagreed. If client components only ran in the browser, there'd be nothing for the browser to disagree with.
Pages Router. Every component in a Next.js app has always rendered on the server. That didn't change with App Router. What changed is that some components got a directive and an unfortunate name.
Check it yourself in thirty seconds. Open a "use client" page in a fresh tab — type the URL, don't click a link to it — and hit Ctrl+U for View Source. Search for any text your client component renders.
It's there. In the raw HTML document, before a single line of your JavaScript has run.
Use View Source, not the Elements panel. Elements shows you the live DOM after hydration, which proves nothing — of course the component rendered, you're looking at the result. View Source shows the bytes the server actually sent.
And don't use a console.log for this. It's an unreliable test: Fast Refresh re-runs your component in the browser only, so a log you just added won't appear in the terminal until the next full request. And if you reached the page by clicking a link, there was no SSR pass at all — see Moment 4. Both produce silence in your terminal for reasons that have nothing to do with whether the claim is true.The exception: a component imported through next/dynamic with ssr: false genuinely doesn't render on the server. Next emits a placeholder and the component first executes in the browser. That's opt-out behaviour, and it's the only way to get it.
What actually runs in that pass
Only the render function body. Specifically:
useStatereturns its initial value and nothing ever updates ituseEffectanduseLayoutEffectdo not run at allRefs are not attached
Event handlers are not attached —
onClickis a function, and functions can't be written into HTML
One pass, to produce markup. Then that component is finished until the browser picks it up.
The reason for the whole exercise is the obvious one: without it, every client component would be a blank hole in your HTML until JavaScript finished downloading. The SSR pass is what makes the page look complete before it becomes interactive — which is exactly the hydration gap the first post in this series described.
Two files get written to disk. An .html file and an .rsc file. Same route, same moment, two artifacts. Remember that both exist; it matters in a minute.
Moment 2: a request comes in
Two cases, and the difference is which artifacts already exist.
If the route was prerendered: Next reads the .html and .rsc files off disk and sends them. On the server, nothing executes — no component runs, no query fires, no rendering happens. That's the entire point of prerendering.
Say that precisely, because the imprecise version causes a real misunderstanding. Prerendering removes the server's request-time work. It removes nothing from the browser. Hydration still happens, your Client Components still run, and everything in the previous post about reconciliation still applies in full. A prerendered page and a dynamic page are identical from the moment the response lands.
If it wasn't: everything from Moment 1 happens now instead, per request, streaming.
Server Components execute, in Node, on your server
They produce a payload
The SSR pass consumes it and produces HTML
Both stream to the browser in one response
Which brings us to the thing that confuses everyone.
Why the first response contains both
Open View Source on a Next.js page. Scroll to the bottom. You'll find a pile of <script> tags pushing strings into an array.
That's the payload, inlined into the HTML document.
The browser gets both artifacts in the same response, and it needs both, for different reasons:
The HTML so it can paint immediately, before any JavaScript has downloaded
The payload so that when React does start up, it knows what the tree is supposed to be
Without the HTML you'd stare at a blank screen. Without the payload React would have to guess what tree the server had in mind, and it can't — HTML has thrown that information away.
I know this is a little repetitive, we covered it already, but it's important to get comfortable with the idea.
Moment 3: the browser wakes up
The HTML has painted. Nothing is interactive yet. Then:
JavaScript chunks download. Only client component code. Your Server Components' code was compiled into the server bundle under .next/server/, and it stays there — it isn't in anything the browser downloads, and it never will be.
React reads the inlined payload and reconstructs the tree in memory — holes, addresses and all.
The same addresses get resolved a second time, differently.
1:I["./components/LikeButton.js",["chunks/main.js"],"default"]That meant "load this out of .next/server/" on the server. In the browser it means "make sure chunks/main.js has been downloaded, then take its default export." Same address, same slot in the tree, two completely different lookups against two different manifests.
That's also how the browser knows which downloaded chunk belongs to which piece of the page. It isn't inferring it from the DOM — the payload told it, by address.
Client components execute again. Second time for these — once on the server, once here. React walks the existing DOM, matches it against what these render functions produce, and attaches event handlers.
That's hydration. React isn't creating DOM here; it's adopting DOM that's already on the page. When you get a hydration mismatch error, this is the moment it happens — the browser run produced something different from the server run.
Now onClick works.
The count
For one component on one page load, here's how many times each one actually runs.
A Server Component runs once. Ever.
RSC render, on the server → runs
SSR pass → doesn't run, because it already produced its output
Browser → never, its code isn't there
A Client Component runs twice before the page is even interactive.
RSC render, on the server → doesn't run, an address is emitted instead
SSR pass, on the server → runs, producing markup
Browser, during hydration → runs again, attaching handlers
And then as many more times after that as state changes require.
But React builds a tree of everything. Doesn't it need the code?
Reasonable objection, especially straight after the previous post. If React reconciles the whole tree in the browser, and Server Components are part of that tree, how does it manage without them?
The distinction is between elements and the code that produced them.
When PostList runs on the server, it returns elements — a <ul>, some <li>s. Those elements go into the payload. The browser reads the payload and puts them into its tree. Everything from the previous post applies to them without exception: they're reconciled, diffed, keyed, committed. React does not treat them as second-class.
What the browser doesn't have is the PostList function.
So the browser can do one of those things and not the other:
It can diff those elements against a newer version of them
It cannot produce a newer version, because it has no way to invoke the function
That single asymmetry explains most of the model. It's why a link click has to go back to the server — not because the browser is missing elements, but because the browser cannot compute new ones and a fresh payload is the only way to get them. And it's a better explanation of "Server Components can't have state" than the usual one: state means something re-runs when a value changes, and nothing in the browser is capable of re-running these.
The browser holds the output. The server holds the function. That's the deal.
Moment 4: the user clicks a link
Here's question 3, and here's where the model usually breaks.
The user is on /posts and clicks a <Link> to /posts/42. What happens?
On the server: the Server Components for /posts/42 execute. Database query fires. A payload is produced.
And then it stops. No SSR pass. No HTML. None is generated, because none is needed — there's already a live React app in the browser, and it doesn't want a document. It wants a tree description.
In the browser: React takes the new payload and reconciles it against the tree it's already got. Layouts that didn't change don't re-render. Client component state in the parts of the tree that survived is preserved. New client components in the new route execute for the first time.
So: same route, same components, different response depending on how you arrived. Type the URL and hit enter, and the server sends a document — HTML plus the payload inlined in it, whether those were built just now or read off disk. Click a link to that exact same page, and the server sends a payload and nothing else.
That asymmetry is the answer to question 3, and it's why "server-rendered" is a misleading description of a Next.js app. Most page views in a real session never produce a single byte of HTML.
Check it yourself. Open the Network tab, click a<Link>, and look for the request with?_rsc=in the query string. Content typetext/x-component. There is no document request. That's the whole thing, visible in five seconds.
Moment 5: interaction
The user clicks a button. State changes.
Only client component code runs. Only in the browser. No server involvement, no payload, no HTML. React re-renders the affected subtree and mutates the DOM.
This is ordinary React, and it's the only moment that is.
Moment 6: a mutation
The user submits a form backed by a Server Action.
A POST goes to your own server. Not a route — the action itself, addressed by an ID the bundler generated.
The function executes in Node. Database write happens.
The response is a payload. The action's return value, and — if you revalidated — a freshly rendered tree for the affected route.
Still no HTML. React reconciles the new tree in.
For now the only thing to hold onto: mutations return trees, not JSON.
The whole schedule
Seven moments. For each one: what runs, where it runs, and what comes out.
next build Server Components, then Client Components in the SSR pass. In Node. → HTML and a payload, written to disk.
First request, prerendered route Nothing runs. → The two files are read off disk and sent.
First request, dynamic route Server Components, then Client Components in the SSR pass. In Node. → HTML and a payload, streamed.
Hydration Client Components. In the browser. → Event handlers attached to DOM that's already on the page.
Link click Server Components only. In Node. → A payload. No HTML.
Interaction Client Components only. In the browser. → DOM mutations. Nothing leaves the machine.
Server Action The action itself, then Server Components if you revalidated. In Node. → A payload. Still no HTML.
Read the "produces" lines on their own and the shape of the whole system shows up: HTML is generated exactly twice — at build, or on a document request. Every other moment produces a payload or nothing at all.
Back to the four questions
- 1.
Build or request? Neither, inherently. Request time by default in Next 16, build time if you cache it. It's a property of the route's configuration, not of the component.
- 2.
HTML or something else? A payload, always and first. HTML is generated from the payload, and only when a browser needs a document.
- 3.
Same thing on a link click? No. Payload only. No HTML is produced at all.
- 4.
How many times does the
<li>get created? Once as a description on the server. Once as HTML text, if this was a document request. Once as a real DOM node in the browser. Three representations of one<li>, and only the last one exists in the page.
If this helped, or if I got something wrong — find me on LinkedIn or my site. I read everything.