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.
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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
