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: src/ 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.

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;

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

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

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.

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 }
  ];
};

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.

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);
    }
  });
}

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.

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')
);

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
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

ReduxReactSSRKoaIsomorphic
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

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.