├── .babelrc ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── package-lock.json ├── package.json ├── src ├── App.css ├── api.js ├── client.js ├── components │ ├── About.js │ ├── Contact.js │ ├── Header.js │ ├── Home.js │ ├── Layout.js │ └── Secret.js ├── routes.js ├── server.js └── store.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ "env", 4 | { 5 | "modules": false, 6 | "useBuiltIns": "usage" 7 | } 8 | ], 9 | "react", 10 | "jest" 11 | ], 12 | "env": { 13 | "test": { 14 | "presets": ["react"], 15 | "plugins": ["transform-es2015-modules-commonjs"] 16 | } 17 | 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "fortech-react", 3 | "env": { 4 | "browser": true, 5 | "jest": true 6 | }, 7 | "rules": { 8 | "react/prop-types": 0 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alex Moldovan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [DEPRECATED] React Server Side Rendering 2 | This is no longer the recommended way to approach server side rendering with React. For an all-round solution look at a starter with `next.js` or `remix` 3 | 4 | ## Getting started 5 | Clone the repo with 6 | ```git clone https://github.com/alexnm/react-ssr``` 7 | 8 | Install dependencies with 9 | ```npm i``` 10 | 11 | Run dev mode with 12 | ```npm run dev``` 13 | 14 | Now open the browser and navigate to `http://localhost:2048` and you get your server rendered React app. You can inspect the page source and see that the html coming from your local server has all the nodes defined in the React app. 15 | 16 | ### A few notes 17 | * I tried to limit the complexity of the entire app to focus on the server side rendering part. Don't take the same shortcuts in your production app! 18 | * We're starting the server with the `index.js` file which is in the root folder. This file loads the babel-register and sets up the babel plugins needed to run JSX and ESModules on the server. 19 | * The node server needs to handle the static files from the `dist` folder. 20 | * The entry point of the bundle is called `client.js` because it's the only part of our application that is not used for the server render. 21 | 22 | ## Navigating through the different steps 23 | Understand the different parts of server side rendering by going through each tag: 24 | * [Base example](https://github.com/alexnm/react-ssr/tree/basic) 25 | * [Adding React Router](https://github.com/alexnm/react-ssr/tree/router) 26 | * [Adding Redux](https://github.com/alexnm/react-ssr/tree/redux) 27 | * [Data Fetching](https://github.com/alexnm/react-ssr/tree/fetch-data) 28 | * [Using React Helmet](https://github.com/alexnm/react-ssr/tree/helmet) 29 | 30 | ## In depth explanations 31 | Read more about [implementing server side rendering](https://medium.com/@alexnm/demystifying-reacts-server-side-render-de335d408fe4) step by step. Feedback is more than welcome! 32 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require( "babel-register" )( { 2 | presets: [ "env" ], 3 | plugins: [ 4 | [ 5 | "css-modules-transform", 6 | { 7 | camelCase: true, 8 | extensions: [ ".css", ".scss" ], 9 | }, 10 | ], 11 | "dynamic-import-node", 12 | ], 13 | } ); 14 | require( "./src/server" ); 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ssr", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "webpack.config.js", 6 | "scripts": { 7 | "build": "NODE_ENV=production webpack --progress", 8 | "dev": "npm-run-all --parallel server client", 9 | "server": "nodemon index.js", 10 | "client": "webpack --watch --progress", 11 | "linter": "eslint src", 12 | "bundlesize": "bundlesize", 13 | "precommit": "eslint src --quiet" 14 | }, 15 | "dependencies": { 16 | "isomorphic-fetch": "^2.2.1", 17 | "react": "^16.0.0", 18 | "react-dom": "^16.0.0", 19 | "react-helmet": "^5.2.0", 20 | "react-redux": "^5.0.7", 21 | "react-router-dom": "^4.2.2", 22 | "redux": "^3.7.2", 23 | "redux-thunk": "^2.2.0", 24 | "serialize-javascript": "^2.1.1" 25 | }, 26 | "devDependencies": { 27 | "babel-core": "^6.26.0", 28 | "babel-eslint": "^8.0.2", 29 | "babel-jest": "^21.2.0", 30 | "babel-loader": "^7.1.2", 31 | "babel-plugin-css-modules-transform": "^1.6.2", 32 | "babel-plugin-dynamic-import-node": "^2.3.0", 33 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", 34 | "babel-preset-env": "^1.6.1", 35 | "babel-preset-react": "^6.3.13", 36 | "babel-register": "^6.26.0", 37 | "bundlesize": "^0.16.0", 38 | "css-loader": "^3.0.0", 39 | "enzyme": "^3.2.0", 40 | "enzyme-adapter-react-16": "^1.1.0", 41 | "eslint": "^4.12.0", 42 | "eslint-config-fortech-react": "^1.0.2", 43 | "eslint-loader": "^1.9.0", 44 | "eslint-plugin-import": "^2.8.0", 45 | "eslint-plugin-jsx-a11y": "^6.0.2", 46 | "eslint-plugin-react": "^7.5.1", 47 | "express": "^4.16.3", 48 | "friendly-errors-webpack-plugin": "^1.6.1", 49 | "husky": "^0.14.3", 50 | "jest": "^21.2.1", 51 | "mini-css-extract-plugin": "^0.7.0", 52 | "nodemon": "^1.17.2", 53 | "npm-run-all": "^4.1.3", 54 | "style-loader": "^0.23.1", 55 | "webpack": "^4.19.1", 56 | "webpack-bundle-analyzer": "^2.13.1", 57 | "webpack-cli": "^3.3.2", 58 | "webpack-dev-server": "^3.0.0" 59 | }, 60 | "browserslist": [ 61 | "> 1%", 62 | "last 2 versions", 63 | "Firefox ESR" 64 | ], 65 | "bundlesize": [ 66 | { 67 | "path": "./dist/js/app.bundle.js", 68 | "maxSize": "170 kB" 69 | }, 70 | { 71 | "path": "./dist/js/lib.bundle.js", 72 | "maxSize": "110 kB" 73 | } 74 | ], 75 | "jest": { 76 | "transform": { 77 | "^.+\\.jsx$": "babel-jest", 78 | "^.+\\.js$": "babel-jest" 79 | }, 80 | "setupTestFrameworkScriptFile": "/test-config/test-setup.js" 81 | }, 82 | "author": "", 83 | "license": "MIT" 84 | } 85 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arimo, 'sans-serif'; 3 | font-size: 14px; 4 | } 5 | 6 | .link { 7 | padding: 8px 10px; 8 | border-radius: 3px; 9 | } 10 | 11 | .link:hover { 12 | background: rgba(0, 0, 0, .1); 13 | } 14 | 15 | .wrapper { 16 | border: 1px solid rgba(0, 0, 0, 0.2); 17 | padding: 5px 20px; 18 | border-radius: 2px; 19 | margin-top: 20px; 20 | background: rgba(128, 128, 128, .1); 21 | } -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | import fetch from "isomorphic-fetch"; 2 | 3 | export function fetchCircuits( ) { 4 | return fetch( "http://ergast.com/api/f1/2018/circuits.json" ) 5 | .then( res => res.json( ) ) 6 | .then( res => res.MRData.CircuitTable.Circuits ); 7 | } 8 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { BrowserRouter as Router } from "react-router-dom"; 4 | import { Provider as ReduxProvider } from "react-redux"; 5 | 6 | import Layout from "./components/Layout"; 7 | import createStore from "./store"; 8 | import "./App.css"; 9 | 10 | const store = createStore( window.REDUX_DATA ); 11 | 12 | const jsx = ( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | 20 | const app = document.getElementById( "app" ); 21 | ReactDOM.hydrate( jsx, app ); 22 | -------------------------------------------------------------------------------- /src/components/About.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const About = () => ( 4 |
5 |

This is the about page

6 |
7 | ); 8 | 9 | export default About; 10 | -------------------------------------------------------------------------------- /src/components/Contact.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Helmet from "react-helmet"; 3 | 4 | const Contact = () => ( 5 |
6 |

This is the contact page

7 | 8 | Contact Page 9 | 10 | 11 |
12 | ); 13 | 14 | export default Contact; 15 | -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { connect } from "react-redux"; 4 | 5 | const Header = ( { loggedIn } ) => ( 6 |
7 | Home 8 | About 9 | Contact 10 | { loggedIn && Secret } 11 |
12 | ); 13 | 14 | const mapStateToProps = ( state ) => ( { 15 | loggedIn: state.loggedIn, 16 | } ); 17 | 18 | export default connect( mapStateToProps )( Header ); 19 | -------------------------------------------------------------------------------- /src/components/Home.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { fetchData } from "../store"; 4 | 5 | class Home extends React.Component { 6 | componentDidMount( ) { 7 | if ( this.props.circuits.length <= 0 ) { 8 | this.props.fetchData( ); 9 | } 10 | } 11 | 12 | render( ) { 13 | const { circuits } = this.props; 14 | 15 | return ( 16 |
17 |

F1 2018 Season Calendar

18 | 23 |
24 | ); 25 | } 26 | } 27 | Home.serverFetch = fetchData; // static declaration of data requirements 28 | 29 | const mapStateToProps = ( state ) => ( { 30 | circuits: state.data, 31 | } ); 32 | 33 | const mapDispatchToProps = { 34 | fetchData, 35 | }; 36 | 37 | export default connect( mapStateToProps, mapDispatchToProps )( Home ); 38 | -------------------------------------------------------------------------------- /src/components/Layout.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Switch, Route } from "react-router-dom"; 3 | import Header from "./Header"; 4 | import routes from "../routes"; 5 | 6 | class Layout extends React.Component { 7 | constructor() { 8 | super(); 9 | this.state = { 10 | title: "Welcome to React SSR!", 11 | }; 12 | } 13 | 14 | render() { 15 | return ( 16 |
17 |

{ this.state.title }

18 |
19 | 20 | { routes.map( route => ) } 21 | 22 |
23 | ); 24 | } 25 | } 26 | 27 | export default Layout; 28 | -------------------------------------------------------------------------------- /src/components/Secret.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Secret = () => ( 4 |
5 |

This is the secret page

6 |
7 | ); 8 | 9 | export default Secret; 10 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import Home from "./components/Home"; 2 | import About from "./components/About"; 3 | import Contact from "./components/Contact"; 4 | import Secret from "./components/Secret"; 5 | 6 | export default [ 7 | { 8 | path: "/", 9 | component: Home, 10 | exact: true, 11 | }, 12 | { 13 | path: "/about", 14 | component: About, 15 | exact: true, 16 | }, 17 | { 18 | path: "/contact", 19 | component: Contact, 20 | exact: true, 21 | }, 22 | { 23 | path: "/secret", 24 | component: Secret, 25 | exact: true, 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import path from "path"; 3 | 4 | import React from "react"; 5 | import serialize from "serialize-javascript"; 6 | import { renderToString } from "react-dom/server"; 7 | import { StaticRouter, matchPath } from "react-router-dom"; 8 | import { Provider as ReduxProvider } from "react-redux"; 9 | import Helmet from "react-helmet"; 10 | import routes from "./routes"; 11 | import Layout from "./components/Layout"; 12 | import createStore, { initializeSession } from "./store"; 13 | 14 | const app = express(); 15 | 16 | app.use( express.static( path.resolve( __dirname, "../dist" ) ) ); 17 | 18 | app.get( "/*", ( req, res ) => { 19 | const context = { }; 20 | const store = createStore( ); 21 | 22 | store.dispatch( initializeSession( ) ); 23 | 24 | const dataRequirements = 25 | routes 26 | .filter( route => matchPath( req.url, route ) ) // filter matching paths 27 | .map( route => route.component ) // map to components 28 | .filter( comp => comp.serverFetch ) // check if components have data requirement 29 | .map( comp => store.dispatch( comp.serverFetch( ) ) ); // dispatch data requirement 30 | 31 | Promise.all( dataRequirements ).then( ( ) => { 32 | const jsx = ( 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | const reactDom = renderToString( jsx ); 40 | const reduxState = store.getState( ); 41 | const helmetData = Helmet.renderStatic( ); 42 | 43 | res.writeHead( 200, { "Content-Type": "text/html" } ); 44 | res.end( htmlTemplate( reactDom, reduxState, helmetData ) ); 45 | } ); 46 | } ); 47 | 48 | app.listen( 2048 ); 49 | 50 | function htmlTemplate( reactDom, reduxState, helmetData ) { 51 | return ` 52 | 53 | 54 | 55 | 56 | ${ helmetData.title.toString( ) } 57 | ${ helmetData.meta.toString( ) } 58 | React SSR 59 | 60 | 61 | 62 | 63 |
${ reactDom }
64 | 67 | 68 | 69 | 70 | `; 71 | } 72 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware } from "redux"; 2 | import thunkMiddleware from "redux-thunk"; 3 | import { fetchCircuits } from "./api"; 4 | 5 | export const initializeSession = ( ) => ( { 6 | type: "INITIALIZE_SESSION", 7 | } ); 8 | 9 | const storeData = ( data ) => ( { 10 | type: "STORE_DATA", 11 | data, 12 | } ); 13 | 14 | export const fetchData = ( ) => ( dispatch ) => 15 | fetchCircuits( ).then( res => dispatch( storeData( res ) ) ); 16 | 17 | const sessionReducer = ( state = false, action ) => { 18 | switch ( action.type ) { 19 | case "INITIALIZE_SESSION": 20 | return true; 21 | default: return state; 22 | } 23 | }; 24 | 25 | const dataReducer = ( state = [ ], action ) => { 26 | switch ( action.type ) { 27 | case "STORE_DATA": 28 | return action.data; 29 | default: return state; 30 | } 31 | }; 32 | 33 | const reducer = combineReducers( { 34 | loggedIn: sessionReducer, 35 | data: dataReducer, 36 | } ); 37 | 38 | export default ( initialState ) => 39 | createStore( reducer, initialState, applyMiddleware( thunkMiddleware ) ); 40 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const dev = process.env.NODE_ENV !== "production"; 2 | const path = require( "path" ); 3 | const { BundleAnalyzerPlugin } = require( "webpack-bundle-analyzer" ); 4 | const FriendlyErrorsWebpackPlugin = require( "friendly-errors-webpack-plugin" ); 5 | const MiniCssExtractPlugin = require( "mini-css-extract-plugin" ); 6 | 7 | const plugins = [ 8 | new FriendlyErrorsWebpackPlugin(), 9 | new MiniCssExtractPlugin({ 10 | filename: "styles.css", 11 | }), 12 | ]; 13 | 14 | if ( !dev ) { 15 | plugins.push( new BundleAnalyzerPlugin( { 16 | analyzerMode: "static", 17 | reportFilename: "webpack-report.html", 18 | openAnalyzer: false, 19 | } ) ); 20 | } 21 | 22 | module.exports = { 23 | mode: dev ? "development" : "production", 24 | context: path.join( __dirname, "src" ), 25 | devtool: dev ? "none" : "source-map", 26 | entry: { 27 | app: "./client.js", 28 | }, 29 | resolve: { 30 | modules: [ 31 | path.resolve( "./src" ), 32 | "node_modules", 33 | ], 34 | }, 35 | module: { 36 | rules: [ 37 | { 38 | test: /\.jsx?$/, 39 | exclude: /(node_modules|bower_components)/, 40 | loader: "babel-loader", 41 | }, { 42 | test: /\.css$/, 43 | use: [ 44 | { 45 | loader: MiniCssExtractPlugin.loader, 46 | }, 47 | "css-loader", 48 | ], 49 | }, 50 | ], 51 | }, 52 | output: { 53 | path: path.resolve( __dirname, "dist" ), 54 | filename: "[name].bundle.js", 55 | }, 56 | plugins, 57 | }; 58 | --------------------------------------------------------------------------------