Emmanuel Akinfulubi

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.

renderToString against renderToStaticMarkup

ran at build time · server (no DOM) · React dev

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>&lt;hi&gt;</b></div>');
check('renderToStaticMarkup leaves the markers out', plain, '<div id="x"><p>n=5</p><b>&lt;hi&gt;</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>&lt;hi&gt;</b></div>
<div id="x"><p>n=5</p><b>&lt;hi&gt;</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.

Initial state, and effects that do not run

ran at build time · server (no DOM) · React dev

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.

useId on the server

ran at build time · server (no DOM) · React dev

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.

renderToString cannot wait

ran at build time · server (no DOM) · React dev

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 &quot;renderToString&quot; 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 &quot;renderToPipeableStream&quot; which supports Suspense on the server" data-cstck="
    at Data (/app/waits.jsx:7:17)
    at Suspense (&lt;anonymous&gt;)"></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.

  • renderToString returns HTML with markers for hydration; renderToStaticMarkup returns the same without them.
  • The server renders once, with initial state, and never runs effects.
  • useId gives the same ids on the server and in the browser.
  • A suspended child under renderToString writes 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.

The shell first, then the rest

ran at build time · server (no DOM) · React dev

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 waits for everything

ran at build time · server (no DOM) · React dev

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.

A stream over HTTP, on this machine only

ran at build time · server (no DOM) · React dev

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.

  • renderToPipeableStream sends the shell when it is ready and the rest later; onShellReady and onAllReady choose 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.
  • prerender waits 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.

Hydrating server HTML

ran at build time · in happy-dom 20.11.6 · React dev

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.

A mismatch, as React reports it

warns, on purpose · in happy-dom 20.11.6 · React dev

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.

onRecoverableError

ran at build time · in happy-dom 20.11.6 · React dev

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.

suppressHydrationWarning

ran at build time · in happy-dom 20.11.6 · React dev

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.

Browser-only text through an effect

ran at build time · in happy-dom 20.11.6 · React dev

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.
  • onRecoverableError receives what React recovered from; without it, React logs it.
  • suppressHydrationWarning silences 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.

React with the react-server condition

ran at build time · server (no DOM) · React dev

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.

A client hook in a server component

dies with SyntaxError · server (no DOM) · React dev

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.

server-only, loaded without the condition

dies with Error · server (no DOM) · React dev

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:

server-only, loaded with the condition

ran at build time · server (no DOM) · React dev

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.

A file with the use client directive

ran at build time · server (no DOM) · React dev

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.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.