DEV Community

Cover image for Setting up partial SSR for a React + TypeScript + webpack app from scratch
Aleksei Berezkin
Aleksei Berezkin

Posted on

Setting up partial SSR for a React + TypeScript + webpack app from scratch

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.

The full code

The full code for this tutorial can be found in this repo. You can copy and paste it to use as is or follow along with this blog post.

The initial setup was created from this repo, a simple yet extensible and up-to-date React + TypeScript + webpack example. It is explained in the previous post of this series.

In this tutorial, we will focus on the application itself and how to prerender it with SSR.

Required Node.js version

This tutorial has been tested on Node.js 22. Some features may not work as smoothly on older versions.

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>
Enter fullscreen mode Exit fullscreen mode

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/>)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

On the client side, you typically perform hydration:

const container = document.getElementById('app-root')
hydrateRoot(container, <App/>)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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/>)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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",
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

And this is the main view of the app:

Image description

3.2. Building for production

The following script builds the app for production:

package.json

{
  "commands": {
    ...,
    "build@webpack": "NODE_ENV=production webpack",
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

After you run it, you will observe the generated files in dist. Let's look closely on dist/index.html. Its main difference from the template is that it contains the link to the JS file generated by webpack. The <div id="app-root"> still contains the comment — it’s not prerendered yet:

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>

Enter fullscreen mode Exit fullscreen mode

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",
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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 to dist/index.html (rewrites the file)
  • /about to dist/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)
}
Enter fullscreen mode Exit fullscreen mode

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. 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 intend to compile the file with tsc, so the actual path will change. Relative directories are interpreted in the scope of the script's 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. There are 3 most popular options:

  • With ts-node
  • With tsx
  • Compile TS to JS and run it as a regular Node.js application

I prefer the last option because it guarantees that your tsconfig.json will be read, 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",
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

We also need the following options to be in tsconfig.json:

  • "module": "ESNext", "target": "ESNext" — make sure to leave ES syntax and ESM imports unchanged.
  • "moduleResolution": "bundler" — this is not very precise because we intend to run the script in Node.js, not in a bundler. However, in fact, bundler works very similarly to nodenext, and it's fine for us here.
  • "outDir": "out" — put compiled files in the out directory. Without this setting, TypeScript will put .js files in the src 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
Enter fullscreen mode Exit fullscreen mode

Now you can observe that the compiled files have appeared:

/out
  /src
    App.js
    entry-client.js
  /ssr
    entry-ssr.js
Enter fullscreen mode Exit fullscreen mode

We are finally ready to run the compiled SSR script:

npm run build@ssr
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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" from package.json
  • In tsconfig.json, make the following changes:
    • "module": "CommonJS"
    • "moduleResolution": "node"
  • In the build@ssr script in package.json, add the --experimental-require-module flag. This flag requires Node 22. It is needed because wouter is only shipped as an ES module, and to require 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"
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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

Our client is still doing the full render, even when you run it against prerendered pages. While it may seem to work fine, it can actually impose a performance penalty in large apps.

So, we need to change our client entry point to hydrate when the page is prerendered, but to leave the full render for development. There are several ways to achieve this:

  • Write different entry points for development and production
  • Check if the container is empty, use render() if it is, and hydrate() 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,
    }),
  ],
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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/>
  )
Enter fullscreen mode Exit fullscreen mode

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(
    // ...
  );
Enter fullscreen mode Exit fullscreen mode

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, {})
}))
Enter fullscreen mode Exit fullscreen mode

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 (0)