React Isomorphic (SSR, CSR)

React Isomorphic SSR, CSR

Ulukbek Dzhunusov

--

For those who can figure it out without explanation, link to the repository.

Hello everyone! Everyone has probably had the need to do a custom webpack project with specific requirements and went looking on github for a well set up and clear repository, without any inbuilt technology that you didn’t need ? I, personally, often had a need for a well-tuned webpack and due to such a need I decided it was worth spending a couple of days and writing a webpack setup with a good project structure and upgradable code.

You will say:
- Why are you wasting your time on this when you have next.js, cra ?
- And after all there are a bunch of the same templates on github

All because you want more flexibility, which they don’t provide and you have to write a lot of twists. All templates I found were overloaded and everything became so heavy and incomprehensible when expanding the project as well as meeting such templates that contained a lot of trash and unnecessary code

This template will include:
- Isomorphic Rendering
- Client Side Rendering
- Server Side Rendering
- Components Lazy Loading
- Code splitting
- Docker wrapper

Create a structure - bash below is the fastest way to create a structure.

$ mkdir config public src src/routes src/utils src/pages src/pages/HomePage src/pages/AboutPage src/pages/NotFound && touch .babelrc .env.dev config/webpack-base.config.js config/webpack-dev.config.js config/webpack-prod.config.js public/index.html src/server.js src/client.js src/App.js src/pages/HomePage/index.js src/pages/AboutPage/index.js src/pages/NotFound/index.js src/routes/index.js src/utils/index.js src/utils/template.js

Init yarn.

$ yarn init

Copy all dependencies from this package.json and install dependencies.

$ yarn install 

Configure .babelrc config.

{
"presets": [
"@babel/preset-react",
"@babel/preset-env"
],
"plugins": [
"@babel/plugin-syntax-dynamic-import",
"@loadable/babel-plugin"
]
}

Fill the env.dev as you need or use code below.

APP_PORT=3000                       APP_API_URI=http://localhost:3001/api

As you can see we have 3 webpack config: base, dev and prod, the base config will be merged for dev and prod config.

In base config we can add configs thous need in both of configs: dev, prod
In dev config we can add configs only need for development such a devServer and HtmlWebpackPlugin. In prod config we should write optimizations and what we need for production.

Below i will share all 3 configs.

webpack-base.config.js

const path = require('path');
const CopyPlugin = require("copy-webpack-plugin");
const LoadablePlugin = require('@loadable/webpack-plugin')
module.exports = {
target: 'web',
entry: {
app: path.resolve(process.cwd(), './src/client.js'),
vendors: ['react', 'react-dom', 'react-router-dom'],
},
output: {
filename: '[name].bundle.js',
path: path.resolve(process.cwd(), './build'),
publicPath: '/'
},
plugins: [
new CopyPlugin({
patterns: [
{
from: path.resolve(process.cwd(), './public'),
to: path.resolve(process.cwd(), './build'),
filter: file => !file.includes('index.html'),
},
],
}),
new LoadablePlugin(),
],
module: {
rules: [
{
test: /\.(js|jsx)$/,
include: path.resolve(process.cwd(), './src'),
loader: 'babel-loader',
},
{
loader: require.resolve('file-loader'),
exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
options: {
name: 'static/media/[name].[hash:8].[ext]',
},
},
],
},
optimization: {
runtimeChunk: 'single',
chunkIds: 'named',
splitChunks: {
chunks: 'async',
},
},
}

webpack-dev.config.js

const path = require('path');
const dotenv = require('dotenv');
const webpack = require('webpack');
const { merge } = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack-base.config');module.exports = merge(base, {
mode: 'development',
devtool: 'inline-source-map',
plugins: base.plugins.concat([
new HtmlWebpackPlugin({
inject: 'body',
template: path.resolve(process.cwd(), './public/index.html'),
}),
new webpack.DefinePlugin({
'process.env': JSON.stringify(
dotenv.config({
path: path.resolve(process.cwd(), './.env.dev')
}).parsed
)
}),
]),
devServer: {
contentBase: path.join(process.cwd(), './build'),
historyApiFallback: true,
compress: true,
port: process.env.APP_PORT,
hot: true,
}
});

webpack-prod.config.js

const dotenv = require('dotenv');
const webpack = require('webpack');
const { merge } = require('webpack-merge');
const base = require('./webpack-base.config');module.exports = merge(base, {
mode: 'production',
devtool: 'source-map',
optimization: Object.assign(base.optimization, {
minimize: true,
}),
plugins: base.plugins.concat([
new webpack.DefinePlugin({
'process.env': JSON.stringify(dotenv.config().parsed)
}),
])
});

Create a HomePage, AboutPage and NotFoundPage.

HomePage.js

import React from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet'
const HomePage = ({ history }) => {
const handleClick = () => {
history.push('/about')
}
return (
<div>
<Helmet>
<title>Home page | React SSR</title>
<meta name="description" content="Home page" />
</Helmet>
<p>Home Page</p>
<button onClick={handleClick}>Go to About page</button>
</div>
)
}
HomePage.propTypes = {
history: PropTypes.object
}
export default HomePage;

