Artwork: https://code-art.pictures/
Do we really need a manual SSR setup in the era of Next.js and Vite?
It's a fair question. Indeed, in blogs and on social networks, Next.js is definitely the “current thing,” while SSR (server-side rendering) done manually might seem too low-level. However, not every app needs a framework, and even if it does, it might turn out that Next.js doesn't support your favorite CSS-in-JS library. In such cases, we have no choice but to write some SSR manually. Fortunately, it's not that complicated, and I'd be happy to show you how.
Tutorial overview
This tutorial focuses on building a React application and prerendering it with SSR. It uses an initial setup created from this repo, which provides a simple, extensible, and up-to-date React + TypeScript + webpack example. For details on the setup, refer to the previous post in this series.
Full code
The full code for this tutorial is available in this repo. You can copy and paste it to use as is or follow along with the blog post.
Components versions
This tutorial has been tested with the following versions:
- Node.js 22
- React 19
- TypeScript 5.7
- webpack 5.97
It should generally work with earlier versions, but some minor adjustments might be needed.
1. What kind of SSR are we doing here?
Broadly, we can define three approaches to implementing SSR in an app:
1.1. No SSR at all
In this approach, the client receives a nearly empty HTML page, typically something like this:
<html>
<head>
<title>My Blog</title>
</head>
<body>
<div id="app-root">Loading...</div>
</body>
</html>
The client-side code is responsible for rendering the entire app. A typical entry point might look like this:
const container = document.getElementById('app-root')
const root = createRoot(container)
root.render(<App/>)
1.2. Full SSR
In this approach, the client receives fully rendered HTML. This requires a server that fetches all user-dependent data (e.g., from a database or an API) and renders it. Since static page hosting cannot support this, you need a server worker to handle the rendering.
<html>
<head>
<title>My Blog</title>
</head>
<body>
<div id="app-root">
<h1>My Blog</h1>
<nav><ul><li><a href="#">Home</a></li>...</nav>
<main>
<article class="user-post">
<h2>A Trip To Venice</h2>
<p>...</p>
</article>
...
</main>
<footer>(c) Me</footer>
</div>
</body>
</html>
On the client side, you typically perform hydration:
const container = document.getElementById('app-root')
hydrateRoot(container, <App/>)
1.3. Partial SSR
Partial SSR falls somewhere between the first two approaches. Simply put, it involves rendering only the unchanged parts of the page — those that don't depend on the user. This unchanged part may include the app header, navigation, user-unaware menus, footer, and so on. Because the rendered HTML is always the same, it can be served statically.
The partially prerendered HTML page might look like this:
<html>
<head>
<title>My Blog</title>
</head>
<body>
<div id="app-root">
<h1>My Blog</h1>
<nav><ul><li><a href="#">Home</a></li>...</nav>
<div class="loader">...</div>
<footer>(c) Me</footer>
</div>
</body>
</html>
As you can see, the <main>
section with the actual content was not rendered. Instead, a loading indicator is displayed.
The client entry point performs hydrateRoot()
, similar to the fully rendered example. The actual content, replacing the loader, is rendered to the page once hydration is complete.
This is the exact kind of SSR we are going to implement.
2. The example
Our example is the traditional React hello-world — a counter with “Increase” and “Decrease” buttons. It features user-dependent data — the current counter value, which it stores in local storage. The app has 2 pages: /
and /about
— so we can have some fun with the router.
2.1. App
src/App.tsx
import { useEffect, useState } from 'react'
import { Link, Route, Switch } from 'wouter'
export function App() {
return (
<>
<h1>React SSR Demo</h1>
<Switch>
<Route path="/"><Index /></Route>
<Route path="/about"><About /></Route>
</Switch>
</>
)
}
wouter is a replacement for react-router. Not only is it smaller in the bundle, but doing SSR with it is much easier.
src/App.tsx (continuation)
function Index() {
const [cnt, setCnt] = useState<number | undefined>(undefined)
useEffect(() => {
if (cnt == null)
setCnt(Number(
localStorage.getItem('my-demo-cnt') ?? '0'
))
else
localStorage.setItem('my-demo-cnt', String(cnt))
}, [cnt])
function update(delta: number) {
if (cnt != null) setCnt(cnt + delta)
}
return (
<main>
<p>Counter: { cnt ?? '⏳' }</p>
<button onClick={() => update(-1)}>Decrement</button>
<button onClick={() => update(1)}>Increment</button>
<p><Link href='/about'>About</Link></p>
</main>
)
}
Before the effect initializes the cnt
, the hourglass emoji is displayed instead of the actual counter. This serves as a loading indicator.
src/App.tsx (continuation)
function About() {
return <main>An app demonstrating ReactSSR</main>
}
2.2. Entry point
src/entry-client.tsx
import { createRoot } from 'react-dom/client'
import { App } from './App'
const container = document.getElementById('app-root')!
createRoot(container).render(<App/>)
2.3. Template
index.html
The template is shortened; see the full code here
<html>
<head>
<title>React SSR TS Demo</title>
</head>
<body>
<div id="app-root"><!--html-placeholder--></div>
</body>
</html>
Note: The comment inside the app root div
serves as a placeholder to easily replace it with the rendered app in the SSR step. To retain it during rendering, we need to set removeComments: false
in HtmlWebpackPlugin
configuration in webpack.config.mjs.
3. Running and building the app
3.1. Running for development
The following script runs the app in development mode:
package.json
{
"scripts": {
...
"start": "webpack serve --port 3000",
...
}
}
And this is the main view of the app:
3.2. Building for production
The following script builds the app for production. This version is for Linux and macOS:
package.json
{
"commands": {
...,
"build@webpack": "NODE_ENV=production webpack",
...
}
}
For Windows, the command would be: set NODE_ENV=production && webpack
.
After you run it, you will see the generated files in dist
. Let's take a closer look at dist/index.html
. You'll notice that webpack has inserted the link to the compiled JS file. The <div id="app-root">
remains intact and still contains the comment:
dist/index.html
The file is shortened and formatted for readability
<html>
<head>
<title>React SSR TS Demo</title>
<script defer="defer" src="main.202c97ca5eeaf2a49f5a.js"></script>
</head>
<body>
<div id="app-root"><!--html-placeholder--></div>
</body>
</html>
It's a working build with full client rendering (as described in section 1.1). We can preview it with the following command:
{
"scripts": {
...
"preview": "serve dist",
...
}
}
The result should look exactly the same as in development.
4. The SSR script
This script reads dist/index.html
, renders two pages, /
and /about
, and outputs them as files:
-
/
path todist/index.html
(rewrites the file) -
/about
todist/about.html
ssr/entry-ssr.tsx
import path from 'node:path'
import fs from 'node:fs'
import { renderToString } from 'react-dom/server'
import { Router } from 'wouter'
import { App } from '../src/App.js'
const indexHtml = String(fs.readFileSync(path.join('dist', 'index.html')))
for (const route of ['/', '/about']) {
const appHtml = renderToString(
<Router ssrPath={route}>
<App/>
</Router>
)
const prerendered = indexHtml
.replace('<!--html-placeholder-->', appHtml)
const fileName = `${route === '/' ? 'index' : route}.html`
fs.writeFileSync(path.join('dist', fileName), prerendered)
}
Notes:
- We imported
App
with the.js
extension. This is because we intend to run it in Node.js as an ESM module, which requires extensions for user files. TypeScript understands that.js
is used here to correctly resolve the import in the compiled code, and behind the scenes, it substitutes it with.tsx
during development. - We use relative directories because we plan to compile the file with
tsc
. This means the actual path will change, andimport.meta.directory
will not match the directory of the source file. Relative directories, however, are interpreted in the context of the Node.js working directory, which we intend to run from the project root path.
5. Running the SSR script
Basically, our task is to run the TypeScript file in Node.js. Here are the three most popular options to achieve this:
I prefer the last option because it guarantees that your tsconfig.json
will be read as is, and it makes fixing problems much easier.
To compile and run, we need the following scripts:
package.json
{
"scripts": {
...
"build@tsc": "tsc",
"build@ssr": "node out/ssr/entry-ssr.js",
...
}
}
We also need the following options to be set in tsconfig.json
:
-
"module": "ESNext"
,"target": "ESNext"
— make sure to leave ES syntax and ESM imports unchanged. -
"moduleResolution": "bundler"
or"nodenext"
— in a more complex setup, you might need different resolution settings for bundling and running SSR. For our example, both option will work because they are very similar. -
"outDir": "out"
— put compiled files in theout
directory. Without this setting, TypeScript will put.js
files in thesrc
directory, next to.ts(x)
sources, which works but reduces the clarity of your directory structure.
Let's compile the SSR script:
npm run build@tsc
Now you can observe that the compiled files have appeared:
/out
/src
App.js
entry-client.js
/ssr
entry-ssr.js
We are finally ready to run the compiled SSR script:
npm run build@ssr
If everything runs smoothly, you should observe the following changes in the dist
directory:
dist/index.html (updated)
The file is formatted and shortened for readability
<html>
<head>
<title>React SSR TS Demo</title>
<script defer="defer" src="main.202c97ca5eeaf2a49f5a.js"></script>
</head>
<body>
<div id="app-root">
<h1>React SSR Demo</h1>
<main>
<p>Counter: ⏳</p>
<button>Decrement</button>
<button>Increment</button>
<p><a href="/about">About</a></p>
</main>
</div>
</body>
</html>
Wow, it seems like it's exactly our application! And it even correctly displays our improvised loading indicator!
dist/about.html
The file is formatted and shortened for readability
<html>
<head>
<title>React SSR TS Demo</title>
<script defer="defer" src="main.202c97ca5eeaf2a49f5a.js"></script>
</head>
<body>
<div id="app-root">
<h1>React SSR Demo</h1>
<main>
An app demonstrating ReactSSR
</main>
</div>
</body>
</html>
This page doesn't contain any user data, so it was rendered completely.
5.1. Alternative: running SSR as CJS
If you don't have problems running your SSR script as ESM, just skip this section.
Strictly speaking, our script was already running “mostly CJS” because react-dom
is only shipped as CJS as of this writing (early 2025). However, all our scripts were in ESM. This should normally work in a recent Node, but if you run into problems because of this, you may compile and run the scripts as CJS. You need the following changes for this:
- Remove
"type": "module"
frompackage.json
- In
tsconfig.json
, make the following changes:"module": "CommonJS"
"moduleResolution": "node"
- In the
build@ssr
script inpackage.json
, add the--experimental-require-module
flag. This flag requires Node 22. It is needed becausewouter
is only shipped as an ES module, and torequire
it into your scripts, you need this flag. The command should look like this:
{
"scripts": {
...
"build@ssr": "node --experimental-require-module out/ssr/entry-ssr.js"
...
}
}
Now remove the dist
directory and rebuild everything. You will probably see a warning in the console saying that loading ES modules with require()
is experimental; that is expected as of this writing (2025). The result should be the same: two prerendered pages in the dist
directory.
6. Tweaking src/entry-client.tsx
Currently, our client is performing a full render even when running against prerendered pages. While this may seem to work fine, it can result in a performance penalty for larger applications.
To address this, we need to adjust the client entry point to use hydrate()
for prerendered pages while keeping a full render for development. Here are a few approaches to achieve this:
- Create separate entry points for development and production.
- In the client entry point, check if the
container
is empty — userender()
if it is andhydrate()
otherwise. - Use webpack's DefinePlugin to pass compile-time values to your app.
I prefer the last option because it doesn't overly complicate the setup while making the dev/prod separation explicit.
webpack.config.mjs
// ...
import { default as webpack } from 'webpack'
const prod = process.env.NODE_ENV === 'production'
export default {
// ...
plugins: [
// ...
new webpack.DefinePlugin({
IS_PROD: prod,
}),
],
// ...
}
entry-client.tsx
// ...
declare const IS_PROD: boolean
const container = document.getElementById('app-root')!
if (IS_PROD)
hydrateRoot(
container,
<Router>
<App/>
</Router>
)
else
createRoot(container).render(
<App/>
)
Let's take a look at how the compiled JS looks in development:
main.a5e563be581a740e1cfc.js
const container = document.getElementById("app-root");
if (false)
{}
else
(0,react_dom_client__WEBPACK_IMPORTED_MODULE_1__.createRoot)(container).render(
// ...
);
As seen, webpack removed the code from the production branch.
And this is how the fragment looks in production:
main.d12874e81a77454f00b2.js
The fragment is formatted for readability
const V = document.getElementById("app-root");
(0, o.hydrateRoot)(V, (0, a.jsx)(A, {
children: (0,a.jsx)(U, {})
}))
As seen, the minifier replaced the if
statement with only the truthy branch body. This ensures that the production build doesn't have any overhead from development.
Congratulations, we’re finally done! 🎉
Hope it wasn’t too hard to follow. You now have a Partial SSR setup that’s lightweight, flexible, and doesn’t rely on frameworks. Thanks for staying with me throughout this journey!
Top comments (3)
Is it for React 19?
Yes, this is done for React 19. It would also work for earlier versions with some adjustments, e.g. using
ReactDOM.render()
instead ofcreateRoot().render()
for React 17.UPD wrote the chapter “Components version” in the tutorial beginning. Thx for your question.
Okay, that's great! Thanks