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 |
--------------------------------------------------------------------------------