Frontend Development 15 min read

How to Build a React‑Redux Isomorphic Server‑Rendered App from Scratch

This article walks through the concepts, architecture, and step‑by‑step implementation of a React + Redux isomorphic application that renders on the server with Koa, bundles with Webpack, and hydrates on the client, while highlighting performance benefits and common pitfalls.

Tencent IMWeb Frontend Team
Tencent IMWeb Frontend Team
Tencent IMWeb Frontend Team
How to Build a React‑Redux Isomorphic Server‑Rendered App from Scratch

Introduction

The rise of server‑side rendering (SSR) and isomorphic applications has become a must‑have skill for modern front‑end engineers. This guide documents a complete implementation of an SSR solution based on React and Redux, built with a Koa server and Webpack.

What is Server‑Side Rendering (SSR)

SSR, also called backend rendering or direct output, generates the HTML markup on the server before sending it to the browser.

Improves first‑screen performance because the browser can display content without downloading the full JavaScript bundle.

Boosts SEO since crawlers can index the pre‑rendered HTML.

What is an Isomorphic (Universal) Application

Isomorphic code runs both on the server and the client, sharing the same JavaScript modules.

Higher code reuse across environments.

Easier maintenance because business logic lives in a single codebase.

Why Choose React + Redux

The team selected React because the existing UI library and scaffolding were already based on React + Redux, and the team had prior experience with React direct output.

Project Structure

The source tree follows a conventional layout:

<code>src/</code>

pages/xxx/ – page‑specific source files (template.html, reducers.js, isomorph.jsx).

server/ – Koa server, controller, router, configuration.

assets/ – static resources.

Server Setup with Koa

A minimal Koa server is created to serve the rendered HTML. The core controller

indexReact.js

builds a Redux store, fetches initial data, renders the React tree to a string, and injects the result into a template.

<code>const react = require('react');
const { renderToString } = require('react-dom/server');
const { createStore, applyMiddleware } = require('redux');
const thunkMiddleware = require('redux-thunk').default;
const { Provider } = require('react-redux');

async function process(ctx) {
  const store = createStore(reducer, undefined, applyMiddleware(thunkMiddleware));
  const preloadedState = await component.getPreloadState(store).then(() => store.getState());
  const headEl = component.getHeadFragment(store);
  const contentEl = react.createElement(Provider, { store }, react.createElement(component));
  ctx.type = 'html';
  ctx.body = template({ preloadedState, head: renderToString(headEl), html: renderToString(contentEl) });
}
module.exports = process;
</code>

The router iterates over the page configuration and registers each route with the controller:

<code>Object.entries(routes).forEach(([name, v]) => {
  const { pattern } = v;
  router.get(pattern, indexReact);
});
</code>

SSR Build Pipeline

Webpack is used to compile the isomorphic entry points for both components and reducers. The configuration creates separate bundles for each page, disables CSS processing on the server, and targets the Node environment.

<code>const path = require('path');
const webpack = require('webpack');
const FilterPlugin = require('filter-chunk-webpack-plugin');

module.exports = (options) => {
  const { mode, output } = options;
  const componentsEntry = {/* generated by glob */};
  const reducersEntry = {/* generated by glob */};
  const ssrOutputConfig = (out, name) => ({
    path: path.resolve(serverDir, name),
    filename: '[name].js',
    libraryTarget: 'commonjs2'
  });
  const ssrPlugins = [
    new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
    new FilterPlugin({ select: true, patterns: ssrPages })
  ];
  return [
    { mode, entry: componentsEntry, output: ssrOutputConfig(output, 'components'), target: 'node', externals: ssrExternals, plugins: ssrPlugins },
    { mode, entry: reducersEntry, output: ssrOutputConfig(output, 'reducers'), target: 'node', externals: ssrExternals, plugins: ssrPlugins }
  ];
};
</code>

Deploying Templates

After the bundles are built, a helper walks through the generated assets, injects the rendered head and body into the original HTML template, and writes the final template files to

server/templates

.

<code>function ssrTemplatesDeployer(assets) {
  Object.entries(assets).forEach(([name, asset]) => {
    const { source } = asset;
    if (/\.html$/.test(name)) {
      const content = source()
        .replace(/(<head[^>]*>)/, `$1${head}`)
        .replace(/(<\/head>)/, `<script>window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\u003c')}</script>$1`)
        .replace(/(<div[^>]*id="react-body"[^>]*>)/, `$1${html}`);
      write.sync(path.join(serverDir, 'templates', name), content);
    }
  });
}
</code>

Client Hydration

On the browser side the same React component tree is hydrated with the preloaded state using

ReactDOM.hydrate

. The store is recreated from

window.__PRELOADED_STATE__

and the app is wrapped with

Provider

.

<code>import React from 'react';
import { hydrate } from 'react-dom';
import { createStore, applyMiddleware, compose } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { Provider } from 'react-redux';
import reducers from './reducers';
import Container from './components/Container';
import './index.css';

let store;
const preloadState = window.__PRELOADED_STATE__;
if (process.env.NODE_ENV === 'production') {
  store = createStore(reducers, preloadState, applyMiddleware(thunkMiddleware));
} else {
  store = createStore(reducers, preloadState, compose(applyMiddleware(thunkMiddleware), window.devToolsExtension ? window.devToolsExtension() : f => f));
}

hydrate(
  <Provider store={store}>
    <Container />
  </Provider>,
  document.getElementById('react-body')
);
</code>

Common Pitfalls

Business logic that cannot be isomorphically executed (e.g., DOM‑only APIs in

componentDidMount

) must be guarded.

Modules that depend on

location

,

cookie

,

userAgent

, or

localStorage

need per‑request isolation; the article recommends using Node’s

domain

module similar to TSW.

CSS imports should be ignored on the server with

ignore-loader

to avoid memory leaks.

Core‑js polyfills can cause global memory leaks; the configuration disables

babel-runtime

and sets

babelrc: false

with a Node‑targeted preset.

Future Directions

The author suggests aligning the framework with Next.js features such as a custom

Document

component, and exploring hot‑reloading of server bundles to avoid full Node restarts when only front‑end assets change.

Architecture diagram
Architecture diagram
ReduxreactSSRWebpackKoaIsomorphic
Tencent IMWeb Frontend Team
Written by

Tencent IMWeb Frontend Team

IMWeb Frontend Community gathering frontend development enthusiasts. Follow us for refined live courses by top experts, cutting‑edge technical posts, and to sharpen your frontend skills.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.