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:
<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.jsbuilds 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
localStorageneed per‑request isolation; the article recommends using Node’s
domainmodule similar to TSW.
CSS imports should be ignored on the server with
ignore-loaderto avoid memory leaks.
Core‑js polyfills can cause global memory leaks; the configuration disables
babel-runtimeand sets
babelrc: falsewith a Node‑targeted preset.
Future Directions
The author suggests aligning the framework with Next.js features such as a custom
Documentcomponent, and exploring hot‑reloading of server bundles to avoid full Node restarts when only front‑end assets change.
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.