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.
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.
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} />);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.jsxNode 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:
<!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.