├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo.svg └── manifest.json ├── server ├── bootstrap.js ├── controllers │ └── index.js ├── index.js └── middleware │ └── renderer.js ├── src ├── App.css ├── App.js ├── App.test.js ├── PageAnother.js ├── PageDefault.js ├── SomeComponent.js ├── index.css ├── index.js ├── logo.svg ├── registerServiceWorker.js └── store │ ├── appReducer.js │ └── configureStore.js └── yarn.lock /.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.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## What's this? 2 | 3 | This is a sample app to demonstrate how you can achieve both **Server Side Rendering** _and_ **Code Splitting** in a create-react-app. 4 | It supports the mini-series I've wrote on _Medium_ about this topic: 5 | 6 | 1. [Upgrading a create-react-app project to a SSR + Code Splitting setup](http://medium.com/bucharestjs/upgrading-a-create-react-app-project-to-a-ssr-code-splitting-setup-9da57df2040a) 7 | 2. [Adding state management with Redux in a CRA + SSR project](https://medium.com/bucharestjs/adding-state-management-with-redux-in-a-cra-srr-project-9798d74dbb3b) 8 | 9 | 10 | ## How can I see it in action? 11 | 12 | Just install dependencies, build the app and run the express server: 13 | 14 | ``` 15 | yarn install 16 | yarn run build 17 | yarn run server 18 | ``` 19 | 20 | ## Can I use this as a template for a production app? 21 | 22 | **NO!** This repo exists only to demonstrate how to achieve SSR and Code Splitting at the same time. 23 | 24 | _But... Why?_ --- The server app is as slim as it can get. It lacks even the most basic security features like XSS and CSRF. 25 | 26 | **This is not a boilerplate for a production expressjs server app!!!** 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cra-ssr-code-splitting", 3 | "version": "0.1.0", 4 | "private": false, 5 | "homepage": "./", 6 | "dependencies": { 7 | "@babel/register": "^7.0.0", 8 | "babel-plugin-dynamic-import-node": "^2.0.0", 9 | "babel-plugin-syntax-dynamic-import": "^6.18.0", 10 | "express": "^4.16.2", 11 | "file-loader": "^2.0.0", 12 | "ignore-styles": "^5.0.1", 13 | "react": "^16.0.0", 14 | "react-dom": "^16.0.0", 15 | "react-helmet": "^5.2.0", 16 | "react-loadable": "^5.3.1", 17 | "react-redux": "^5.0.6", 18 | "react-router": "^4.3.1", 19 | "react-router-dom": "^4.3.1", 20 | "react-scripts": "^2.1.1", 21 | "redux": "^4.0.0", 22 | "redux-thunk": "^2.3.0", 23 | "url-loader": "^1.0.1" 24 | }, 25 | "resolutions": { 26 | "babel-core": "7.0.0-bridge.0" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "react-scripts test --env=jsdom", 32 | "eject": "react-scripts eject", 33 | "server": "NODE_ENV=production node server/bootstrap.js" 34 | }, 35 | "browserslist": [ 36 | ">0.2%", 37 | "not dead", 38 | "not ie <= 11", 39 | "not op_mini all" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreiduca/cra-ssr-code-splitting/909ae540f2f10604df507a5f5d64f65a31768e8b/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 23 | 24 | 25 | 26 | 29 |
30 | 31 | 34 | 35 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /server/bootstrap.js: -------------------------------------------------------------------------------- 1 | require('ignore-styles'); 2 | require('url-loader'); 3 | require('file-loader'); 4 | require('@babel/register')({ 5 | ignore: [ /(node_modules)/ ], 6 | presets: ['@babel/preset-env', '@babel/preset-react'], 7 | plugins: [ 8 | 'syntax-dynamic-import', 9 | 'dynamic-import-node', 10 | 'react-loadable/babel' 11 | ] 12 | }); 13 | require('./index'); 14 | -------------------------------------------------------------------------------- /server/controllers/index.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | 3 | import serverRenderer from '../middleware/renderer'; 4 | import configureStore from '../../src/store/configureStore'; 5 | import { setAsyncMessage } from '../../src/store/appReducer'; 6 | 7 | const router = express.Router(); 8 | const path = require("path"); 9 | 10 | 11 | const actionIndex = (req, res, next) => { 12 | const store = configureStore(); 13 | 14 | store.dispatch(setAsyncMessage("Hi, I'm from server!")) 15 | .then(() => { 16 | serverRenderer(store)(req, res, next); 17 | }); 18 | }; 19 | 20 | 21 | // root (/) should always serve our server rendered page 22 | router.use('^/$', actionIndex); 23 | 24 | // other static resources should just be served as they are 25 | router.use(express.static( 26 | path.resolve(__dirname, '..', '..', 'build'), 27 | { maxAge: '30d' }, 28 | )); 29 | 30 | // any other route should be handled by react-router, so serve the index page 31 | router.use('*', actionIndex); 32 | 33 | 34 | export default router; 35 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Loadable from 'react-loadable'; 3 | 4 | import indexController from './controllers/index'; 5 | 6 | const PORT = 3000; 7 | 8 | // initialize the application and create the routes 9 | const app = express(); 10 | 11 | app.use(indexController); 12 | 13 | // start the app 14 | Loadable.preloadAll().then(() => { 15 | app.listen(PORT, (error) => { 16 | if (error) { 17 | return console.log('something bad happened', error); 18 | } 19 | 20 | console.log("listening on " + PORT + "..."); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /server/middleware/renderer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOMServer from 'react-dom/server' 3 | import Loadable from 'react-loadable'; 4 | import { Provider as ReduxProvider } from 'react-redux' 5 | import { StaticRouter } from 'react-router'; 6 | import { Helmet } from 'react-helmet'; 7 | 8 | // import our main App component 9 | import App from '../../src/App'; 10 | 11 | // import the manifest generated with the create-react-app build 12 | import manifest from '../../build/asset-manifest.json'; 13 | // function to extract js assets from the manifest 14 | const extractAssets = (assets, chunks) => Object.keys(assets) 15 | .filter(asset => chunks.indexOf(asset.replace('.js', '')) > -1) 16 | .map(k => assets[k]); 17 | 18 | 19 | const path = require("path"); 20 | const fs = require("fs"); 21 | 22 | 23 | export default (store) => (req, res, next) => { 24 | // get the html file created with the create-react-app build 25 | const filePath = path.resolve(__dirname, '..', '..', 'build', 'index.html'); 26 | 27 | fs.readFile(filePath, 'utf8', (err, htmlData) => { 28 | if (err) { 29 | console.error('err', err); 30 | return res.status(404).end() 31 | } 32 | 33 | const modules = []; 34 | const routerContext = {}; 35 | 36 | // render the app as a string 37 | const html = ReactDOMServer.renderToString( 38 | modules.push(m)}> 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | 47 | // get the stringified state 48 | const reduxState = JSON.stringify(store.getState()); 49 | 50 | // map required assets to script tags 51 | const extraChunks = extractAssets(manifest, modules) 52 | .map(c => ``); 53 | 54 | // get HTML headers 55 | const helmet = Helmet.renderStatic(); 56 | 57 | // now inject the rendered app into our html and send it to the client 58 | return res.send( 59 | htmlData 60 | // write the React app 61 | .replace('
', `
${html}
`) 62 | // write the string version of our state 63 | .replace('__REDUX_STATE__={}', `__REDUX_STATE__=${reduxState}`) 64 | // append the extra js assets 65 | .replace('', extraChunks.join('') + '') 66 | // write the HTML header tags 67 | .replace('', helmet.title.toString() + helmet.meta.toString()) 68 | ); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .App-header { 11 | background-color: #222; 12 | height: 150px; 13 | padding: 20px; 14 | color: white; 15 | } 16 | 17 | .App-title { 18 | font-size: 1.5em; 19 | } 20 | 21 | .App-intro { 22 | font-size: large; 23 | } 24 | 25 | @keyframes App-logo-spin { 26 | from { transform: rotate(0deg); } 27 | to { transform: rotate(360deg); } 28 | } 29 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Loadable from 'react-loadable'; 3 | import { connect } from 'react-redux'; 4 | import { withRouter } from 'react-router'; 5 | import { Route, Switch, NavLink } from 'react-router-dom'; 6 | 7 | import { setMessage } from './store/appReducer'; 8 | 9 | // import logo from './logo.svg'; 10 | import './App.css'; 11 | 12 | 13 | const AsyncComponent = Loadable({ 14 | loader: () => import(/* webpackChunkName: "myNamedChunk" */ './SomeComponent'), 15 | loading: () =>
loading...
, 16 | modules: ['myNamedChunk'], 17 | }); 18 | 19 | const AsyncPageDefault = Loadable({ 20 | loader: () => import(/* webpackChunkName: "pageDefault" */ './PageDefault'), 21 | loading: () =>
loading page...
, 22 | modules: ['pageDefault'], 23 | }); 24 | 25 | const AsyncPageAnother = Loadable({ 26 | loader: () => import(/* webpackChunkName: "pageAnother" */ './PageAnother'), 27 | loading: () =>
loading another page...
, 28 | modules: ['pageAnother'], 29 | }); 30 | 31 | class App extends Component { 32 | componentDidMount() { 33 | if(!this.props.message) { 34 | this.props.updateMessage("Hi, I'm from client!"); 35 | } 36 | } 37 | 38 | render() { 39 | return ( 40 |
41 |
42 | logo 43 |

Welcome to React

44 |
45 |
46 |

Part 1: Async component

47 | 48 | 49 |
50 | 51 |

Part 2: Redux store

52 |

53 | Redux: { this.props.message } 54 |

55 | 56 |
57 | 58 |

Part 3: React router

59 | 63 | 64 | 65 | 66 | 67 |
68 |
69 | ); 70 | } 71 | } 72 | 73 | export default withRouter( 74 | connect( 75 | ({ app }) => ({ 76 | message: app.message, 77 | }), 78 | dispatch => ({ 79 | updateMessage: (messageText) => dispatch(setMessage(messageText)), 80 | }) 81 | )(App) 82 | ); 83 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | }); 9 | -------------------------------------------------------------------------------- /src/PageAnother.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () => ( 4 |
This is for another page.
5 | ); 6 | -------------------------------------------------------------------------------- /src/PageDefault.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () => ( 4 |
This is for the main page.
5 | ); 6 | -------------------------------------------------------------------------------- /src/SomeComponent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () => ( 4 |

Hi, I'm async.

5 | ); 6 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | 7 | nav a { 8 | margin-bottom: 30px; 9 | } 10 | 11 | nav a { 12 | display: inline-block; 13 | } 14 | 15 | nav a.active { 16 | color: #222222; 17 | text-decoration: none; 18 | } 19 | 20 | nav a + a { 21 | margin-left: 10px; 22 | padding-left: 10px; 23 | border-left: 1px solid #222; 24 | } 25 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Loadable from 'react-loadable'; 4 | import { Provider as ReduxProvider } from 'react-redux' 5 | import { BrowserRouter } from 'react-router-dom'; 6 | 7 | import './index.css'; 8 | import App from './App'; 9 | import configureStore from './store/configureStore'; 10 | // import registerServiceWorker from './registerServiceWorker'; 11 | 12 | const store = configureStore( window.__REDUX_STATE__ || {} ); 13 | 14 | const AppBundle = ( 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | 22 | window.onload = () => { 23 | Loadable.preloadReady().then(() => { 24 | ReactDOM.hydrate( 25 | AppBundle, 26 | document.getElementById('root') 27 | ); 28 | }); 29 | }; 30 | 31 | // registerServiceWorker(); 32 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | } else { 39 | // Is not local host. Just register service worker 40 | registerValidSW(swUrl); 41 | } 42 | }); 43 | } 44 | } 45 | 46 | function registerValidSW(swUrl) { 47 | navigator.serviceWorker 48 | .register(swUrl) 49 | .then(registration => { 50 | registration.onupdatefound = () => { 51 | const installingWorker = registration.installing; 52 | installingWorker.onstatechange = () => { 53 | if (installingWorker.state === 'installed') { 54 | if (navigator.serviceWorker.controller) { 55 | // At this point, the old content will have been purged and 56 | // the fresh content will have been added to the cache. 57 | // It's the perfect time to display a "New content is 58 | // available; please refresh." message in your web app. 59 | console.log('New content is available; please refresh.'); 60 | } else { 61 | // At this point, everything has been precached. 62 | // It's the perfect time to display a 63 | // "Content is cached for offline use." message. 64 | console.log('Content is cached for offline use.'); 65 | } 66 | } 67 | }; 68 | }; 69 | }) 70 | .catch(error => { 71 | console.error('Error during service worker registration:', error); 72 | }); 73 | } 74 | 75 | function checkValidServiceWorker(swUrl) { 76 | // Check if the service worker can be found. If it can't reload the page. 77 | fetch(swUrl) 78 | .then(response => { 79 | // Ensure service worker exists, and that we really are getting a JS file. 80 | if ( 81 | response.status === 404 || 82 | response.headers.get('content-type').indexOf('javascript') === -1 83 | ) { 84 | // No service worker found. Probably a different app. Reload the page. 85 | navigator.serviceWorker.ready.then(registration => { 86 | registration.unregister().then(() => { 87 | window.location.reload(); 88 | }); 89 | }); 90 | } else { 91 | // Service worker found. Proceed as normal. 92 | registerValidSW(swUrl); 93 | } 94 | }) 95 | .catch(() => { 96 | console.log( 97 | 'No internet connection found. App is running in offline mode.' 98 | ); 99 | }); 100 | } 101 | 102 | export function unregister() { 103 | if ('serviceWorker' in navigator) { 104 | navigator.serviceWorker.ready.then(registration => { 105 | registration.unregister(); 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/store/appReducer.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | message: null, 3 | }; 4 | 5 | export const appReducer = (state = initialState, action) => { 6 | switch(action.type) { 7 | case 'SET_MESSAGE': 8 | return { 9 | ...state, 10 | message: action.message, 11 | }; 12 | default: 13 | return state; 14 | } 15 | }; 16 | 17 | export const setMessage = messageText => ({ type: 'SET_MESSAGE', message: messageText }); 18 | 19 | export const setAsyncMessage = messageText => dispatch => ( 20 | new Promise((resolve, reject) => { 21 | setTimeout(() => resolve(), 2000); 22 | }) 23 | .then(() => dispatch(setMessage(messageText))) 24 | ); 25 | -------------------------------------------------------------------------------- /src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { 2 | createStore, 3 | combineReducers, 4 | compose, 5 | applyMiddleware, 6 | } from 'redux'; 7 | 8 | import ReduxThunk from 'redux-thunk' 9 | 10 | import { appReducer } from './appReducer'; 11 | 12 | // if you're also using redux-thunk, add it as a middleware 13 | const createStoreWithMiddleware = compose(applyMiddleware(ReduxThunk))(createStore); 14 | 15 | const rootReducer = combineReducers({ 16 | app: appReducer, 17 | }); 18 | 19 | export default function configureStore(initialState = {}) { 20 | return createStoreWithMiddleware(rootReducer, initialState); 21 | }; 22 | --------------------------------------------------------------------------------