Skip to content

Instantly share code, notes, and snippets.

@cereallarceny
Last active May 2, 2023 06:41
Show Gist options
  • Save cereallarceny/ee1b86227aabaf4a4b2a3144b84dfaa2 to your computer and use it in GitHub Desktop.
Save cereallarceny/ee1b86227aabaf4a4b2a3144b84dfaa2 to your computer and use it in GitHub Desktop.
Server-side rendering in Create React App
const md5File = require('md5-file');
const path = require('path');
// CSS styles will be imported on load and that complicates matters... ignore those bad boys!
const ignoreStyles = require('ignore-styles');
const register = ignoreStyles.default;
// We also want to ignore all image requests
// When running locally these will load from a standard import
// When running on the server, we want to load via their hashed version in the build folder
const extensions = ['.gif', '.jpeg', '.jpg', '.png', '.svg'];
// Override the default style ignorer, also modifying all image requests
register(ignoreStyles.DEFAULT_EXTENSIONS, (mod, filename) => {
if (!extensions.find(f => filename.endsWith(f))) {
// If we find a style
return ignoreStyles.noOp();
} else {
// If we find an image
const hash = md5File.sync(filename).slice(0, 8);
const bn = path.basename(filename).replace(/(\.\w{3})$/, `.${hash}$1`);
mod.exports = `/static/media/${bn}`;
}
});
// Set up babel to do its thing... env for the latest toys, react-app for CRA
// Notice three plugins: the first two allow us to use import rather than require, the third is for code splitting
// Polyfill is required for Babel 7, polyfill includes a custom regenerator runtime and core-js
require('@babel/polyfill');
require('@babel/register')({
ignore: [/\/(build|node_modules)\//],
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: [
'@babel/plugin-syntax-dynamic-import',
'dynamic-import-node',
'react-loadable/babel'
]
});
// Now that the nonsense is over... load up the server entry point
require('./server');
// Express requirements
import path from 'path';
import fs from 'fs';
// React requirements
import React from 'react';
import { renderToString } from 'react-dom/server';
import Helmet from 'react-helmet';
import { Provider } from 'react-redux';
import { StaticRouter } from 'react-router';
import { Frontload, frontloadServerRender } from 'react-frontload';
import Loadable from 'react-loadable';
// Our store, entrypoint, and manifest
import createStore from '../src/store';
import App from '../src/app/app';
import manifest from '../build/asset-manifest.json';
// Some optional Redux functions related to user authentication
import { setCurrentUser, logoutUser } from '../src/modules/auth';
// LOADER
export default (req, res) => {
/*
A simple helper function to prepare the HTML markup. This loads:
- Page title
- SEO meta tags
- Preloaded state (for Redux) depending on the current route
- Code-split script tags depending on the current route
*/
const injectHTML = (data, { html, title, meta, body, scripts, state }) => {
data = data.replace('<html>', `<html ${html}>`);
data = data.replace(/<title>.*?<\/title>/g, title);
data = data.replace('</head>', `${meta}</head>`);
data = data.replace(
'<div id="root"></div>',
`<div id="root">${body}</div><script>window.__PRELOADED_STATE__ = ${state}</script>`
);
data = data.replace('</body>', scripts.join('') + '</body>');
return data;
};
// Load in our HTML file from our build
fs.readFile(
path.resolve(__dirname, '../build/index.html'),
'utf8',
(err, htmlData) => {
// If there's an error... serve up something nasty
if (err) {
console.error('Read error', err);
return res.status(404).end();
}
// Create a store (with a memory history) from our current url
const { store } = createStore(req.url);
// If the user has a cookie (i.e. they're signed in) - set them as the current user
// Otherwise, we want to set the current state to be logged out, just in case this isn't the default
if ('mywebsite' in req.cookies) {
store.dispatch(setCurrentUser(req.cookies.mywebsite));
} else {
store.dispatch(logoutUser());
}
const context = {};
const modules = [];
/*
Here's the core funtionality of this file. We do the following in specific order (inside-out):
1. Load the <App /> component
2. Inside of the Frontload HOC
3. Inside of a Redux <StaticRouter /> (since we're on the server), given a location and context to write to
4. Inside of the store provider
5. Inside of the React Loadable HOC to make sure we have the right scripts depending on page
6. Render all of this sexiness
7. Make sure that when rendering Frontload knows to get all the appropriate preloaded requests
In English, we basically need to know what page we're dealing with, and then load all the appropriate scripts and
data for that page. We take all that information and compute the appropriate state to send to the user. This is
then loaded into the correct components and sent as a Promise to be handled below.
*/
frontloadServerRender(() =>
renderToString(
<Loadable.Capture report={m => modules.push(m)}>
<Provider store={store}>
<StaticRouter location={req.url} context={context}>
<Frontload isServer={true}>
<App />
</Frontload>
</StaticRouter>
</Provider>
</Loadable.Capture>
)
).then(routeMarkup => {
if (context.url) {
// If context has a url property, then we need to handle a redirection in Redux Router
res.writeHead(302, {
Location: context.url
});
res.end();
} else {
// Otherwise, we carry on...
// Let's give ourself a function to load all our page-specific JS assets for code splitting
const extractAssets = (assets, chunks) =>
Object.keys(assets)
.filter(asset => chunks.indexOf(asset.replace('.js', '')) > -1)
.map(k => assets[k]);
// Let's format those assets into pretty <script> tags
const extraChunks = extractAssets(manifest, modules).map(
c => `<script type="text/javascript" src="/${c.replace(/^\//, '')}"></script>`
);
// We need to tell Helmet to compute the right meta tags, title, and such
const helmet = Helmet.renderStatic();
// NOTE: Disable if you desire
// Let's output the title, just to see SSR is working as intended
console.log('THE TITLE', helmet.title.toString());
// Pass all this nonsense into our HTML formatting function above
const html = injectHTML(htmlData, {
html: helmet.htmlAttributes.toString(),
title: helmet.title.toString(),
meta: helmet.meta.toString(),
body: routeMarkup,
scripts: extraChunks,
state: JSON.stringify(store.getState()).replace(/</g, '\\u003c')
});
// We have all the final HTML, let's send it to the user already!
res.send(html);
}
});
}
);
};
// Express requirements
import bodyParser from 'body-parser';
import compression from 'compression';
import express from 'express';
import morgan from 'morgan';
import path from 'path';
import forceDomain from 'forcedomain';
import Loadable from 'react-loadable';
import cookieParser from 'cookie-parser';
// Our loader - this basically acts as the entry point for each page load
import loader from './loader';
// Create our express app using the port optionally specified
const app = express();
const PORT = process.env.PORT || 3000;
// NOTE: UNCOMMENT THIS IF YOU WANT THIS FUNCTIONALITY
/*
Forcing www and https redirects in production, totally optional.
http://mydomain.com
http://www.mydomain.com
https://mydomain.com
Resolve to: https://www.mydomain.com
*/
// if (process.env.NODE_ENV === 'production') {
// app.use(
// forceDomain({
// hostname: 'www.mydomain.com',
// protocol: 'https'
// })
// );
// }
// Compress, parse, log, and raid the cookie jar
app.use(compression());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(morgan('dev'));
app.use(cookieParser());
// Set up homepage, static assets, and capture everything else
app.use(express.Router().get('/', loader));
app.use(express.static(path.resolve(__dirname, '../build')));
app.use(loader);
// We tell React Loadable to load all required assets and start listening - ROCK AND ROLL!
Loadable.preloadAll().then(() => {
app.listen(PORT, console.log(`App listening on port ${PORT}!`));
});
// Handle the bugs somehow
app.on('error', error => {
if (error.syscall !== 'listen') {
throw error;
}
const bind = typeof PORT === 'string' ? 'Pipe ' + PORT : 'Port ' + PORT;
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
});
@nikitaeverywhere
Copy link

nikitaeverywhere commented Nov 6, 2019

Just to mention, there's a simpler alternative to the SSR approach provided here, if you just need to fix your SEO:

You can use quite straightforward pre-render solutions like Prerender.io or Rendertron. You set them up to work just for social/search engines and they do the rest of the magic without the need to change your application at all.

@manh-dan
Copy link

I tried it but I am having a problem, when I run on the web there is an error
Uncaught TypeError: Cannot read property '.css' of undefined
located in: ignore-styles.js
oldHandlers[ext] = require.extensions[ext]
require.extensions[ext] = handler
Can you help me, thank you in advance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment