Server rendering all 4 written
renderToString, streaming, hydration and what a mismatch looks like, and the line between server code and client code.
30 renderToString, and the HTML React sends
On a page that is rendered on the server, the first thing the browser gets is
HTML, written before any JavaScript has run. That is what react-dom/server is for. It
has no DOM to update, so it does one thing: it takes a tree of elements and returns a string. This
lesson reads that string closely, because the rest of the part, streaming, hydration and the
server/client boundary, is about what happens to it next.
Two functions, two strings
renderToString is meant for HTML that the browser will later hand back to React to
take over, which is called hydration (lesson 32). To let React find its place again, it leaves
small markers behind: where two pieces of text sit side by side in one element, it puts an empty
comment between them. renderToStaticMarkup is for HTML that will never be hydrated,
such as an email, so it leaves the markers out. Same tree, same page to look at, slightly different
strings. Both escape the characters that would otherwise start a tag.
strings.jsx
import { renderToString, renderToStaticMarkup } from 'react-dom/server';
function Total() {
return <p>n={5}</p>;
}
const tree = (
<div id="x">
<Total />
<b>{'<hi>'}</b>
</div>
);
const full = renderToString(tree);
const plain = renderToStaticMarkup(tree);
console.log(full);
console.log(plain);
check('renderToString marks the join between two text nodes', full, '<div id="x"><p>n=<!-- -->5</p><b><hi></b></div>');
check('renderToStaticMarkup leaves the markers out', plain, '<div id="x"><p>n=5</p><b><hi></b></div>');
check('text is escaped, so it cannot start a tag', plain.includes('<hi>'), false);
What node printed (React 19.2.7, development build), when the build ran it
node strings.jsx
<div id="x"><p>n=<!-- -->5</p><b><hi></b></div> <div id="x"><p>n=5</p><b><hi></b></div> ok renderToString marks the join between two text nodes ok renderToStaticMarkup leaves the markers out ok text is escaped, so it cannot start a tag
The server renders once, with the starting state
A server render is one pass over the tree. State is whatever useState was given to
start with, because nothing has happened yet that could change it. Effects never run: they are for
synchronising with the browser, and there is no browser. The panel keeps a log that an effect
writes to, renders, and then looks at the log.
effects.jsx
import { useState, useEffect } from 'react';
import { renderToString } from 'react-dom/server';
const log = [];
function Counter() {
const [count] = useState(5);
useEffect(() => {
log.push('effect ran');
});
return <p>count {count}</p>;
}
const html = renderToString(<Counter />);
console.log(html);
console.log(log);
check('the starting state is what was rendered', html, '<p>count <!-- -->5</p>');
check('the effect did not run on the server', log, []);
What node printed (React 19.2.7, development build), when the build ran it
node effects.jsx
<p>count <!-- -->5</p> [] ok the starting state is what was rendered ok the effect did not run on the server
Ids that match on both sides
If the server and the browser each made up their own ids, the two would disagree and hydration
would fail. useId avoids that: it gives each call a name based on its position in the
tree, so the same tree gets the same names wherever it is rendered. Two copies of one component get
two different ids, in order.
ids.jsx
import { useId } from 'react';
import { renderToString } from 'react-dom/server';
function Field() {
const id = useId();
return <i id={id}></i>;
}
const html = renderToString(
<div>
<Field />
<Field />
</div>,
);
console.log(html);
check('the first id', html.includes('id="_R_1_"'), true);
check('the second id', html.includes('id="_R_2_"'), true);
check('the whole string, so the order is known', html, '<div><i id="_R_1_"></i><i id="_R_2_"></i></div>');
What node printed (React 19.2.7, development build), when the build ran it
node ids.jsx
<div><i id="_R_1_"></i><i id="_R_2_"></i></div> ok the first id ok the second id ok the whole string, so the order is known
A component that has to wait
Some components cannot render yet because they are waiting for data, which React expresses by
suspending (lesson 25). renderToString cannot wait: it returns a string, straight
away. It does not fail and it logs nothing. It writes the boundary's fallback instead, and leaves a
<template> that records why, so that the browser knows to render that part
itself. The next lesson shows the version that does wait. The panel suspends on a promise that
never settles, which is the worst case, and the text of the template is React's own.
waits.jsx
import { Suspense, use } from 'react';
import { renderToString } from 'react-dom/server';
const never = new Promise(() => {});
function Data() {
const value = use(never);
return <p>{value}</p>;
}
const html = renderToString(
<Suspense fallback={<p>loading</p>}>
<Data />
</Suspense>,
);
console.log(html);
check('the fallback was written', html.includes('<p>loading</p>'), true);
check('and a template says why', html.includes('<template data-msg='), true);
check('it is wrapped in the markers of a boundary that gave up', html.startsWith('<!--$!-->') && html.endsWith('<!--/$-->'), true);
What node printed (React 19.2.7, development build), when the build ran it
node waits.jsx
<!--$!--><template data-msg="Switched to client rendering because the server rendering aborted due to:
The server used "renderToString" which does not support Suspense. If you intended for this Suspense boundary to render the fallback content on the server consider throwing an Error somewhere within the Suspense boundary. If you intended to have the server wait for the suspended component please switch to "renderToPipeableStream" which supports Suspense on the server" data-cstck="
at Data (/app/waits.jsx:7:17)
at Suspense (<anonymous>)"></template><p>loading</p><!--/$-->
ok the fallback was written
ok and a template says why
ok it is wrapped in the markers of a boundary that gave up
The server's HTML is a snapshot. It has the starting state, no effects and no event handlers: a button in it looks right and does nothing until the browser attaches React to it. That step is the subject of lesson 32.
renderToStringreturns HTML with markers for hydration;renderToStaticMarkupreturns the same without them.- The server renders once, with initial state, and never runs effects.
useIdgives the same ids on the server and in the browser.- A suspended child under
renderToStringwrites the fallback and a template; to wait, stream (lesson 31).
31 Streaming: renderToPipeableStream and prerender
The last lesson ended with a limit: renderToString returns a
string, so a component that has to wait gets its fallback and nothing more. For a page that needs
data from somewhere slow, that is not good enough. React's other server functions return a
stream: the part of the page that is ready goes out first, and each part that was waiting
follows when it is ready, down the same response.
The shell first, the rest later
renderToPipeableStream(tree, options) returns an object whose pipe
method sends the output into a Node writable stream. You choose when to call it, with two
callbacks. onShellReady fires when everything outside the Suspense boundaries is
ready, the shell. onAllReady fires when everything is, boundaries included. Piping at
the first starts sending soonest; piping at the second sends a page with no fallbacks in it.
The panel uses a promise that settles after a short wait, so that the shell is ready first. The
sink is a Writable that collects what is written. React also sends a small script
after the content that moves it into place; the panel looks for the pieces it can name and does not
print the script.
stream.jsx
import { Suspense, use } from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import { Writable } from 'node:stream';
const wait = new Promise((resolve) => setTimeout(() => resolve('late'), 20));
function Late() {
return <p>{use(wait)}</p>;
}
const tree = (
<main>
<h1>top</h1>
<Suspense fallback={<p>wait</p>}>
<Late />
</Suspense>
</main>
);
function collect(pipeAt) {
return new Promise((resolve) => {
const chunks = [];
const events = [];
const sink = new Writable({
write(chunk, encoding, done) {
chunks.push(String(chunk));
done();
},
});
sink.on('finish', () => resolve({ html: chunks.join(''), events }));
const stream = renderToPipeableStream(tree, {
onShellReady() {
events.push('shell');
if (pipeAt === 'shell') stream.pipe(sink);
},
onAllReady() {
events.push('all');
if (pipeAt === 'all') stream.pipe(sink);
},
});
});
}
const early = await collect('shell');
const whole = await collect('all');
const start = early.html.slice(0, early.html.indexOf('</main>') + '</main>'.length);
console.log(start);
console.log('the late content follows in a hidden div:', early.html.includes('<div hidden id="S:0"><p>late</p></div>'));
console.log(whole.html);
console.log(early.events, whole.events);
check('piping at the shell sends the fallback first', start, '<main><h1>top</h1><!--$?--><template id="B:0"></template><p>wait</p><!--/$--></main>');
check('the fallback comes before the late content', early.html.indexOf('<p>wait</p>') < early.html.indexOf('<p>late</p>'), true);
check('the late content did arrive', early.html.includes('<div hidden id="S:0"><p>late</p></div>'), true);
check('piping when everything is ready has no fallback', whole.html, '<main><h1>top</h1><!--$--><p>late</p><!--/$--></main>');
check('the shell is ready before everything is', early.events, ['shell', 'all']);
What node printed (React 19.2.7, development build), when the build ran it
node stream.jsx
<main><h1>top</h1><!--$?--><template id="B:0"></template><p>wait</p><!--/$--></main> the late content follows in a hidden div: true <main><h1>top</h1><!--$--><p>late</p><!--/$--></main> [ 'shell', 'all' ] [ 'shell', 'all' ] ok piping at the shell sends the fallback first ok the fallback comes before the late content ok the late content did arrive ok piping when everything is ready has no fallback ok the shell is ready before everything is
Waiting for everything: prerender
Sometimes there is nobody to stream to: the page is being written to a file, at build time.
prerender, from react-dom/static, is the version for that. It returns a
promise that resolves once all the Suspense content has loaded, and the HTML it gives back has no
fallbacks. It returns a web stream, which the panel reads to text.
prerender.jsx
import { Suspense, use } from 'react';
import { prerender } from 'react-dom/static';
const wait = new Promise((resolve) => setTimeout(() => resolve('late'), 20));
function Late() {
return <p>{use(wait)}</p>;
}
const { prelude } = await prerender(
<main>
<h1>top</h1>
<Suspense fallback={<p>wait</p>}>
<Late />
</Suspense>
</main>,
);
const html = await new Response(prelude).text();
console.log(html);
check('prerender waited for the content', html, '<main><h1>top</h1><!--$--><p>late</p><!--/$--></main>');
check('and no fallback is left', html.includes('wait'), false);
What node printed (React 19.2.7, development build), when the build ran it
node prerender.jsx
<main><h1>top</h1><!--$--><p>late</p><!--/$--></main> ok prerender waited for the content ok and no fallback is left
Over a real connection
This is how the stream reaches a browser. A Node HTTP server hands React its response object (a writable stream, like the sink above), and the browser receives the shell as soon as it is written. The panel runs the server inside the build, on the loopback address and on a port the operating system picks, then fetches from it in the same process. Nothing leaves the machine; the note under the panel says what was added to the command to allow it. The Node.js course covers streams and HTTP servers.
serve.jsx
import { createServer } from 'node:http';
import { renderToPipeableStream } from 'react-dom/server';
function Page() {
return (
<main>
<h1>served</h1>
</main>
);
}
const server = createServer((request, response) => {
const stream = renderToPipeableStream(<Page />, {
onShellReady() {
response.statusCode = 200;
response.setHeader('content-type', 'text/html');
stream.pipe(response);
},
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const reply = await fetch('http://127.0.0.1:' + port + '/');
const body = await reply.text();
await new Promise((resolve) => server.close(resolve));
console.log(reply.status, reply.headers.get('content-type'));
console.log(body);
check('the status', reply.status, 200);
check('the type', reply.headers.get('content-type'), 'text/html');
check('the body is the page React rendered', body, '<main><h1>served</h1></main>');
What node printed (React 19.2.7, development build), when the build ran it
node serve.jsx
200 text/html <main><h1>served</h1></main> ok the status ok the type ok the body is the page React rendered
This panel runs a server on 127.0.0.1 and talks to it from the same process. --allow-net and --disable-warning=ExperimentalWarning were added to the command, and the sandbox still refuses everything that is not loopback.
renderToPipeableStreamsends the shell when it is ready and the rest later;onShellReadyandonAllReadychoose when to pipe.- Piped at the shell, the fallback is in the output before the late content; piped at the end, there is no fallback.
prerenderwaits for all the content and gives HTML with no fallbacks.- A stream is a Node writable's job: an HTTP response is one.
32 Hydration, and what a mismatch looks like
The HTML from the server shows the page at once, and it is dead: no click
does anything, because no JavaScript has attached to it. Hydration is the step that fixes
that. In the browser, hydrateRoot walks the HTML that is already there, matches it
against what the components render, and attaches the event handlers, keeping the nodes that the
server made. The panels here do the whole trip in one place: render to a string, put the string in
the page, hydrate it.
The same nodes, now alive
The panel remembers the first node in the stage before it hydrates, and compares it with the first node afterwards. If React had thrown the HTML away and started again, it would be a new node. Then it clicks.
hydrate.jsx
import { useState, act } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { renderToString } from 'react-dom/server';
function Clicker() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{'clicked ' + count}</button>;
}
stage.innerHTML = renderToString(<Clicker />);
const before = stage.firstChild;
console.log(stage.innerHTML);
let root;
await act(async () => {
root = hydrateRoot(stage, <Clicker />);
});
const after = stage.firstChild;
await act(async () => after.click());
console.log(stage.innerHTML);
check('the server HTML was a button', before.tagName, 'BUTTON');
check('hydration kept the same node', after === before, true);
check('and the click now works', stage.textContent, 'clicked 1');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node hydrate.jsx
<button>clicked 0</button> <button>clicked 1</button> ok the server HTML was a button ok hydration kept the same node ok and the click now works
Reusing the node is the point: nothing the server made is thrown away, so the page the browser painted from the HTML is the page that becomes interactive.
When the two sides disagree
Hydration assumes the server and the client render the same thing. If they do not, React cannot match the HTML to the tree. It does not stop: it reports the difference and renders the client's version over the top. In development the report is a console error with a diff, and the panel declares it, so the build can tell it from a surprise.
mismatch.jsx
import { act } from 'react';
import { hydrateRoot } from 'react-dom/client';
stage.innerHTML = '<h1>hi server</h1>';
let root;
await act(async () => {
root = hydrateRoot(stage, <h1>hi client</h1>);
});
console.log(stage.innerHTML);
check('the client text won', stage.innerHTML, '<h1>hi client</h1>');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node mismatch.jsx
Error: Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
https://react.dev/link/hydration-mismatch
<h1>
+ hi client
- hi server
at throwOnHydrationMismatch (/app/node_modules/react-dom/cjs/react-dom-client.development.js:5238:11)
at prepareToHydrateHostInstance (/app/node_modules/react-dom/cjs/react-dom-client.development.js:5334:21)
at completeWork (/app/node_modules/react-dom/cjs/react-dom-client.development.js:12553:15)
at runWithFiberInDEV (/app/node_modules/react-dom/cjs/react-dom-client.development.js:874:13)
at completeUnitOfWork (/app/node_modules/react-dom/cjs/react-dom-client.development.js:17777:19)
at performUnitOfWork (/app/node_modules/react-dom/cjs/react-dom-client.development.js:17658:11)
at workLoopSync (/app/node_modules/react-dom/cjs/react-dom-client.development.js:17469:41)
at renderRootSync (/app/node_modules/react-dom/cjs/react-dom-client.development.js:17450:11)
at performWorkOnRoot (/app/node_modules/react-dom/cjs/react-dom-client.development.js:16504:11)
at performWorkOnRootViaSchedulerTask (/app/node_modules/react-dom/cjs/react-dom-client.development.js:18957:7)
<h1>hi client</h1>
ok the client text won
The message is long on purpose: it lists the usual causes (a date or a random number computed
in render, a browser-only check such as typeof window, HTML nested in a way the
browser rewrites) and prints a diff of what the client expected against what the server sent.
The page ended up showing the client's text, so a mismatch is not a crash. React discarded the
server's version of that tree and built it again on the client.
Collecting the mismatch yourself
hydrateRoot takes an option, onRecoverableError, called for errors
React recovers from, which a mismatch is. With a callback of your own, React does not write the
console error: you decide what to do with it, such as sending it to a log. The panel collects it.
recoverable.jsx
import { act } from 'react';
import { hydrateRoot } from 'react-dom/client';
stage.innerHTML = '<h1>hi server</h1>';
const caught = [];
let root;
await act(async () => {
root = hydrateRoot(stage, <h1>hi client</h1>, {
onRecoverableError(error) {
caught.push(error.message.split('\n')[0]);
},
});
});
console.log('errors collected:', caught.length);
check('one error was passed to the callback', caught.length, 1);
check('it says what happened', caught[0], "Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:");
check('and the client text still won', stage.innerHTML, '<h1>hi client</h1>');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node recoverable.jsx
errors collected: 1 ok one error was passed to the callback ok it says what happened ok and the client text still won
Text that is meant to differ
Some things are different on purpose. A "rendered at" time cannot be the same on the server and
in the browser. For a single element whose text you know will differ,
suppressHydrationWarning turns the warning off. It goes one level deep and does not
fix anything: the server's text stays in the page until something else changes it.
suppress.jsx
import { act } from 'react';
import { hydrateRoot } from 'react-dom/client';
stage.innerHTML = '<p>server time</p>';
let root;
await act(async () => {
root = hydrateRoot(stage, <p suppressHydrationWarning>client time</p>);
});
console.log(stage.innerHTML);
check('no warning, and the server text is still there', stage.innerHTML, '<p>server time</p>');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node suppress.jsx
<p>server time</p> ok no warning, and the server text is still there
The better habit is to make the first client render match, then change it. State set from an effect does exactly that: the first render uses the same value as the server, so hydration is satisfied, and the effect, which only runs in the browser, then updates it.
effect.jsx
import { useState, useEffect, act } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { renderToString } from 'react-dom/server';
function Where() {
const [where, setWhere] = useState('server');
useEffect(() => {
setWhere('client');
}, []);
return <p>{'rendered on the ' + where}</p>;
}
stage.innerHTML = renderToString(<Where />);
const seen = [stage.textContent];
let root;
await act(async () => {
root = hydrateRoot(stage, <Where />);
});
seen.push(stage.textContent);
console.log(seen);
check('the server said server, then the effect said client', seen, ['rendered on the server', 'rendered on the client']);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node effect.jsx
[ 'rendered on the server', 'rendered on the client' ] ok the server said server, then the effect said client
Rendering must not depend on where it runs. Anything that can differ, the time, a random number, the width of the window, belongs in an effect or in an event handler, never in what a component returns while rendering.
hydrateRoot(container, tree)attaches React to server HTML and keeps its nodes.- A mismatch is reported and recovered from: the client's version replaces the server's.
onRecoverableErrorreceives what React recovered from; without it, React logs it.suppressHydrationWarningsilences one element's text mismatch and fixes nothing.- Make the first client render match the server, and change things in an effect.
33 Server code and client code: the react-server build and server-only
So far every component has run in a place where it could do anything: hold state, run an effect, answer a click. A server component is different. It runs once, on the server, to produce output, so a hook that only makes sense in a browser has nothing to attach to. Frameworks enforce that, and the enforcement is not magic. It is two ordinary things: a second build of React with the client parts removed, and a package that throws if it is loaded in the wrong place.
A build of React with no client hooks
A package can say, in its exports field, that a different file should be loaded
when node is started with a given condition. React uses this: with
--conditions=react-server, import 'react' gives a smaller module. The
version is the same, and the hooks that need a client are gone. The note under each of these
panels shows the flag in the command.
condition.jsx
import * as React from 'react';
console.log(React.version);
console.log(typeof React.useState, typeof React.useEffect, typeof React.createElement);
check('it is the same React', React.version, '19.2.7');
check('useState is not there', typeof React.useState, 'undefined');
check('useEffect is not there either', typeof React.useEffect, 'undefined');
check('but elements can still be made', typeof React.createElement, 'function');
What node printed (React 19.2.7, development build), when the build ran it
node --conditions=react-server condition.jsx
19.2.7 undefined undefined function ok it is the same React ok useState is not there ok useEffect is not there either ok but elements can still be made
What a framework does with that
A component written for the browser, loaded into the server build, fails at the first line that
names a missing hook. The failure is not a React error. It is the module system, saying that the
thing the file asked react for is not there. The entry below imports a
Counter that uses useState; the build stops while loading it, before
anything renders.
Counter.jsx
import { useState } from 'react';
export function Counter() {
const [count] = useState(0);
return <p>{count}</p>;
}
server.jsx
import { Counter } from './Counter.jsx';
import { renderToString } from 'react-dom/server';
console.log(renderToString(<Counter />));
What node printed as it died (React 19.2.7, development build), when the build ran it
node --conditions=react-server server.jsx
/app/Counter.jsx:1
import { useState } from 'react';
^
SyntaxError: The requested module 'react' does not provide an export named 'useState'
at #asyncInstantiate (node:internal/modules/esm/module_job:463:21)
at async ModuleJob.run (node:internal/modules/esm/module_job:561:5)
at async node:internal/modules/esm/loader:650:26
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v26.7.0
The usual fix is to move the stateful part into a
client component, a file that begins with the directive 'use client', and to
render it from the server component.
Code that must stay on the server
The other half is the opposite mistake: server-only code, such as something that reads a secret
or a database, ending up in a file the browser will download. The server-only package
is a guard. It has two entry points: an empty file for the react-server condition, and
a file that throws for everything else. A module that imports it can only be loaded where it is
safe.
secret.js
import 'server-only';
export const secret = 'only on the server';
client.mjs
import { secret } from './secret.js';
console.log(secret);
What node printed as it died (React 19.2.7, development build), when the build ran it
node client.mjs
/app/node_modules/server-only/index.js:1
throw new Error(
^
Error: This module cannot be imported from a Client Component module. It should only be used from a Server Component.
at Object.<anonymous> (/app/node_modules/server-only/index.js:1:7)
at Module._compile (node:internal/modules/cjs/loader:1934:14)
at Object..js (node:internal/modules/cjs/loader:2074:10)
at Module.load (node:internal/modules/cjs/loader:1656:32)
at Module._load (node:internal/modules/cjs/loader:1448:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at loadCJSModuleWithModuleLoad (node:internal/modules/esm/translators:373:15)
at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:245:9)
at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
at async node:internal/modules/esm/loader:650:26
Node.js v26.7.0
With the condition, which is how a server component is loaded, the same two files work:
secret.js
import 'server-only';
export const secret = 'only on the server';
server.mjs
import { secret } from './secret.js';
console.log(secret);
check('the guard let the server through', secret, 'only on the server');
What node printed (React 19.2.7, development build), when the build ran it
node --conditions=react-server server.mjs
only on the server ok the guard let the server through
The directive is only a string
'use client' marks the line where the server stops and the browser begins. To Node
it is a string statement and does nothing: the file loads and its component renders. The meaning
is given by the tool that builds the app, which cuts the program in two at every such file. The
panel loads one in plain Node.
Widget.jsx
'use client';
export function Widget() {
return <p>client widget</p>;
}
load.jsx
import { Widget } from './Widget.jsx';
import { renderToString } from 'react-dom/server';
const html = renderToString(<Widget />);
console.log(html);
check('the file loaded', typeof Widget, 'function');
check('and rendered, directive and all', html, '<p>client widget</p>');
What node printed (React 19.2.7, development build), when the build ran it
node load.jsx
<p>client widget</p> ok the file loaded ok and rendered, directive and all
What these panels cannot show. A real server component sends its result to the browser in a format of its own, and a framework serialises what a server component passes to a client component. Passing a function across that line is an error, and it is a good one to meet. React 19.2.7 on its own does not include that serialiser, so no panel here can make it happen, and this course does not pretend to. It belongs to the Next.js course. That course will also have to say that Next's App Router runs a canary build of React bundled inside Next, not the 19.2.7 used in this course, so what it prints is not guaranteed to match a panel here.
- With
--conditions=react-server, React has the same version and no client hooks. - A client hook in a server component is a module error: the export is not there.
import 'server-only'throws anywhere but the server, and loads empty there.'use client'is a string to Node; the app's build tool gives it meaning.