AboutPage.js

import React from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet'
const AboutPage = ({ history }) => {
const handleClick = () => {
history.push('/')
}
return (
<div>
<Helmet>
<title>About page | React SSR</title>
<meta name="description" content="About page" />
</Helmet>
<p>About Page</p>
<button onClick={handleClick}>Go to Home page</button>
</div>
)
}
AboutPage.propTypes = {
history: PropTypes.object
}
export default AboutPage;

NotFoundPage.js

import React from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet'
const NotFound = ({ history }) => {
const handleClick = () => {
history.push('/')
}
return (
<div>
<Helmet>
<title>404 Not Found Page | React SSR</title>
<meta name="description" content="Not Found Page" />
</Helmet>
<p>404 Not Found Page</p>
<button onClick={handleClick}>Go to Home page</button>
</div>
)
}
NotFound.propTypes = {
history: PropTypes.object
}
export default NotFound;

Next step is routes and you can do routes as you need but i will show you how to do good and upgradable routes. First, we should create routes.js file with dynamic imports, why we are using dynamic imports? Because dynamic imports will split our code and optimize bundle size for dynamic imports we will use @loadable/component

import loadable from "@loadable/component";const HomePage = loadable(() => import('../pages/HomePage'))
const AboutPage = loadable(() => import('../pages/AboutPage'))
const NotFoundPage = loadable(() => import('../pages/NotFoundPage'))
export default [
{
title: 'Home',
path: '/',
exact: true,
component: HomePage,
},
{
title: 'About',
exact: true,
path: '/about',
component: AboutPage,
},
{
title: 'Not Found',
exact: true,
component: NotFoundPage,
}
];

Second, we should configure our routes in App.js

import React from 'react';
import { Switch, Route } from 'react-router-dom';
import routes from './routes';const App = () => {
return (
<Switch>
{routes.map(({ component: Component, ...route }) => (
<Route
{...route}
key={route.title}
render={props => <Component {...props} />}
/>
))}
</Switch>
)
};
export default App;

Last step is create client.js and server.js entry points and template renderer for server.js.

client.js

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { loadableReady } from '@loadable/component'
import App from './App';const entry = document.getElementById('root');
const Main = () => (
<BrowserRouter>
<App />
</BrowserRouter>
);
if (module.hot) {
ReactDOM.render(<Main />, entry);
module.hot.accept(App);
} else {
loadableReady(() => {
ReactDOM.hydrate(<Main />, entry);
});
}

server.js

require('@babel/register')({
presets: ['@babel/preset-env', '@babel/preset-react'],
});
const { template } = require('./utils');
const { ChunkExtractor } = require('@loadable/server');
const express = require('express');
const path = require('path');
const PORT = process.env.APP_PORT || '3000'
const app = express();
const router = express.Router();
app.use(express.static(
path.resolve(process.cwd(), './build'), { index: false })
);
app.use((req, res, next) => {
if (req.path.match(/server\.js/)) return res.status(404).end('Not Found');
next();
})
router.get('/*', (req, res) => {
const extractor = new ChunkExtractor({
statsFile: path.resolve(process.cwd(), './build/loadable-stats.json'),
entrypoints: ['app', 'vendors']
});
const html = template(extractor, req, res);
res.set('content-type', 'text/html')
res.send(html);
});
app.get('/*', router);app.listen(PORT, () => console.log(`listening on http://localhost:${PORT}`));

This file will be create magic of bundles and SEO optimization.
For bundles we will be use ChunkExtractor from @loadable/server.
For the SEO we will be use Helmet from react-helmet.

import React from 'react';
import { Helmet } from 'react-helmet'
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import App from '../App';const template = (extractor, req, res) => {
const reactApp = extractor.collectChunks(
<StaticRouter location={req.path} context={{ req, res }}>
<App />
</StaticRouter>
);
const renderedApp = renderToString(reactApp);const helmet = Helmet.renderStatic();
const linkTags = extractor.getLinkTags();
const styleTags = extractor.getStyleTags();
const scriptTags = extractor.getScriptTags();
const html = `
<!DOCTYPE html>
<html lang="en" ${helmet.htmlAttributes.toString()}>
<head>
<meta charset="utf-8" />
<meta name="theme-color" content="#000000" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.webmanifest" />
${helmet.title.toString()}
${helmet.meta.toString()}
${helmet.link.toString()}
${linkTags}
${styleTags}
</head>
<body ${helmet.bodyAttributes.toString()}>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">${renderedApp}</div>
${scriptTags}
</body>
</html>`;
return html;
};
export default template;

Our last step is a run app.

Build source

$ yarn build

Start development

$ yarn dev

Start production

$ yarn prod

Thank’s for reading my trash and fill free for the comments! ;)

What we get at the end !!!

--

--