renderToString and hydrateRoot

The smallest complete server-rendered application is a component, a server, a browser entry and no framework. app.jsx exports an ordinary App that takes a products prop and renders <h1>Catalog ({products.length} items)</h1>, a <ul> of names and prices, and a button whose onClick increments a useState counter. renderToString turns it into HTML synchronously; the rest of the server is plumbing — serve the bundle, embed the data the render used (data.js exports the two-product array), and link the bundle.

server.jsx - a hand-rolled SSR server on node:httpJSX
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { renderToString } from 'react-dom/server';
import App from './app.jsx';
import { products } from './data.js';
createServer(async (req, res) => {
  if (req.url === '/client.js') {
    res.writeHead(200, { 'Content-Type': 'text/javascript' });
    return res.end(await readFile('./public/client.js'));
  }
  const html = renderToString(<App products={products} />);
  const data = JSON.stringify(products).replace(/</g, '\\u003c');
  res.writeHead(200, { 'Content-Type': 'text/html' });
  res.end(`<!doctype html><html><head><title>Catalog</title></head><body>
<div id="root">${html}</div><script id="data" type="application/json">${data}</script>
<script type="module" src="/client.js"></script></body></html>`);
}).listen(4000, () => console.log('SSR server on http://localhost:4000/'));

The browser entry parses the embedded JSON and calls hydrateRoot instead of createRoot, with the props the server used. That is the rule that matters: render a different tree than the server did and React 7,897 throws the markup away, re-renders on the client and logs a hydration mismatch — which is why timestamps, Math.random() and window checks belong in an Effect, not in the render.

client.jsx, then the commands that build and run all of itJSX
import { hydrateRoot } from 'react-dom/client';
import App from './app.jsx';
const products = JSON.parse(document.getElementById('data').textContent);
hydrateRoot(document.getElementById('root'), <App products={products} />);
Building the client bundle and starting the serverShell
echo '{ "compilerOptions": { "jsx": "react-jsx", "allowJs": true } }' > tsconfig.json
npx esbuild client.jsx --bundle --minify --format=esm --outfile=public/client.js
node --import tsx server.jsx

Node cannot parse JSX and browsers cannot resolve bare imports, so tsx 12,162 compiles the server files as Node loads them and esbuild 126 bundles the client entry; both read jsx from tsconfig.json, which must also allow .jsx files. What curl 3,008 http://localhost:4000/ returns is a finished page, not a shell:

Output of 192
<!doctype html><html><head><title>Catalog</title></head><body>
<div id="root"><main><h1>Catalog (<!-- -->2<!-- --> items)</h1><ul><li>Keyboard<!-- --> - $<!--
-->79</li><li>Mouse<!-- --> - $<!-- -->29</li></ul><button>In cart: <!-- -->0</button></main>
</div><script id="data" type="application/json">[{"id":1,"name":"Keyboard","price":79},...]
</script><script type="module" src="/client.js"></script></body></html>

Hydration is not a second render into an empty container: React walks the existing DOM, matches it against the tree your components produce, and attaches listeners to nodes already there. In Chrome 152 1 the page paints the catalog before the bundle arrives, and clicking the button twice afterwards shows "In cart: 2" with nothing logged to the console. Disable JavaScript and the list stays readable; only the button stops working.

renderToStaticMarkup is the same function without the hydration markers, for output nothing will hydrate: email bodies, RSS descriptions, a PDF pipeline. Note what renderToString cannot do, though: it is synchronous, so a Suspense boundary that suspends renders its fallback and never resolves. React's documentation labels it legacy for that reason.