├── config ├── jest │ ├── setupTestsAfterEnv.js │ ├── fileTransform.js │ └── cssTransform.js ├── env.js ├── webpack.server.prod.js └── webpackConfigFactory.js ├── .eslintrc ├── .prettierrc ├── public ├── robots.txt ├── favicon.ico ├── manifest.json └── images │ └── react.svg ├── src ├── styles │ ├── index.scss │ ├── _colors.scss │ └── _base.scss ├── components │ ├── about │ │ ├── about.module.scss │ │ └── About.js │ ├── __tests__ │ │ └── Home.test.js │ ├── Head.js │ ├── App.js │ └── Home.js ├── polyfills.js ├── utils │ └── assetUtils.js ├── api │ ├── todosApi.js │ ├── index.js │ └── httpClient.js ├── index.js ├── main.js └── state │ └── serverDataContext.js ├── .dockerignore ├── .browserslistrc ├── .gitignore ├── scripts ├── test.js ├── utils │ ├── purgeCacheOnChange.js │ └── devMiddleware.js ├── startProd.js ├── start.js └── build.js ├── Dockerfile ├── babel.config.js ├── server ├── todoApi.js ├── fetchDataForRender.js ├── app.js ├── renderServerSideApp.js └── indexHtml.js ├── jest.config.js ├── CHANGELOG.md ├── package.json └── README.md /config/jest/setupTestsAfterEnv.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["react-app"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import './colors'; 2 | @import './base'; 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /.git 2 | /node_modules 3 | /build 4 | /coverage 5 | /dist 6 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # Browsers that we support 2 | 3 | last 2 versions 4 | > 1% 5 | -------------------------------------------------------------------------------- /src/styles/_colors.scss: -------------------------------------------------------------------------------- 1 | $gray-light: #f2f3f5; 2 | $blue-dark: #2b3642; 3 | $blue: #007aff; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cullenjett/react-ssr-boilerplate/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/components/about/about.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors'; 2 | 3 | .title { 4 | color: $blue; 5 | } 6 | 7 | .react-logo { 8 | width: 200px; 9 | } 10 | -------------------------------------------------------------------------------- /src/polyfills.js: -------------------------------------------------------------------------------- 1 | // Require any polyfills you need here and they'll only be fetched 2 | // if browserSupportsAllFeatures() in src/index.js returns true 3 | 4 | // You might not need to be so heavy-handed with your polyfills... 5 | require('core-js'); 6 | -------------------------------------------------------------------------------- /src/utils/assetUtils.js: -------------------------------------------------------------------------------- 1 | // We can use "process.env.VAR_NAME" on both the server and the client. 2 | // See config/env.js and server/indexHtml.js 3 | export function imagePath(assetName) { 4 | return `${process.env.PUBLIC_URL}/images/${assetName}`; 5 | } 6 | -------------------------------------------------------------------------------- /src/api/todosApi.js: -------------------------------------------------------------------------------- 1 | export function todosApi(http) { 2 | return { 3 | all: () => { 4 | return http.get('/api/todos'); 5 | }, 6 | 7 | create: newTodo => { 8 | return http.post('/api/todos', newTodo); 9 | } 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | import { httpClient } from './httpClient'; 2 | import { todosApi } from './todosApi'; 3 | 4 | export function apiFactory(http) { 5 | return { 6 | todos: todosApi(http) 7 | }; 8 | } 9 | 10 | const http = httpClient('http://localhost:3000'); 11 | export const api = apiFactory(http); 12 | -------------------------------------------------------------------------------- /config/jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | // This is a custom Jest transformer turning file imports into filenames. 4 | // http://facebook.github.io/jest/docs/tutorial-webpack.html 5 | 6 | module.exports = { 7 | process(src, filename) { 8 | return `module.exports = ${JSON.stringify(path.basename(filename))};`; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | // This is a custom Jest transformer turning style imports into empty objects. 2 | // http://facebook.github.io/jest/docs/tutorial-webpack.html 3 | 4 | module.exports = { 5 | process() { 6 | return 'module.exports = {};'; 7 | }, 8 | getCacheKey() { 9 | // The output is always the same. 10 | return 'cssTransform'; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React SSR", 3 | "name": "React with Server Side Rendering", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /scripts/test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | 3 | process.on('unhandledRejection', err => { 4 | throw err; 5 | }); 6 | 7 | const jest = require('jest'); 8 | 9 | require('../config/env'); 10 | 11 | const argv = process.argv.slice(2); 12 | 13 | if (!process.env.CI && argv.indexOf('--coverage') < 0) { 14 | argv.push('--watch'); 15 | } 16 | 17 | if (process.env.CI) { 18 | argv.push('--runInBand'); 19 | } 20 | 21 | jest.run(argv); 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build =============================== 2 | FROM node:10 as build 3 | 4 | WORKDIR /react-ssr-boilerplate 5 | 6 | COPY package*.json ./ 7 | 8 | RUN npm install 9 | 10 | COPY . . 11 | 12 | RUN npm run build 13 | 14 | # run =============================== 15 | FROM node:10-alpine as run 16 | 17 | WORKDIR /react-ssr-boilerplate 18 | 19 | COPY --from=build /react-ssr-boilerplate . 20 | 21 | EXPOSE 3000 22 | 23 | CMD ["npm", "run", "start:prod"] 24 | -------------------------------------------------------------------------------- /src/components/about/About.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import { imagePath } from '../../utils/assetUtils'; 4 | import styles from './about.module.scss'; 5 | 6 | class About extends Component { 7 | render() { 8 | return ( 9 |
10 |

About page

11 | 12 |
13 | ); 14 | } 15 | } 16 | 17 | export default About; 18 | -------------------------------------------------------------------------------- /src/components/__tests__/Home.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | import Home from '../Home'; 5 | import { ServerDataProvider } from '../../state/serverDataContext'; 6 | 7 | describe('', () => { 8 | it('renders server todos', () => { 9 | const { container } = render( 10 | 11 | 12 | 13 | ); 14 | 15 | expect(container.querySelector('li').textContent).toEqual('Test todo'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | 4 | const presets = [ 5 | [ 6 | '@babel/preset-env', 7 | { 8 | targets: { 9 | browsers: ['>1%', 'ie 11', 'not op_mini all'] 10 | } 11 | } 12 | ], 13 | '@babel/preset-react' 14 | ]; 15 | 16 | const plugins = [ 17 | 'react-loadable/babel', 18 | '@babel/plugin-transform-runtime', 19 | '@babel/plugin-proposal-class-properties', 20 | '@babel/plugin-syntax-dynamic-import' 21 | ]; 22 | 23 | return { 24 | presets, 25 | plugins 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /scripts/utils/purgeCacheOnChange.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import chokidar from 'chokidar'; 4 | 5 | export const purgeCacheOnChange = path => { 6 | const watcher = chokidar.watch(path, { 7 | ignoreInitial: true, 8 | ignored: /\/(node_modules|build)\// 9 | }); 10 | 11 | watcher.on('ready', () => { 12 | watcher.on('all', () => { 13 | console.log('Reloading server...'); 14 | 15 | Object.keys(require.cache).forEach(id => { 16 | if (/[/\\](src|server)[/\\]/.test(id)) { 17 | delete require.cache[id]; 18 | } 19 | }); 20 | }); 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /server/todoApi.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | export const todoRoutes = () => { 4 | const todoRoutes = new Router(); 5 | const todos = [{ id: 1, text: 'server-fetched todo' }]; 6 | 7 | todoRoutes.get('/api/todos', (_req, res) => { 8 | setTimeout(() => { 9 | res.json(todos); 10 | }, 300); 11 | }); 12 | 13 | todoRoutes.post('/api/todos', (req, res) => { 14 | const newTodo = req.body; 15 | newTodo.id = Date.now(); 16 | 17 | todos.push(newTodo); 18 | 19 | setTimeout(() => { 20 | res.json(newTodo); 21 | }, 100); 22 | }); 23 | 24 | return todoRoutes; 25 | }; 26 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: ['src/**/*.{js,jsx}'], 3 | setupFilesAfterEnv: ['/config/jest/setupTestsAfterEnv.js'], 4 | testMatch: [ 5 | '/src/**/__tests__/**/*.js?(x)', 6 | '/src/**/?(*.)(spec|test).js?(x)' 7 | ], 8 | testEnvironment: 'jsdom', 9 | testURL: 'http://localhost', 10 | transform: { 11 | '^.+\\.(js|jsx)$': '/node_modules/babel-jest', 12 | '^.+\\.css$': '/config/jest/cssTransform.js', 13 | '^(?!.*\\.(js|jsx|css|json)$)': '/config/jest/fileTransform.js' 14 | }, 15 | transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$'] 16 | }; 17 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | if (browserSupportsAllFeatures()) { 2 | runMain(); 3 | } else { 4 | loadScript(window.__ASSET_MANIFEST__['polyfills.js'], runMain); 5 | } 6 | 7 | function runMain() { 8 | const { main } = require('./main'); 9 | main(); 10 | } 11 | 12 | function browserSupportsAllFeatures() { 13 | return window.Promise && Object.assign; 14 | } 15 | 16 | function loadScript(src, done) { 17 | const script = document.createElement('script'); 18 | 19 | script.src = src; 20 | script.onload = () => { 21 | done(); 22 | }; 23 | script.onerror = () => { 24 | done(new Error('Failed to load script ' + src)); 25 | }; 26 | 27 | document.head.appendChild(script); 28 | } 29 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import Loadable from 'react-loadable'; 5 | 6 | import App from './components/App'; 7 | import { ServerDataProvider } from './state/serverDataContext'; 8 | 9 | import './styles/index.scss'; 10 | 11 | const serverData = window.__SERVER_DATA__; 12 | 13 | export const main = () => { 14 | Loadable.preloadReady().then(() => { 15 | ReactDOM.hydrate( 16 | 17 | 18 | 19 | 20 | , 21 | document.getElementById('root') 22 | ); 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /src/styles/_base.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | 6 | body { 7 | font-family: sans-serif; 8 | font-size: 16px; 9 | padding: 0; 10 | margin: 0; 11 | color: $blue-dark; 12 | } 13 | 14 | #root { 15 | min-height: 100%; 16 | position: relative; 17 | } 18 | 19 | nav { 20 | padding: 20px; 21 | background: $gray-light; 22 | margin-bottom: 40px; 23 | } 24 | 25 | .main { 26 | padding-left: 20px; 27 | padding-right: 20px; 28 | padding-bottom: 200px; 29 | } 30 | 31 | footer { 32 | background: $blue-dark; 33 | width: 100%; 34 | position: absolute; 35 | bottom: 0; 36 | left: 0; 37 | height: 150px; 38 | text-align: center; 39 | } 40 | 41 | .active { 42 | color: $blue; 43 | } 44 | -------------------------------------------------------------------------------- /src/components/Head.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Helmet } from 'react-helmet'; 3 | 4 | const Head = () => { 5 | return ( 6 | 7 | React SSR Boilerplate 8 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | ); 26 | }; 27 | 28 | export default Head; 29 | -------------------------------------------------------------------------------- /src/state/serverDataContext.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo } from 'react'; 2 | 3 | const ServerDataContext = React.createContext(); 4 | 5 | export const ServerDataProvider = props => { 6 | const value = useMemo(() => { 7 | return { 8 | data: props.value 9 | }; 10 | }, [props.value]); 11 | 12 | return ( 13 | 14 | {props.children} 15 | 16 | ); 17 | }; 18 | 19 | export const useServerData = fn => { 20 | const context = useContext(ServerDataContext); 21 | 22 | if (!context) { 23 | throw new Error( 24 | 'useServerData() must be a child of ' 25 | ); 26 | } 27 | 28 | if (fn) { 29 | return fn(context.data); 30 | } else { 31 | return context.data; 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/api/httpClient.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-unfetch'; 2 | 3 | export function httpClient(baseURL) { 4 | return { 5 | get: (path, options) => { 6 | return fetch(baseURL + path, options).then(res => { 7 | if (!res.ok) { 8 | throw new Error(res.statusText); 9 | } 10 | 11 | return res.json(); 12 | }); 13 | }, 14 | 15 | post: (path, body, options = {}) => { 16 | return fetch(baseURL + path, { 17 | ...options, 18 | method: 'POST', 19 | body: JSON.stringify(body), 20 | headers: { 21 | 'Content-Type': 'application/json', 22 | ...options.headers 23 | } 24 | }).then(res => { 25 | if (!res.ok) { 26 | throw new Error(res.statusText); 27 | } 28 | 29 | return res.json(); 30 | }); 31 | } 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /scripts/utils/devMiddleware.js: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | import webpackDevMiddleware from 'webpack-dev-middleware'; 3 | import webpackHotMiddleware from 'webpack-hot-middleware'; 4 | 5 | import createConfig from '../../config/webpackConfigFactory'; 6 | 7 | const config = createConfig('development'); 8 | 9 | export const applyDevMiddleware = app => { 10 | const compiler = webpack(config); 11 | 12 | app.use( 13 | webpackDevMiddleware(compiler, { 14 | hot: true, 15 | publicPath: config.output.publicPath, 16 | progress: true, 17 | stats: { 18 | colors: true, 19 | assets: true, 20 | chunks: false, 21 | modules: false, 22 | hash: false 23 | } 24 | }) 25 | ); 26 | 27 | app.use( 28 | webpackHotMiddleware(compiler, { 29 | path: '/__webpack_hmr', 30 | heartbeat: 4000 31 | }) 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /scripts/startProd.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | process.env.NODE_ENV = 'production'; 3 | process.env.PUBLIC_URL = process.env.PUBLIC_URL || ''; 4 | 5 | const cluster = require('cluster'); 6 | 7 | const { app } = require('../build/server'); 8 | 9 | const PORT = process.env.PORT || 3000; 10 | 11 | // Use the native Node.js cluster module to create a worker processes for each CPU 12 | if (cluster.isMaster) { 13 | console.log(`Master pid: ${process.pid}`); 14 | 15 | const cpuCount = require('os').cpus().length; 16 | for (let i = 0; i < cpuCount; i += 1) { 17 | cluster.fork(); 18 | } 19 | 20 | cluster.on('exit', worker => { 21 | console.log(`Worker ${worker.process.pid} died`); 22 | }); 23 | } else { 24 | app.listen(PORT, err => { 25 | if (err) { 26 | return console.error(err); 27 | } 28 | 29 | console.info( 30 | `Server running on port ${PORT} -- Worker pid: ${ 31 | cluster.worker.process.pid 32 | }` 33 | ); 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /config/env.js: -------------------------------------------------------------------------------- 1 | const dotenvVars = require('dotenv').config().parsed; 2 | 3 | const packageJson = require('../package.json'); 4 | process.env.VERSION = packageJson.version; 5 | 6 | const BAKED_IN_ENV_VARS = ['NODE_ENV', 'PUBLIC_URL', 'VERSION']; 7 | 8 | function getAppEnv() { 9 | const raw = Object.keys(dotenvVars || {}).reduce( 10 | (env, key) => { 11 | env[key] = process.env[key]; 12 | return env; 13 | }, 14 | { 15 | NODE_ENV: process.env.NODE_ENV, 16 | PUBLIC_URL: process.env.PUBLIC_URL, 17 | VERSION: process.env.VERSION 18 | } 19 | ); 20 | 21 | const forWebpackDefinePlugin = { 22 | 'process.env': Object.keys(raw).reduce((env, key) => { 23 | if (BAKED_IN_ENV_VARS.includes(key)) { 24 | env[key] = JSON.stringify(raw[key]); 25 | } else { 26 | env[key] = `process.env.${key}`; 27 | } 28 | return env; 29 | }, {}) 30 | }; 31 | 32 | const forIndexHtml = JSON.stringify({ 33 | env: raw 34 | }); 35 | 36 | return { raw, forIndexHtml, forWebpackDefinePlugin }; 37 | } 38 | 39 | module.exports = { 40 | getAppEnv 41 | }; 42 | -------------------------------------------------------------------------------- /server/fetchDataForRender.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ssrPrepass from 'react-ssr-prepass'; 3 | import chalk from 'chalk'; 4 | 5 | export const fetchDataForRender = (ServerApp, req) => { 6 | let data = {}; 7 | 8 | return ssrPrepass(, element => { 9 | if (element && element.type && element.type.fetchData) { 10 | return element.type.fetchData(req).then(d => { 11 | Object.keys(d).forEach(key => { 12 | if (data[key]) { 13 | logDuplicateKeyMessage(key, element.type.name); 14 | } 15 | }); 16 | 17 | data = { 18 | ...data, 19 | ...d 20 | }; 21 | }); 22 | } 23 | }).then(() => { 24 | return data; 25 | }); 26 | }; 27 | 28 | function logDuplicateKeyMessage(key, component) { 29 | /* eslint-disable no-console */ 30 | console.log(''); 31 | console.log( 32 | chalk.red( 33 | `Warning: <${component} /> is overwriting an existing server data value for "${key}".` 34 | ) 35 | ); 36 | console.log(chalk.red('This can cause unexpected behavior.')); 37 | console.log(''); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Switch, Route, NavLink } from 'react-router-dom'; 3 | import Loadable from 'react-loadable'; 4 | 5 | import Head from './Head'; 6 | 7 | const LoadableHome = Loadable({ 8 | loader: () => import(/* webpackChunkName: 'home' */ './Home'), 9 | loading: () =>
Loading...
10 | }); 11 | 12 | const LoadableAbout = Loadable({ 13 | loader: () => import(/* webpackChunkName: 'about' */ './about/About'), 14 | loading: () =>
Loading...
15 | }); 16 | 17 | const App = () => ( 18 |
19 | 20 | 21 | 29 | 30 |
31 | 32 | 33 | 34 | 35 |
36 | 37 |
38 |
39 | ); 40 | 41 | export default App; 42 | -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import express from 'express'; 3 | import compression from 'compression'; 4 | import helmet from 'helmet'; 5 | import morgan from 'morgan'; 6 | import responseTime from 'response-time'; 7 | import bodyParser from 'body-parser'; 8 | 9 | import { renderServerSideApp } from './renderServerSideApp'; 10 | import { todoRoutes } from './todoApi'; 11 | 12 | const { PUBLIC_URL = '' } = process.env; 13 | 14 | // This export is used by our initialization code in /scripts 15 | export const app = express(); 16 | 17 | app.use(compression()); 18 | app.use(helmet()); 19 | app.use(bodyParser.json()); 20 | 21 | // Serve generated assets 22 | app.use( 23 | PUBLIC_URL, 24 | express.static(path.resolve(__dirname, '../build'), { 25 | maxage: Infinity 26 | }) 27 | ); 28 | 29 | // Serve static assets in /public 30 | app.use( 31 | PUBLIC_URL, 32 | express.static(path.resolve(__dirname, '../public'), { 33 | maxage: '30 days' 34 | }) 35 | ); 36 | 37 | app.use(morgan('tiny')); 38 | 39 | // Demo API endpoints 40 | app.use(todoRoutes()); 41 | 42 | app.use( 43 | responseTime((_req, res, time) => { 44 | res.setHeader('X-Response-Time', time.toFixed(2) + 'ms'); 45 | res.setHeader('Server-Timing', `renderServerSideApp;dur=${time}`); 46 | }) 47 | ); 48 | 49 | app.use(renderServerSideApp); 50 | -------------------------------------------------------------------------------- /src/components/Home.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { api } from '../api'; 4 | import { useServerData } from '../state/serverDataContext'; 5 | 6 | const Home = () => { 7 | const serverTodos = useServerData(data => { 8 | return data.todos || []; 9 | }); 10 | const [text, setText] = useState(''); 11 | const [todos, setTodos] = useState(serverTodos); 12 | 13 | return ( 14 |
15 |

Home page

16 | 17 |
{ 19 | e.preventDefault(); 20 | 21 | const newTodo = { 22 | text 23 | }; 24 | 25 | api.todos.create(newTodo).then(res => { 26 | setTodos([...todos, res]); 27 | setText(''); 28 | }); 29 | }} 30 | > 31 | 32 |
33 | setText(e.target.value)} 39 | /> 40 |
41 | 42 |
    43 | {todos.map(todo => ( 44 |
  • {todo.text}
  • 45 | ))} 46 |
47 |
48 | ); 49 | }; 50 | 51 | Home.fetchData = () => { 52 | return api.todos.all().then(todos => { 53 | return { 54 | todos 55 | }; 56 | }); 57 | }; 58 | 59 | export default Home; 60 | -------------------------------------------------------------------------------- /server/renderServerSideApp.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | import { StaticRouter } from 'react-router-dom'; 4 | import Helmet from 'react-helmet'; 5 | import Loadable from 'react-loadable'; 6 | import { getBundles } from 'react-loadable/webpack'; 7 | 8 | import App from '../src/components/App'; 9 | import { fetchDataForRender } from './fetchDataForRender'; 10 | import { indexHtml } from './indexHtml'; 11 | import stats from '../build/react-loadable.json'; 12 | import { ServerDataProvider } from '../src/state/serverDataContext'; 13 | 14 | const ServerApp = ({ context, data, location }) => { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | export const renderServerSideApp = (req, res) => { 25 | Loadable.preloadAll() 26 | .then(() => fetchDataForRender(ServerApp, req)) 27 | .then(data => renderApp(ServerApp, data, req, res)); 28 | }; 29 | 30 | function renderApp(ServerApp, data, req, res) { 31 | const context = {}; 32 | const modules = []; 33 | 34 | const markup = ReactDOMServer.renderToString( 35 | modules.push(moduleName)}> 36 | 37 | 38 | ); 39 | 40 | if (context.url) { 41 | res.redirect(context.url); 42 | } else { 43 | const fullMarkup = indexHtml({ 44 | helmet: Helmet.renderStatic(), 45 | serverData: data, 46 | bundles: getBundles(stats, modules), 47 | markup 48 | }); 49 | 50 | res.status(200).send(fullMarkup); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /config/webpack.server.prod.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const nodeExternals = require('webpack-node-externals'); 3 | 4 | const { getAppEnv } = require('./env'); 5 | 6 | const env = getAppEnv(); 7 | const { PUBLIC_URL = '' } = env.raw; 8 | 9 | const resolvePath = relativePath => path.resolve(__dirname, relativePath); 10 | 11 | if (env.raw.NODE_ENV !== 'production') { 12 | throw new Error('Production builds must have NODE_ENV=production.'); 13 | } 14 | 15 | module.exports = { 16 | mode: 'production', 17 | target: 'node', 18 | node: { 19 | __dirname: true 20 | }, 21 | entry: './server/app.js', 22 | output: { 23 | path: resolvePath('../build'), 24 | filename: 'server.js', 25 | publicPath: PUBLIC_URL + '/', 26 | libraryTarget: 'commonjs2' 27 | }, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.(js|jsx)$/, 32 | exclude: /node_modules/, 33 | loader: 'babel-loader', 34 | options: { 35 | plugins: [ 36 | [ 37 | 'css-modules-transform', 38 | { 39 | camelCase: true, 40 | extensions: ['.css', '.scss'], 41 | generateScopedName: '[hash:base64]', 42 | ignore: 'src/styles' 43 | } 44 | ], 45 | 'dynamic-import-node' 46 | ] 47 | } 48 | }, 49 | { 50 | test: /\.s?css$/, 51 | exclude: [resolvePath('../src/styles')], 52 | use: [ 53 | { 54 | loader: 'css-loader', 55 | options: { 56 | localsConvention: 'camelCase', 57 | modules: true 58 | } 59 | }, 60 | 'sass-loader', 61 | 'import-glob-loader' 62 | ] 63 | } 64 | ] 65 | }, 66 | externals: [nodeExternals()] 67 | }; 68 | -------------------------------------------------------------------------------- /scripts/start.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | process.env.NODE_ENV = 'development'; 3 | process.env.PUBLIC_URL = process.env.PUBLIC_URL || ''; 4 | 5 | require('@babel/register')({ 6 | plugins: [ 7 | [ 8 | 'css-modules-transform', 9 | { 10 | camelCase: true, 11 | extensions: ['.css', '.scss'], 12 | generateScopedName: '[hash:base64]' 13 | } 14 | ], 15 | 'dynamic-import-node' 16 | ] 17 | }); 18 | 19 | const chalk = require('chalk'); 20 | const clearConsole = require('react-dev-utils/clearConsole'); 21 | const express = require('express'); 22 | const openBrowser = require('react-dev-utils/openBrowser'); 23 | const path = require('path'); 24 | const { 25 | choosePort, 26 | prepareUrls 27 | } = require('react-dev-utils/WebpackDevServerUtils'); 28 | 29 | const { applyDevMiddleware } = require('./utils/devMiddleware'); 30 | const { purgeCacheOnChange } = require('./utils/purgeCacheOnChange'); 31 | 32 | process.on('unhandledRejection', err => { 33 | throw err; 34 | }); 35 | 36 | const DEFAULT_PORT = process.env.PORT || 3000; 37 | const HOST = process.env.HOST || '0.0.0.0'; 38 | const isInteractive = process.stdout.isTTY; 39 | const server = express(); 40 | 41 | // We need to "inject" the dev middleware higher up in the stack of middlewares, 42 | // so applyDevMiddleware needs to happen before server.use() 43 | applyDevMiddleware(server); 44 | 45 | server.use((req, res) => { 46 | // We use "require" inside this function 47 | // so that when purgeCacheOnChange() runs we pull in the most recent code. 48 | // https://codeburst.io/dont-use-nodemon-there-are-better-ways-fc016b50b45e 49 | const { app } = require('../server/app'); 50 | app(req, res); 51 | }); 52 | 53 | choosePort(HOST, DEFAULT_PORT).then(port => { 54 | if (!port) { 55 | return; 56 | } 57 | 58 | const urls = prepareUrls('http', HOST, port); 59 | 60 | server.listen(port, HOST, err => { 61 | if (err) { 62 | return console.log(err); 63 | } 64 | 65 | if (isInteractive) { 66 | clearConsole(); 67 | } 68 | 69 | console.log(chalk.white('\n\tStarting dev server...')); 70 | 71 | openBrowser(urls.localUrlForBrowser); 72 | 73 | purgeCacheOnChange(path.resolve(__dirname, '../')); 74 | 75 | console.log( 76 | chalk.blue(` 77 | Running locally at ${urls.localUrlForBrowser} 78 | Running on your network at ${urls.lanUrlForConfig}:${port} 79 | `) 80 | ); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /public/images/react.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [5.1.0] - 2019-09-03 9 | 10 | ### Changed 11 | 12 | - Use a `` component to set document head data via react-helmet instead of writing it directly in `` using a separate JSON-like file. 13 | - Update dependencies. 14 | 15 | ## [5.0.1] - 2019-07-17 16 | 17 | ### Fixed 18 | 19 | - Production builds only load necessary code-split (ie css-modules) CSS files instead of all of them. 20 | 21 | ### Security 22 | 23 | ## [5.0.0] - 2019-06-16 24 | 25 | ### Added 26 | 27 | - `react-testing-library` replaces `enzyme` 28 | 29 | ### Removed 30 | 31 | - `enzyme` 32 | 33 | ### Changed 34 | 35 | - The `` context provider wrapper accepts a prop called `value` instead of `serverCache`. Internally, it's context value is now just called `data` vs `dataCache`. 36 | 37 | ### Fixed 38 | 39 | - Jest config no longer runs in a "node" environment 40 | 41 | ### Security 42 | 43 | ## [4.1.0] - 2019-06-16 44 | 45 | ### Added 46 | 47 | - Resolve CSS modules by looking for `.module.s?css` file extension 48 | 49 | ### Changed 50 | 51 | - Client-side webpack config for dev and prod combined into a factory function 52 | 53 | ## [4.0.1] - 2019-06-15 54 | 55 | ### Added 56 | 57 | ### Changed 58 | 59 | - Upgraded `css-loader` to v3 and change webpack config options 60 | - Dependencies updated 61 | 62 | ### Fixed 63 | 64 | - The correct route is now rendered on the server (I forgot to pass the URL to react router :facepalm:) 65 | 66 | ## [4.0.0] - 2019-06-09 67 | 68 | ### Added 69 | 70 | - CHANGELOG.md :grin: 71 | - Client-side data hydration setup using React context 72 | - Sample todo application 73 | - Sample API module for making HTTP requests 74 | 75 | ### Changed 76 | 77 | - Fetch data while rendering on the server from any component in the tree 78 | - Ignore `node_modules` when watching files with chokidar during development 79 | - ESLint config now uses [`eslint-config-react-app`](https://github.com/facebook/create-react-app/tree/master/packages/eslint-config-react-app) 80 | - Jest config moved out of `package.json` to `jest.config.js` 81 | - Dependencies updated to latest version 82 | - README updates 83 | 84 | ### Removed 85 | 86 | - `redux` and `react-redux` were removed and replaced with React context 87 | 88 | # Template 89 | 90 | ## [1.0.0] - 2019-06-09 91 | 92 | ### Added 93 | 94 | ### Changed 95 | 96 | ### Deprecated 97 | 98 | ### Removed 99 | 100 | ### Fixed 101 | 102 | ### Security 103 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | process.env.NODE_ENV = 'production'; 3 | process.env.PUBLIC_URL = process.env.PUBLIC_URL || ''; 4 | 5 | const chalk = require('chalk'); 6 | const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); 7 | const fs = require('fs-extra'); 8 | const path = require('path'); 9 | const webpack = require('webpack'); 10 | const { 11 | measureFileSizesBeforeBuild, 12 | printFileSizesAfterBuild 13 | } = require('react-dev-utils/FileSizeReporter'); 14 | 15 | const createConfig = require('../config/webpackConfigFactory'); 16 | const serverConfig = require('../config/webpack.server.prod'); 17 | const clientConfig = createConfig('production'); 18 | 19 | process.on('unhandledRejection', err => { 20 | throw err; 21 | }); 22 | 23 | const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; 24 | const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; 25 | 26 | const resolvePath = relativePath => path.resolve(__dirname, relativePath); 27 | 28 | measureFileSizesBeforeBuild(resolvePath('../build')) 29 | .then(previousFileSizes => { 30 | fs.emptyDirSync(resolvePath('../build')); 31 | return build(previousFileSizes); 32 | }) 33 | .then( 34 | result => printResult(result), 35 | err => { 36 | console.log(chalk.red('Failed to compile.\n')); 37 | console.log((err.message || err) + '\n'); 38 | process.exit(1); 39 | } 40 | ); 41 | 42 | function build(previousFileSizes) { 43 | console.log(chalk.blue('\n\tCreating an optimized production build...\n')); 44 | 45 | const clientCompiler = webpack(clientConfig); 46 | const serverCompiler = webpack(serverConfig); 47 | 48 | return new Promise((resolve, reject) => { 49 | clientCompiler.run((err, stats) => { 50 | if (err) { 51 | return reject(err); 52 | } else { 53 | console.log(chalk.white('✓ Client webpack build complete')); 54 | } 55 | 56 | serverCompiler.run(err => { 57 | if (err) { 58 | return reject(err); 59 | } else { 60 | console.log(chalk.white('✓ Server webpack build complete')); 61 | } 62 | 63 | const messages = formatWebpackMessages(stats.toJson({}, true)); 64 | 65 | if (messages.errors.length) { 66 | return reject(new Error(messages.errors.join('\n\n'))); 67 | } 68 | 69 | resolve({ 70 | stats, 71 | previousFileSizes, 72 | warnings: messages.warnings 73 | }); 74 | }); 75 | }); 76 | }); 77 | } 78 | 79 | function printResult({ stats, previousFileSizes, warnings }) { 80 | if (warnings.length) { 81 | console.log(chalk.yellow('Compiled with warnings.\n')); 82 | console.log(warnings.join('\n\n')); 83 | } else { 84 | console.log(chalk.green('Compiled successfully.\n')); 85 | } 86 | 87 | console.log('File sizes after gzip:\n'); 88 | printFileSizesAfterBuild( 89 | stats, 90 | previousFileSizes, 91 | resolvePath('../build'), 92 | WARN_AFTER_BUNDLE_GZIP_SIZE, 93 | WARN_AFTER_CHUNK_GZIP_SIZE 94 | ); 95 | console.log(); 96 | } 97 | -------------------------------------------------------------------------------- /server/indexHtml.js: -------------------------------------------------------------------------------- 1 | import { getAppEnv } from '../config/env'; 2 | 3 | const env = getAppEnv(); 4 | const { NODE_ENV, PUBLIC_URL = '' } = env.raw; 5 | 6 | let assetManifest; 7 | if (NODE_ENV === 'production') { 8 | assetManifest = require('../build/asset-manifest.json'); 9 | } else { 10 | assetManifest = { 11 | 'main.js': '/main.bundle.js' 12 | }; 13 | } 14 | 15 | const prefetchStyleLinks = bundles => { 16 | if (NODE_ENV !== 'production') { 17 | return ''; 18 | } 19 | 20 | const assetFilePaths = Object.keys(assetManifest) 21 | .filter( 22 | file => 23 | file !== 'main.css' && 24 | file.match(/\.css$/) && 25 | !bundles.find(b => b.publicPath === assetManifest[file]) 26 | ) 27 | .map(cssFile => `${PUBLIC_URL}${assetManifest[cssFile]}`); 28 | 29 | return assetFilePaths 30 | .map( 31 | cssFilePath => `` 32 | ) 33 | .join(''); 34 | }; 35 | 36 | const cssLinks = bundles => { 37 | if (NODE_ENV !== 'production') { 38 | return ''; 39 | } 40 | 41 | const mainCSS = assetManifest['main.css']; 42 | const bundleFilePaths = bundles 43 | .filter(bundle => bundle.file.match(/\.css$/)) 44 | .map(cssBundle => `${PUBLIC_URL}/${cssBundle.file}`); 45 | 46 | return [mainCSS, ...bundleFilePaths] 47 | .map(cssFilePath => ``) 48 | .join(''); 49 | }; 50 | 51 | const preloadScripts = bundles => { 52 | const mainJS = assetManifest['main.js']; 53 | const bundleFilePaths = bundles 54 | .filter(bundle => bundle.file.match(/\.js$/)) 55 | .map(jsBundle => `${PUBLIC_URL}/${jsBundle.file}`); 56 | 57 | return [...bundleFilePaths, mainJS] 58 | .map(jsFilePath => ``) 59 | .join(''); 60 | }; 61 | 62 | const jsScripts = bundles => { 63 | const mainJS = assetManifest['main.js']; 64 | const bundleFilePaths = bundles 65 | .filter(bundle => bundle.file.match(/\.js$/)) 66 | .map(jsBundle => `${PUBLIC_URL}/${jsBundle.file}`); 67 | 68 | return [...bundleFilePaths, mainJS] 69 | .map( 70 | jsFilePath => 71 | `` 72 | ) 73 | .join(''); 74 | }; 75 | 76 | export const indexHtml = ({ helmet, serverData, markup, bundles }) => { 77 | const htmlAttrs = helmet.htmlAttributes.toString(); 78 | const bodyAttrs = helmet.bodyAttributes.toString(); 79 | 80 | return ` 81 | 82 | 83 | 84 | ${helmet.title.toString()} 85 | ${helmet.meta.toString()} 86 | 87 | ${preloadScripts(bundles)} 88 | ${prefetchStyleLinks(bundles)} 89 | ${helmet.link.toString()} 90 | ${cssLinks(bundles)} 91 | ${helmet.style.toString()} 92 | 93 | ${helmet.noscript.toString()} 94 | ${helmet.script.toString()} 95 | ${jsScripts(bundles)} 96 | 97 | 98 |
${markup}
99 | 100 | 105 | 106 | 107 | `; 108 | }; 109 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ssr-boilerplate", 3 | "version": "5.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=10.15" 7 | }, 8 | "scripts": { 9 | "start": "node scripts/start.js", 10 | "build": "node scripts/build.js", 11 | "test": "node scripts/test.js", 12 | "start:prod": "node scripts/startProd.js", 13 | "lint": "eslint src/**/*.js", 14 | "format": "prettier --write \"src/**/*.{js,json,css,md}\"", 15 | "docker:build": "docker build --rm -t cullenjett/react-ssr-boilerplate .", 16 | "docker:start": "docker run --rm -it -p 3000:3000 cullenjett/react-ssr-boilerplate", 17 | "docker": "npm run docker:build && npm run docker:start" 18 | }, 19 | "husky": { 20 | "hooks": { 21 | "pre-commit": "lint-staged" 22 | } 23 | }, 24 | "lint-staged": { 25 | "*.js": [ 26 | "npm run lint" 27 | ], 28 | "*.{js,json,css,md}": [ 29 | "npm run format", 30 | "git add" 31 | ] 32 | }, 33 | "dependencies": { 34 | "@babel/core": "^7.5.5", 35 | "@babel/plugin-proposal-class-properties": "^7.5.5", 36 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 37 | "@babel/plugin-transform-runtime": "^7.5.5", 38 | "@babel/preset-env": "^7.5.5", 39 | "@babel/preset-react": "^7.0.0", 40 | "@babel/register": "^7.5.5", 41 | "@babel/runtime": "^7.5.5", 42 | "@testing-library/react": "^9.1.4", 43 | "autoprefixer": "^9.6.1", 44 | "babel-eslint": "^10.0.3", 45 | "babel-loader": "^8.0.6", 46 | "babel-plugin-css-modules-transform": "^1.6.2", 47 | "babel-plugin-dynamic-import-node": "^2.3.0", 48 | "body-parser": "^1.19.0", 49 | "case-sensitive-paths-webpack-plugin": "^2.2.0", 50 | "chalk": "^2.4.2", 51 | "chokidar": "^3.0.2", 52 | "compression": "^1.7.4", 53 | "core-js": "^3.2.1", 54 | "css-loader": "^3.2.0", 55 | "dotenv": "^8.1.0", 56 | "error-overlay-webpack-plugin": "^0.4.1", 57 | "eslint": "^6.3.0", 58 | "eslint-config-react-app": "^5.0.1", 59 | "eslint-loader": "^3.0.0", 60 | "eslint-plugin-flowtype": "^4.3.0", 61 | "eslint-plugin-import": "^2.18.2", 62 | "eslint-plugin-jsx-a11y": "^6.2.3", 63 | "eslint-plugin-react": "^7.14.3", 64 | "eslint-plugin-react-hooks": "^2.0.1", 65 | "express": "^4.17.1", 66 | "fs-extra": "^8.1.0", 67 | "helmet": "^3.20.1", 68 | "husky": "^3.0.5", 69 | "import-glob-loader": "^1.1.0", 70 | "isomorphic-unfetch": "^3.0.0", 71 | "jest": "^24.9.0", 72 | "lint-staged": "^9.2.5", 73 | "lodash-webpack-plugin": "^0.11.5", 74 | "mini-css-extract-plugin": "^0.8.0", 75 | "morgan": "^1.9.1", 76 | "node-sass": "^4.12.0", 77 | "optimize-css-assets-webpack-plugin": "^5.0.3", 78 | "postcss-flexbugs-fixes": "^4.1.0", 79 | "postcss-loader": "^3.0.0", 80 | "prettier": "^1.18.2", 81 | "prop-types": "^15.7.2", 82 | "react": "^16.9.0", 83 | "react-dev-utils": "^9.0.3", 84 | "react-dom": "^16.9.0", 85 | "react-helmet": "^5.2.1", 86 | "react-loadable": "^5.5.0", 87 | "react-router-dom": "^5.0.1", 88 | "react-ssr-prepass": "^1.0.6", 89 | "react-test-renderer": "^16.9.0", 90 | "response-time": "^2.3.2", 91 | "sass-loader": "^8.0.0", 92 | "style-loader": "^1.0.0", 93 | "uglifyjs-webpack-plugin": "^2.2.0", 94 | "webpack": "^4.39.3", 95 | "webpack-dev-middleware": "^3.7.1", 96 | "webpack-hot-middleware": "^2.25.0", 97 | "webpack-manifest-plugin": "^2.0.4", 98 | "webpack-node-externals": "^1.7.2" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /config/webpackConfigFactory.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const autoprefixer = require('autoprefixer'); 3 | const webpack = require('webpack'); 4 | const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin'); 5 | const eslintFormatter = require('react-dev-utils/eslintFormatter'); 6 | const LodashModuleReplacementPlugin = require('lodash-webpack-plugin'); 7 | const { ReactLoadablePlugin } = require('react-loadable/webpack'); 8 | const ErrorOverlayPlugin = require('error-overlay-webpack-plugin'); 9 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 10 | const ManifestPlugin = require('webpack-manifest-plugin'); 11 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 12 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 13 | 14 | const { getAppEnv } = require('./env'); 15 | 16 | const env = getAppEnv(); 17 | const { PUBLIC_URL = '' } = env.raw; 18 | 19 | const resolvePath = relativePath => path.resolve(__dirname, relativePath); 20 | 21 | /** 22 | * This function generates a webpack config object for the client-side bundle. 23 | */ 24 | module.exports = function(envType) { 25 | const IS_DEV = envType === 'development'; 26 | const IS_PROD = envType === 'production'; 27 | const config = {}; 28 | 29 | config.mode = envType; 30 | 31 | config.devtool = IS_DEV ? 'cheap-module-source-map' : 'source-map'; 32 | 33 | config.entry = IS_DEV 34 | ? [ 35 | 'webpack-hot-middleware/client?path=/__webpack_hmr&reload=true', 36 | resolvePath('../src/index.js') 37 | ] 38 | : { 39 | polyfills: resolvePath('../src/polyfills.js'), 40 | main: resolvePath('../src/index.js') 41 | }; 42 | 43 | config.output = IS_DEV 44 | ? { 45 | path: resolvePath('../build'), 46 | filename: '[name].bundle.js', 47 | chunkFilename: '[name].chunk.js', 48 | publicPath: PUBLIC_URL + '/' 49 | } 50 | : { 51 | path: resolvePath('../build'), 52 | filename: 'static/js/[name].[chunkhash:8].js', 53 | chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js', 54 | publicPath: PUBLIC_URL + '/' 55 | }; 56 | 57 | config.module = { 58 | rules: [ 59 | // ESLint 60 | { 61 | test: /\.(js|jsx)$/, 62 | enforce: 'pre', 63 | use: [ 64 | { 65 | options: { 66 | formatter: eslintFormatter 67 | }, 68 | loader: 'eslint-loader' 69 | } 70 | ], 71 | include: resolvePath('../src') 72 | }, 73 | 74 | // Babel 75 | { 76 | test: /\.(js|jsx)$/, 77 | include: resolvePath('../src'), 78 | loader: 'babel-loader', 79 | options: { 80 | cacheDirectory: IS_DEV, 81 | compact: IS_PROD 82 | } 83 | }, 84 | 85 | // CSS Modules 86 | { 87 | test: /\.module\.s?css$/, 88 | include: [resolvePath('../src')], 89 | use: [ 90 | IS_DEV && 'style-loader', 91 | IS_PROD && MiniCssExtractPlugin.loader, 92 | { 93 | loader: 'css-loader', 94 | options: { 95 | localsConvention: 'camelCase', 96 | modules: true 97 | } 98 | }, 99 | { 100 | loader: 'postcss-loader', 101 | options: { 102 | ident: 'postcss', 103 | plugins: () => [ 104 | require('postcss-flexbugs-fixes'), 105 | autoprefixer({ 106 | flexbox: 'no-2009' 107 | }) 108 | ] 109 | } 110 | }, 111 | 'sass-loader', 112 | 'import-glob-loader' 113 | ].filter(Boolean) 114 | }, 115 | 116 | // CSS 117 | { 118 | test: /\.s?css$/, 119 | include: [resolvePath('../src')], 120 | exclude: [/\.module\.s?css$/], 121 | use: [ 122 | IS_DEV && 'style-loader', 123 | IS_PROD && MiniCssExtractPlugin.loader, 124 | 'css-loader', 125 | { 126 | loader: 'postcss-loader', 127 | options: { 128 | ident: 'postcss', 129 | plugins: () => [ 130 | require('postcss-flexbugs-fixes'), 131 | autoprefixer({ 132 | flexbox: 'no-2009' 133 | }) 134 | ] 135 | } 136 | }, 137 | 'sass-loader', 138 | 'import-glob-loader' 139 | ].filter(Boolean) 140 | } 141 | ].filter(Boolean) 142 | }; 143 | 144 | config.optimization = IS_DEV 145 | ? {} 146 | : { 147 | minimizer: [ 148 | new UglifyJsPlugin({ 149 | parallel: true, 150 | sourceMap: true, 151 | uglifyOptions: { 152 | output: { 153 | comments: false 154 | } 155 | } 156 | }), 157 | new OptimizeCSSAssetsPlugin({}) 158 | ] 159 | }; 160 | 161 | config.plugins = [ 162 | new webpack.DefinePlugin(env.forWebpackDefinePlugin), 163 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), 164 | new LodashModuleReplacementPlugin(), 165 | IS_DEV && new webpack.HotModuleReplacementPlugin(), 166 | IS_DEV && new CaseSensitivePathsPlugin(), 167 | IS_DEV && new ErrorOverlayPlugin(), 168 | IS_PROD && 169 | new MiniCssExtractPlugin({ 170 | filename: 'static/css/[name].[contenthash:8].css' 171 | }), 172 | IS_PROD && 173 | new ManifestPlugin({ 174 | fileName: 'asset-manifest.json' 175 | }), 176 | new ReactLoadablePlugin({ 177 | filename: 'build/react-loadable.json' 178 | }) 179 | ].filter(Boolean); 180 | 181 | config.node = { 182 | dgram: 'empty', 183 | fs: 'empty', 184 | net: 'empty', 185 | tls: 'empty' 186 | }; 187 | 188 | return config; 189 | }; 190 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This project is not actively maintained 2 | 3 | ## React Server Side Rendering Boilerplate ⚛️ 4 | 5 | Tools like [create-react-app](https://github.com/facebook/create-react-app) have made setting up client-side React apps trivial, but transitioning to SSR is still kind of a pain in the ass. [Next.js](https://nextjs.org) is a powerhouse, and the [Razzle](https://github.com/jaredpalmer/razzle) tool looks like an absolute beast, but sometimes you just want to see the whole enchilada running your app. This is a sample setup for fully featured, server-rendered React applications. 6 | 7 | **What's included:** 8 | 9 | - Server-side rendering with code splitting (via the excellent [React Loadable](https://github.com/thejameskyle/react-loadable) package) 10 | - Server-side data fetching and client-side hydration 11 | - React Router 12 | - Conditionally load pollyfills -- only ship bloat to outdated browsers 13 | - React Helmet for dynamic manipulation of the document `` 14 | - Dev server with hot reloading styles 15 | - Jest and react-testing-library ready to test the crap out of some stuff 16 | - CSS Modules, Sass, and autoprefixer 17 | - Run-time environment variables 18 | - Node.js clusters for improved performance under load (in production) 19 | - Prettier and ESLint run on commit 20 | - Docker-ized for production like a bawsss 21 | 22 | ## Initial setup 23 | 24 | - `npm install` 25 | 26 | ## Development 27 | 28 | - `npm start` 29 | - Start the dev server at [http://localhost:3000](http://localhost:3000) 30 | - `npm test` 31 | - Start `jest` in watch mode 32 | 33 | ## Production 34 | 35 | - `npm run build && npm run start:prod` 36 | - Bundle the JS and fire up the Express server for production 37 | - `npm run docker` 38 | - Build and start a local Docker image in production mode (mostly useful for debugging) 39 | 40 | ## General architecture 41 | 42 | This app has two main pieces: the server and the client code. 43 | 44 | #### Server (`server/`) 45 | 46 | A fairly basic Express application in `server/app.js` handles serving static assets (the generated CSS and JS code in `build/` + anything in `public/` like images and fonts), and sends all other requests to the React application via `server/renderServerSideApp.js`. That function delegates the fetching of server-side data fetching to `server/fetchDataForRender`, and then sends the rendered React application (as a string) injected inside the HTML-ish code in `server/indexHtml.js`. 47 | 48 | During development the server code is run with `@babel/register` and middleware is added to the Express app (see `scripts/start`), and in production we bundle the server code to `build/server` and the code in `scripts/startProd` is used to run the server with Node's `cluster` module to take advantage of multiple CPU cores. 49 | 50 | #### Client (`src/`) 51 | 52 | The entrypoint for the client-side code (`src/index.js`) first checks if the current browser needs to be polyfilled and then defers to `src/main.js` to hydrate the React application. These two files are only ever called on the client, so you can safely reference any browser APIs here without anything fancy. The rest of the client code is a React application -- in this case a super basic UI w/2 routes, but you can safely modify/delete nearly everything inside `src/` and make it your own. 53 | 54 | As with all server-rendered React apps you'll want to be cautious of using browser APIs in your components -- they don't exist when rendering on the server and will throw errors unless you handle them gracefully (I've found some success with using `if (typeof myBrowserAPI !== 'undefined') { ... }` checks when necessary, but it feels dirty so I try to avoid when possible). The one exception to this is the `componentDidMount()` method for class components and `useEffect()` & `useLayoutEffect()` hooks, which are only run on the client. 55 | 56 | ## "How do I ...?" 57 | 58 | #### Fetch data on the server before rendering? 59 | 60 | _The client-side sample code to handle is a little experimental at the moment._ 61 | 62 | Sometimes you'll want to make API calls on the server to fetch data **before** rendering the page. In those cases you can use a static `fetchData()` method on any component. That method will be called with the `req` object from express, and it should return a Promise that resolves to an object, which will be merged with other `fetchData()` return values into a single object. That object of server data is injected into the server HTML, added to `window.__SERVER_DATA__`, and used to hydrate the client via the `` context provider. Components can use the `useServerData()` hook to grab the data object. **IMPORTANT:** Your component must handle the case where the server data property it's reading from is `undefined`. 63 | 64 | Check out `src/components/Home.js` for an example. 65 | 66 | #### Add Redux? 67 | 68 | Adding `redux` takes a few steps, but shouldn't be too painful; start by replacing the `` with the `` from `react-redux` on both the server and the client. You can then pass the `store` as an argument to the static `fetchData()` method (in `server/fetchDataForRender.js`) and dispatch actions inside of `fetchData()`. Finally you'll need to pass the `store`'s current state to the index.html generator function so you can grab it on the client and hydrate the client-side `store`. 69 | 70 | ## Current Quirks 71 | 72 | - There are console message saying "componentWillMount has been renamed, and is not recommended for use." due to the react-loadable package. Hopefully React will support SSR with Suspense soon, but until then react-loadable works great and the console messages should not affect your app. 73 | - This project does not have a webpack configuration that allows for the use of `url-loader` or `file-loader` (so no `import src from 'my-img.svg'`). Instead it relies on serving static assets via the `public/` directory. See `src/components/about/About.js` for a reference on how to work with assets in your app. 74 | 75 | ## Roadmap 76 | 77 | - [ ] Run server via webpack in dev mode so we can use more loaders 78 | - [x] Intelligently resolve CSS modules by looking for a `.module.s?css` file extension 79 | - [ ] Add example app that handles authentication 80 | - [x] Migrate to `react-testing-library` instead of `enzyme` 81 | --------------------------------------------------------------------------------