
If you've used Next.js, you've probably wondered: what's actually happening under the hood when a page renders on the server? Where does renderToString come from? How does hydration actually work?
Most tutorials hand you a framework and say "just run create-next-app." But there's a huge gap between using SSR and understanding SSR.
This article closes that gap. We'll build a working SSR framework from scratch — no Next.js, no magic — using just Node.js, React, and Webpack. By the end, you'll know exactly what every line of create-next-app is doing behind the scenes.
This article is the prerequisite for the Understanding Next.js series. If you want to deeply understand how Next.js works — RSC Payload, streaming, prefetching — start here first.
createServer)renderToString converts React components to HTMLhttp module)Before writing code, let's be clear about what we're building and why.
Server-Side Rendering (SSR): The server produces the full HTML document. The browser receives a complete page and displays it immediately.
Client-Side Rendering (CSR): The server sends an almost empty HTML shell with a <script> tag. The browser downloads and executes JavaScript, which then builds the page dynamically.
Here's the simplest possible SSR — no React, no framework, just Node:
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<html>
<head><title>SSR Demo</title></head>
<body><div id="root">Hello from the server!</div></body>
</html>
`);
});
server.listen(3000, () => {
console.log('Server running at http://localhost:3000');
});Visit http://localhost:3000 and you'll see the page immediately. This isn't a new idea — PHP did this decades ago. Modern Node.js template engines like EJS and Pug work the same way.
The CSR equivalent would look like this in a create-react-app project:
<!-- What the browser initially receives with CSR -->
<html>
<head><title>CSR Demo</title></head>
<body>
<div id="root"></div> <!-- Empty! -->
<script src="bundle.js"></script>
</body>
</html>The <div id="root"> is empty. Everything the user sees has to wait for bundle.js to download, parse, and execute. This is why CSR has two fundamental problems:
<div> with no content.SSR solves both: the HTML arrives pre-filled with content, so the browser paints immediately and crawlers can index it.
But there's a catch. If we just renderToString our React components, the page is static — buttons don't work, state doesn't update. We need hydration to bring it to life.
Let's build a real SSR setup with React. We'll use TypeScript throughout.
pnpm install @types/node @types/react-dom @types/react -D
pnpm install react-dom@18 react@18The server handles two things: rendering React to HTML on /, and returning a 404 for everything else.
// server/app.tsx
import { createServer } from "http";
import { App } from "../client/App";
import { renderToString } from "react-dom/server";
const server = createServer(async (req, res) => {
if (req.url === "/") {
res.writeHead(200, { "Content-Type": "text/html" });
const HTMLContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSR Demo</title>
</head>
<body>
<div id="root">${renderToString(App())}</div>
</body>
</html>
`;
res.end(HTMLContent);
} else {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found\n");
}
});
server.listen(3000, () => {
console.log("Server running at http://127.0.0.1:3000/");
});What
renderToStringdoes: React traverses the component tree and produces an HTML string. No DOM, no browser APIs — just a string. This is why React calls it "isomorphic": the same component code runs on both server and client.
Let's add a Counter component to test interactivity later:
// client/App.tsx
import { Counter } from "./component/Counter";
export function App() {
return (
<>
<Counter />
<main>hello SSR</main>
</>
);
}// client/component/Counter.tsx
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
const increment = () => setCount((prev) => prev + 1);
const decrement = () => setCount((prev) => prev - 1);
const reset = () => setCount(0);
return (
<div>
<h1>{count}</h1>
<button onClick={decrement}>-</button>
<button onClick={increment}>+</button>
<button onClick={reset}>Reset</button>
</div>
);
}Use tsx (based on esbuild) to run TypeScript directly:
pnpm install tsx -g
tsx watch server/app.tsxVisit http://127.0.0.1:3000/ and you'll see:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSR Demo</title>
</head>
<body>
<div id="root">
<div>
<h1>0</h1>
<button>-</button>
<button>+</button>
<button>Reset</button>
</div>
<main>hello SSR</main>
</div>
</body>
</html>The content is there. But try clicking a button — nothing happens. The HTML is static because renderToString only captures the initial render output, not the event handlers. We need hydration.
Hydration is React's process of attaching event listeners to server-rendered DOM without rebuilding it from scratch. Instead of createRoot().render() (which wipes and rebuilds everything), we use hydrateRoot() (which reuses existing DOM nodes).
First, update the server to serve a bundled JS file:
// server/app.tsx
import { createServer } from "http";
import { App } from "../client/App";
import { renderToString } from "react-dom/server";
import { resolve } from "path";
import { readFile } from "fs/promises";
const server = createServer(async (req, res) => {
if (req.url === "/") {
res.writeHead(200, { "Content-Type": "text/html" });
const HTMLContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSR Demo</title>
</head>
<body>
<div id="root">${renderToString(App())}</div>
<script src="index.js"></script>
</body>
</html>
`;
res.end(HTMLContent);
} else if (req.url === "/index.js") {
const data = await readFile(resolve(__dirname, "../dist/index.js"));
res.end(data);
} else {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found\n");
}
});
server.listen(3000, () => {
console.log("Server running at http://127.0.0.1:3000/");
});The key addition: <script src="index.js"></script> tells the browser to request the client bundle, and the server serves it from the dist/ directory.
This is where hydration happens:
// client/index.ts
import { hydrateRoot } from "react-dom/client";
import { App } from "./App";
hydrateRoot(document.querySelector("#root")!, App());hydrateRoot takes the existing server-rendered DOM and "hydrates" it — attaching event listeners and setting up React's internal state management, all without rebuilding the DOM tree.
Why not
createRoot?createRootdestroys the existing DOM and builds from scratch. You'd get a flash of content followed by a blank screen while React rebuilds.hydrateRootpreserves what the server already rendered and only adds interactivity.
TypeScript and JSX can't run directly in the browser — we need Webpack to transpile and bundle everything into a single index.js.
Install the dependencies:
# Webpack
pnpm install webpack webpack-cli webpack-merge @types/webpack -D
# Babel (transpiles TS/JSX to browser-compatible JS)
pnpm install babel-loader @babel/register @babel/preset-typescript @babel/preset-react @babel/preset-env @babel/core -D// webpack.base.ts
import { type Configuration } from "webpack";
export const BaseConfig: Configuration = {
watch: true,
resolve: {
extensions: [".ts", ".tsx"],
},
module: {
rules: [
{
test: /\.(ts|tsx)$/,
loader: "babel-loader",
exclude: /node_modules/,
options: {
presets: [
["@babel/preset-react", { runtime: "automatic" }],
[
"@babel/preset-env",
{ targets: { browsers: ["last 2 versions"] } },
],
"@babel/preset-typescript",
],
},
},
],
},
};This tells Webpack: "when you encounter .ts or .tsx files, run them through Babel." The three presets handle React JSX, modern JavaScript syntax, and TypeScript respectively.
// webpack.client.ts
import * as path from "path";
import * as webpack from "webpack";
import { merge } from "webpack-merge";
import { BaseConfig } from "./webpack.base";
const clientConfig: webpack.Configuration = merge(BaseConfig, {
mode: "development",
entry: "./client/index.ts",
target: "web",
output: {
filename: "index.js",
path: path.resolve(__dirname, "dist"),
},
});
export default clientConfig;The target: "web" flag tells Webpack to bundle for the browser (as opposed to Node.js). The entry point is our client/index.ts from Step 2, and the output goes to dist/index.js — exactly where the server expects it.
Add a build script to package.json:
{
"scripts": {
"build:client": "webpack --config webpack.client.ts"
}
}Run it:
pnpm build:clientNow start the server and open the browser. This time, click the Counter buttons — they work! The page loads with server-rendered HTML, then Webpack's bundle kicks in and React hydrates the DOM.
To recap, our handmade SSR framework does the following:
renderToString(App()) converts the React component tree to an HTML string.dist/index.js.<script> tag) to the browser. The browser requests the JS bundle and hydrates the page.hydrateRoot attaches event handlers to the existing DOM without rebuilding it.This is the exact same pipeline that Next.js, Remix, and every other React SSR framework uses. The difference is that those frameworks add routing, data fetching, streaming, caching, and hundreds of other optimizations on top.
Now that you understand the SSR foundation, the next step is understanding how modern frameworks build on it. I've written a complete series that covers Next.js from the inside out:
The series covers RSC Payload, file-system routing, Server/Client Components, streaming with Suspense, prefetching, client-side navigation, and the complete caching system — all explained from first principles, just like this article.
Cover image from a SSR meme. Respect to everyone who has wrestled with Webpack configs.