├── .prettierignore ├── .env.example ├── .gitignore ├── .eslintignore ├── src ├── layouts │ └── CoreLayout │ │ ├── CoreLayout.css │ │ ├── index.js │ │ └── CoreLayout.js ├── routes │ ├── Home │ │ ├── index.js │ │ ├── back.png │ │ ├── HomeView.css │ │ ├── TestView.js │ │ └── HomeView.js │ ├── NotFoundPage │ │ ├── NotFoundPage.css │ │ ├── index.js │ │ └── NotFoundPage.js │ └── index.js ├── components │ ├── RouterStatus │ │ ├── index.js │ │ └── RouterStatus.js │ └── RedirectWithStatus │ │ ├── index.js │ │ └── RedirectWithStatus.js ├── constant.js ├── url.js ├── utils │ └── fetch.js └── renderer │ ├── root.js │ ├── server.js │ ├── client.js │ ├── serverRenderer.js │ └── app.js ├── .prettierrc ├── .travis.yml ├── jsconfig.json ├── babel.config.js ├── config ├── env.config.js ├── webpack.server.config.babel.js ├── webpack.base.config.js ├── project.config.js └── webpack.client.config.babel.js ├── .client.babelrc ├── .server.babelrc ├── .eslintrc ├── README.md └── package.json /.prettierignore: -------------------------------------------------------------------------------- 1 | .babelrc -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_BACKEND_URL=https://example.com -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | public 4 | dist 5 | .env 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | build/** 3 | public/** 4 | dist/** 5 | -------------------------------------------------------------------------------- /src/layouts/CoreLayout/CoreLayout.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "singleQuote": false, 4 | "semi": false 5 | } 6 | -------------------------------------------------------------------------------- /src/routes/Home/index.js: -------------------------------------------------------------------------------- 1 | import HomeView from "./HomeView" 2 | 3 | export default HomeView 4 | -------------------------------------------------------------------------------- /src/routes/NotFoundPage/NotFoundPage.css: -------------------------------------------------------------------------------- 1 | .not-found-page { 2 | background-color: aquamarine; 3 | } 4 | -------------------------------------------------------------------------------- /src/routes/Home/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antonybudianto/react-ssr-starter/HEAD/src/routes/Home/back.png -------------------------------------------------------------------------------- /src/components/RouterStatus/index.js: -------------------------------------------------------------------------------- 1 | import RouterStatus from './RouterStatus' 2 | 3 | export default RouterStatus 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10.1" 4 | - "8.11" 5 | script: 6 | - yarn lint 7 | - yarn deploy:prod 8 | -------------------------------------------------------------------------------- /src/components/RedirectWithStatus/index.js: -------------------------------------------------------------------------------- 1 | import RedirectWithStatus from './RedirectWithStatus' 2 | 3 | export default RedirectWithStatus 4 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "experimentalDecorators": true 5 | } 6 | } -------------------------------------------------------------------------------- /src/routes/Home/HomeView.css: -------------------------------------------------------------------------------- 1 | .home-view { 2 | color: white; 3 | padding: 100px; 4 | background-size: cover; 5 | background: url('./back.png'); 6 | } -------------------------------------------------------------------------------- /src/layouts/CoreLayout/index.js: -------------------------------------------------------------------------------- 1 | import CoreLayout from "./CoreLayout" 2 | import { hot } from "react-hot-loader/root" 3 | 4 | export default hot(CoreLayout) 5 | -------------------------------------------------------------------------------- /src/constant.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Make sure it has slash '/' at the end 3 | */ 4 | 5 | export const ASSET_URL_STAG = '/' 6 | export const ASSET_URL_PROD = '/' 7 | export const ASSET_URL_DEV = '/' 8 | -------------------------------------------------------------------------------- /src/routes/NotFoundPage/index.js: -------------------------------------------------------------------------------- 1 | // import NotFoundPage from './NotFoundPage' 2 | import loadable from "@loadable/component" 3 | 4 | const NotFoundPage = loadable(() => import("./NotFoundPage")) 5 | 6 | export default NotFoundPage 7 | -------------------------------------------------------------------------------- /src/url.js: -------------------------------------------------------------------------------- 1 | import { ASSET_URL_PROD, ASSET_URL_STAG, ASSET_URL_DEV } from './constant' 2 | 3 | let assetUrl = ASSET_URL_PROD 4 | 5 | if (__DEV__) { 6 | assetUrl = ASSET_URL_DEV 7 | } else if (__STAG__) { 8 | assetUrl = ASSET_URL_STAG 9 | } 10 | 11 | export const HOME_PATH = '/' 12 | export const ASSET_URL = assetUrl 13 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | targets: { 7 | browsers: ["last 2 versions"] 8 | } 9 | } 10 | ], 11 | "@babel/preset-react" 12 | ], 13 | plugins: ["@babel/plugin-proposal-class-properties", "@loadable/babel-plugin"] 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/fetch.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export const fetch = async (url, headers) => { 4 | let header = { 5 | ...headers, 6 | url: url, 7 | withCredentials: 'true' 8 | } 9 | try { 10 | let data = await axios.request(header) 11 | return data.data 12 | } catch (err) { 13 | throw err.response 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /config/env.config.js: -------------------------------------------------------------------------------- 1 | import { ASSET_URL_DEV, ASSET_URL_PROD, ASSET_URL_STAG } from "../src/constant" 2 | import config from "./project.config" 3 | 4 | let assetUrl = ASSET_URL_DEV 5 | 6 | if (config.globals.__STAG__) { 7 | assetUrl = ASSET_URL_STAG 8 | } else if (config.globals.__PROD__) { 9 | assetUrl = ASSET_URL_PROD 10 | } 11 | 12 | export default { 13 | assetUrl 14 | } 15 | -------------------------------------------------------------------------------- /.client.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false, 7 | "targets": { 8 | "browsers": ["last 2 versions"] 9 | } 10 | } 11 | ], 12 | "@babel/preset-react" 13 | ], 14 | "plugins": [ 15 | "react-hot-loader/babel", 16 | "@babel/plugin-proposal-class-properties", 17 | "@loadable/babel-plugin" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.server.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false, 7 | "targets": { 8 | "browsers": ["last 2 versions"] 9 | } 10 | } 11 | ], 12 | "@babel/preset-react" 13 | ], 14 | "plugins": [ 15 | "@babel/plugin-syntax-dynamic-import", 16 | "@babel/plugin-proposal-class-properties", 17 | "@loadable/babel-plugin" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/root.js: -------------------------------------------------------------------------------- 1 | import "react-hot-loader" 2 | import React from "react" 3 | import { BrowserRouter } from "react-router-dom" 4 | import { HelmetProvider } from "react-helmet-async" 5 | import CoreLayout from "../layouts/CoreLayout" 6 | 7 | const Root = () => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | } 16 | 17 | export default Root 18 | -------------------------------------------------------------------------------- /src/renderer/server.js: -------------------------------------------------------------------------------- 1 | import app from './app' 2 | import { serverPort, serverHost } from '../../config/project.config' 3 | 4 | const httpServer = app.listen(serverPort, serverHost, error => { 5 | if (error) { 6 | console.error(error) 7 | } else { 8 | const address = httpServer.address() 9 | console.info( 10 | `==> 🌎 Listening on ${address.port}. Open up http://localhost:${ 11 | address.port 12 | }/ in your browser.` 13 | ) 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /src/routes/NotFoundPage/NotFoundPage.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Helmet } from "react-helmet-async" 3 | 4 | import RouterStatus from "../../components/RouterStatus" 5 | import "./NotFoundPage.css" 6 | 7 | const NotFoundPage = () => ( 8 | 9 | 10 | 11 | 12 |
Sorry, Page Not found
13 |
14 | ) 15 | 16 | export default NotFoundPage 17 | -------------------------------------------------------------------------------- /src/routes/Home/TestView.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | class TestView extends Component { 4 | state = { 5 | count: 0 6 | } 7 | 8 | componentDidMount() { 9 | this.timer = setInterval(() => { 10 | this.setState({ 11 | count: this.state.count + 1 12 | }) 13 | }, 1000) 14 | } 15 | 16 | componentWillUnmount() { 17 | clearInterval(this.timer) 18 | } 19 | 20 | render() { 21 | return
Count: {this.state.count}
22 | } 23 | } 24 | 25 | export default TestView 26 | -------------------------------------------------------------------------------- /src/components/RouterStatus/RouterStatus.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Route } from "react-router-dom" 3 | import PropTypes from "prop-types" 4 | 5 | const RouterStatus = ({ code, children }) => ( 6 | { 8 | if (staticContext) { 9 | staticContext.status = code 10 | } 11 | return children 12 | }} 13 | /> 14 | ) 15 | 16 | RouterStatus.propTypes = { 17 | code: PropTypes.number, 18 | children: PropTypes.node 19 | } 20 | 21 | export default RouterStatus 22 | -------------------------------------------------------------------------------- /src/routes/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Switch, Route } from "react-router-dom" 3 | 4 | import HomeView from "./Home" 5 | import NotFoundPage from "./NotFoundPage" 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | export const getInitialData = (req, store) => { 9 | return [] 10 | } 11 | 12 | export default function initRenderRoutes() { 13 | return ( 14 | 15 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/client.js: -------------------------------------------------------------------------------- 1 | import "raf/polyfill" 2 | import React from "react" 3 | import { hydrate } from "react-dom" 4 | import { loadableReady } from "@loadable/component" 5 | 6 | import Root from "./root" 7 | import "basscss/css/basscss.css" 8 | 9 | function render(MyApp) { 10 | hydrate(, document.querySelector("#root")) 11 | } 12 | 13 | loadableReady(() => { 14 | render(Root) 15 | }) 16 | 17 | // if (module.hot) { 18 | // module.hot.accept("../layouts/CoreLayout", () => { 19 | // const MyApp = require("../layouts/CoreLayout").default 20 | // render(MyApp) 21 | // }) 22 | // } 23 | -------------------------------------------------------------------------------- /src/components/RedirectWithStatus/RedirectWithStatus.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route, Redirect } from 'react-router-dom' 3 | import PropTypes from 'prop-types' 4 | 5 | const RedirectWithStatus = ({ from, to, status }) => ( 6 | { 8 | // there is no `staticContext` on the client, so 9 | // we need to guard against that here 10 | if (staticContext) { 11 | staticContext.status = status 12 | } 13 | return 14 | }} 15 | /> 16 | ) 17 | 18 | RedirectWithStatus.propTypes = { 19 | from: PropTypes.string, 20 | to: PropTypes.string, 21 | status: PropTypes.number 22 | } 23 | 24 | export default RedirectWithStatus 25 | -------------------------------------------------------------------------------- /src/routes/Home/HomeView.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react" 2 | import PropTypes from "prop-types" 3 | 4 | import "./HomeView.css" 5 | 6 | const HomeView = () => { 7 | const [count, setCount] = useState(0) 8 | return ( 9 |
10 |
home {count}
11 |
12 | 19 |
20 |
21 | ) 22 | } 23 | 24 | HomeView.propTypes = { 25 | user: PropTypes.object, 26 | toggleLogin: PropTypes.func 27 | } 28 | 29 | export default HomeView 30 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:react/recommended", 6 | "plugin:jest/recommended" 7 | ], 8 | "plugins": ["babel", "react", "jest"], 9 | "env": { 10 | "jest/globals": true, 11 | "browser": true 12 | }, 13 | "globals": { 14 | "__DEV__": false, 15 | "__STAG__": false, 16 | "__PROD__": false, 17 | "jest": false, 18 | "process": false, 19 | "module": false, 20 | "__dirname": false, 21 | "Promise": false 22 | }, 23 | "rules": { 24 | "space-before-function-paren": 0, 25 | "key-spacing": 0, 26 | "no-unused-vars": 2, 27 | "jsx-quotes": [2, "prefer-double"], 28 | "max-len": [2, 120, 2], 29 | "object-curly-spacing": [2, "always"], 30 | "no-console": 0 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/layouts/CoreLayout/CoreLayout.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react" 2 | import { Helmet } from "react-helmet-async" 3 | 4 | import renderRoutes from "../../routes" 5 | 6 | import "./CoreLayout.css" 7 | 8 | class CoreLayout extends Component { 9 | render() { 10 | return ( 11 |
12 | 13 | React App 14 | 15 | 16 | 17 | 18 | 19 |
{renderRoutes()}
20 |
21 | ) 22 | } 23 | } 24 | 25 | export default CoreLayout 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-ssr-starter 2 | 3 | [![Greenkeeper badge](https://badges.greenkeeper.io/antonybudianto/react-ssr-starter.svg)](https://greenkeeper.io/) 4 | [![Build Status](https://travis-ci.com/antonybudianto/react-ssr-starter.svg?branch=master)](https://travis-ci.com/antonybudianto/react-ssr-starter) 5 | 6 | > Personal and opinionated React Fullstack starter. 7 | 8 | ## Features: 9 | 10 | - SSR (Server side rendering) 11 | - Code-splitting 12 | - HMR (Hot module reload, **both** client and server) 13 | - Webpack 4, ESLint, Jest, Prettier 14 | 15 | ## How to use 16 | 17 | 1. Start 18 | ``` 19 | npm start 20 | ``` 21 | 22 | 2. Build 23 | ``` 24 | npm run deploy:prod 25 | ``` 26 | 27 | ## Alternatives 28 | - Do you use [create-react-app](https://github.com/facebook/create-react-app)? 29 | - Try on [CRA Universal](https://github.com/antonybudianto/cra-universal) for SSR, without eject! 30 | 31 | ## License 32 | MIT 33 | -------------------------------------------------------------------------------- /config/webpack.server.config.babel.js: -------------------------------------------------------------------------------- 1 | const nodeExternals = require('webpack-node-externals') 2 | const merge = require('webpack-merge') 3 | const baseConfig = require('./webpack.base.config') 4 | const project = require('./project.config') 5 | const { assetUrl } = require('./env.config').default 6 | const path = require('path') 7 | const StartServerPlugin = require('start-server-webpack-plugin') 8 | 9 | let config = { 10 | target: 'node', 11 | node: { 12 | fs: 'empty', 13 | __dirname: false, 14 | __filename: false 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js?$/, 20 | exclude: /node_modules/, 21 | loader: 'babel-loader', 22 | options: { 23 | babelrc: false, 24 | extends: path.resolve(__dirname, '../.server.babelrc') 25 | } 26 | }, 27 | { 28 | test: /\.s?[ac]ss$/, 29 | use: 'null-loader' 30 | } 31 | ] 32 | }, 33 | entry: { 34 | bundle: project.paths.client('renderer/server') 35 | }, 36 | externals: [nodeExternals()], 37 | output: { 38 | filename: '[name].js', 39 | publicPath: assetUrl, 40 | path: project.paths.dist() 41 | } 42 | } 43 | 44 | if (project.globals.__DEV__) { 45 | const addConfig = { 46 | plugins: [ 47 | new StartServerPlugin({ 48 | entryName: 'bundle' 49 | }) 50 | ] 51 | } 52 | config = merge(config, addConfig) 53 | } 54 | 55 | module.exports = merge(config, baseConfig) 56 | -------------------------------------------------------------------------------- /src/renderer/serverRenderer.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { renderToString } from "react-dom/server" 3 | import { StaticRouter } from "react-router-dom" 4 | import path from "path" 5 | import { ChunkExtractor } from "@loadable/server" 6 | 7 | import { HelmetProvider } from "react-helmet-async" 8 | 9 | import CoreLayout from "../layouts/CoreLayout" 10 | 11 | export default async (locPath, store, context) => { 12 | const helmetCtx = {} 13 | 14 | const statsFile = path.resolve( 15 | __dirname, 16 | path.resolve("./dist/loadable-stats.json") 17 | ) 18 | const extractor = new ChunkExtractor({ statsFile, entrypoints: ["app"] }) 19 | 20 | const App = ( 21 | 22 | 23 | 24 | 25 | 26 | ) 27 | 28 | const jsx = extractor.collectChunks(App) 29 | const content = renderToString(jsx) 30 | 31 | const helmet = helmetCtx.helmet 32 | 33 | return ` 34 | 35 | 36 | ${helmet.title.toString()} 37 | 38 | 39 | 40 | ${helmet.meta.toString()} 41 | ${extractor.getStyleTags()} 42 | 43 | ${helmet.link.toString()} 44 | 45 | 46 |
${content}
47 | ${extractor.getScriptTags()} 48 | 49 | ` 50 | } 51 | -------------------------------------------------------------------------------- /config/webpack.base.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack") 2 | const UglifyJsPlugin = require("uglifyjs-webpack-plugin") 3 | const project = require("./project.config") 4 | const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin") 5 | 6 | const devMode = project.globals.__DEV__ 7 | let config = { 8 | mode: devMode ? "development" : "production", 9 | stats: { 10 | chunks: true, 11 | chunkModules: true, 12 | colors: true, 13 | children: false 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.woff(\?.*)?$/, 19 | loader: 20 | "url-loader?prefix=fonts/&name=[path][name].[ext]&limit=10000&mimetype=application/font-woff" 21 | }, 22 | { 23 | test: /\.woff2(\?.*)?$/, 24 | loader: 25 | "url-loader?prefix=fonts/&name=[path][name].[ext]&limit=10000&mimetype=application/font-woff2" 26 | }, 27 | { 28 | test: /\.otf(\?.*)?$/, 29 | loader: 30 | "file-loader?prefix=fonts/&name=[path][name].[ext]&limit=10000&mimetype=font/opentype" 31 | }, 32 | { 33 | test: /\.ttf(\?.*)?$/, 34 | loader: 35 | "url-loader?prefix=fonts/&name=[path][name].[ext]&limit=10000&mimetype=application/octet-stream" 36 | }, 37 | { 38 | test: /\.eot(\?.*)?$/, 39 | loader: "file-loader?prefix=fonts/&name=[path][name].[ext]" 40 | }, 41 | { 42 | test: /\.svg(\?.*)?$/, 43 | loader: 44 | "url-loader?prefix=fonts/&name=[path][name].[ext]&limit=10000&mimetype=image/svg+xml" 45 | }, 46 | { 47 | test: /\.(png|jpg)$/, 48 | loader: "url-loader?limit=8192" 49 | } 50 | ] 51 | }, 52 | optimization: { 53 | minimizer: [ 54 | new UglifyJsPlugin({ 55 | uglifyOptions: { 56 | compress: true 57 | } 58 | }), 59 | new OptimizeCSSAssetsPlugin({}) 60 | ] 61 | }, 62 | plugins: [new webpack.DefinePlugin(project.globals)] 63 | } 64 | 65 | console.log("webpack mode: ", config.mode) 66 | 67 | module.exports = config 68 | -------------------------------------------------------------------------------- /config/project.config.js: -------------------------------------------------------------------------------- 1 | /* eslint key-spacing:0 spaced-comment:0 */ 2 | const path = require('path') 3 | 4 | console.log('Creating default configuration.') 5 | // ======================================================== 6 | // Default Configuration 7 | // ======================================================== 8 | const config = { 9 | env: process.env.NODE_ENV || 'development', 10 | 11 | // ---------------------------------- 12 | // Project Structure 13 | // ---------------------------------- 14 | path_base: path.resolve(__dirname, '..'), 15 | dir_client: path.resolve(__dirname, '../src'), 16 | dir_dist: path.resolve(__dirname, '../dist'), 17 | dir_public: path.resolve(__dirname, '../public'), 18 | 19 | // ---------------------------------- 20 | // Server Configuration 21 | // ---------------------------------- 22 | // serverHost: ip.address(), // use string 'localhost' to prevent exposure on local network 23 | serverHost: 'localhost', 24 | serverPort: process.env.PORT || 3000 25 | } 26 | 27 | /************************************************ 28 | ------------------------------------------------- 29 | 30 | All Internal Configuration Below 31 | Edit at Your Own Risk 32 | 33 | ------------------------------------------------- 34 | ************************************************/ 35 | 36 | // ------------------------------------ 37 | // Environment 38 | // ------------------------------------ 39 | // N.B.: globals added here must _also_ be added to .eslintrc 40 | config.globals = { 41 | 'process.env.NODE_ENV': JSON.stringify(config.env), 42 | __DEV__: config.env === 'development', 43 | __STAG__: config.env === 'staging', 44 | __PROD__: config.env === 'production' 45 | } 46 | 47 | // ------------------------------------ 48 | // Utilities 49 | // ------------------------------------ 50 | function base() { 51 | const args = [config.path_base].concat([].slice.call(arguments)) 52 | return path.resolve.apply(path, args) 53 | } 54 | 55 | config.paths = { 56 | base: base, 57 | client: base.bind(null, config.dir_client), 58 | public: base.bind(null, config.dir_public), 59 | dist: base.bind(null, config.dir_dist) 60 | } 61 | 62 | // ======================================================== 63 | // Environment Configuration 64 | // ======================================================== 65 | console.log(`Environment NODE_ENV "${config.env}".`) 66 | 67 | module.exports = config 68 | -------------------------------------------------------------------------------- /config/webpack.client.config.babel.js: -------------------------------------------------------------------------------- 1 | const merge = require("webpack-merge") 2 | const baseConfig = require("./webpack.base.config") 3 | const project = require("./project.config") 4 | const { assetUrl } = require("./env.config").default 5 | const path = require("path") 6 | const webpack = require("webpack") 7 | const MiniCssExtractPlugin = require("mini-css-extract-plugin") 8 | const ManifestPlugin = require("webpack-manifest-plugin") 9 | const BundleAnalyzerPlugin = require("webpack-bundle-analyzer") 10 | .BundleAnalyzerPlugin 11 | const LoadablePlugin = require("@loadable/webpack-plugin") 12 | 13 | const devMode = project.globals.__DEV__ 14 | 15 | const config = { 16 | devtool: project.globals.__PROD__ ? false : "source-map", 17 | entry: { 18 | app: [ 19 | ...(project.globals.__DEV__ 20 | ? [ 21 | "react-hot-loader/patch", 22 | "webpack-hot-middleware/client?timeout=1000&reload=true" 23 | ] 24 | : []), 25 | project.paths.client("renderer/client") 26 | ] 27 | }, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.js?$/, 32 | exclude: /node_modules/, 33 | loader: "babel-loader", 34 | options: { 35 | babelrc: false, 36 | extends: path.resolve(__dirname, "../.client.babelrc") 37 | } 38 | }, 39 | { 40 | test: /\.css$/, 41 | use: [ 42 | devMode ? "style-loader" : MiniCssExtractPlugin.loader, 43 | { 44 | loader: "css-loader", 45 | options: { 46 | minimize: true, 47 | url: true 48 | } 49 | } 50 | ] 51 | } 52 | ] 53 | }, 54 | output: { 55 | filename: project.globals.__DEV__ ? "[name].js" : `[name].[chunkhash].js`, 56 | publicPath: assetUrl, 57 | path: project.paths.dist() 58 | }, 59 | plugins: [ 60 | ...(project.globals.__DEV__ 61 | ? [new webpack.HotModuleReplacementPlugin()] 62 | : [ 63 | new ManifestPlugin(), 64 | new BundleAnalyzerPlugin({ 65 | analyzerMode: "static", 66 | openAnalyzer: false 67 | }) 68 | ]), 69 | new LoadablePlugin({ 70 | writeToDisk: true 71 | }), 72 | new MiniCssExtractPlugin({ 73 | filename: devMode ? "[name].css" : "[name].[hash].css", 74 | chunkFilename: devMode ? "[id].css" : "[id].[hash].css" 75 | }) 76 | ], 77 | optimization: { 78 | splitChunks: { 79 | cacheGroups: { 80 | default: false, 81 | commons: { 82 | test: /[\\/]node_modules[\\/].+\.js$/, 83 | name: "vendor", 84 | chunks: "all" 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | module.exports = merge(config, baseConfig) 92 | -------------------------------------------------------------------------------- /src/renderer/app.js: -------------------------------------------------------------------------------- 1 | import "@babel/polyfill" 2 | 3 | import express from "express" 4 | import morgan from "morgan" 5 | import proxy from "http-proxy-middleware" 6 | 7 | import serverRender from "./serverRenderer" 8 | import { getInitialData } from "../routes" 9 | import { HOME_PATH } from "../url" 10 | 11 | const app = express() 12 | const loggerEnv = __DEV__ ? "dev" : "combined" 13 | const logger = morgan(loggerEnv, { 14 | skip: function(req, res) { 15 | if (__DEV__) { 16 | return false 17 | } 18 | return res.statusCode < 400 19 | } 20 | }) 21 | 22 | app.use(logger) 23 | 24 | let devAssets = { 25 | appJs: "", 26 | vendorJs: "", 27 | appCss: "" 28 | } 29 | 30 | if (__DEV__) { 31 | var webpack = require("webpack") 32 | var webpackConfig = require("../../config/webpack.client.config.babel") 33 | var compiler = webpack(webpackConfig) 34 | 35 | app.use( 36 | require("webpack-dev-middleware")(compiler, { 37 | serverSideRender: true, 38 | publicPath: webpackConfig.output.publicPath 39 | }) 40 | ) 41 | app.use(require("webpack-hot-middleware")(compiler)) 42 | } 43 | 44 | app.use(HOME_PATH, express.static("dist")) 45 | 46 | if (__DEV__) { 47 | const backendUrl = process.env.APP_BACKEND_URL || "https://example.com" 48 | console.log("APP_BACKEND_URL = " + backendUrl) 49 | 50 | app.use( 51 | ["/api"], 52 | proxy({ 53 | secure: false, 54 | target: backendUrl, 55 | changeOrigin: true, 56 | prependPath: false 57 | }) 58 | ) 59 | } 60 | 61 | app.get(HOME_PATH + "(*)", (req, res) => { 62 | if (__DEV__) { 63 | const assetsByChunkName = res.locals.webpackStats.toJson().assetsByChunkName 64 | devAssets.appJs = assetsByChunkName.app.find(f => 65 | /^app(\.[a-z0-9]+)?\.js$/.test(f) 66 | ) 67 | devAssets.appCss = assetsByChunkName.app.find(f => 68 | /^app(\.[a-z0-9]+)?\.css$/.test(f) 69 | ) 70 | devAssets.vendorJs = assetsByChunkName.vendor.find(f => 71 | /^vendor(\.[a-z0-9]+)?\.js$/.test(f) 72 | ) 73 | devAssets.vendorCss = assetsByChunkName.vendor.find(f => 74 | /^vendor(\.[a-z0-9]+)?\.css$/.test(f) 75 | ) 76 | } 77 | 78 | // attach cookies to store object as a way to let cookies to be passed into server fetching 79 | // req.headers.cookie && (store["cookies"] = req.headers.cookie) 80 | const path = req.path 81 | const promises = getInitialData(req, {}) 82 | Promise.all(promises) 83 | .then(() => { 84 | let context = {} 85 | serverRender(path, {}, context, devAssets).then(html => { 86 | if (context.status === 404) { 87 | return res.status(404).send(html) 88 | } 89 | if (context.url) { 90 | return res.redirect(302, context.url) 91 | } 92 | res.send(html) 93 | }) 94 | }) 95 | .catch(err => { 96 | console.log("Error fetching", err) 97 | res.status(500).send("Error") 98 | }) 99 | }) 100 | 101 | if (module.hot) { 102 | module.hot.accept("./serverRenderer", () => { 103 | console.log("✅ Server hot reloaded ./serverRenderer") 104 | }) 105 | module.hot.accept("../routes", () => { 106 | console.log("✅ Server hot reloaded ../routes") 107 | }) 108 | } 109 | 110 | export default app 111 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ssr-starter", 3 | "version": "1.0.0", 4 | "description": "react ssr starter", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "better-npm-run start", 8 | "clean": "rimraf dist", 9 | "lint": "eslint config src", 10 | "test": "jest", 11 | "deploy": "better-npm-run deploy", 12 | "dev:server": "better-npm-run dev:server", 13 | "build:client": "better-npm-run build:client", 14 | "build:server": "better-npm-run build:server", 15 | "deploy:dev": "better-npm-run deploy:dev", 16 | "deploy:staging": "better-npm-run deploy:staging", 17 | "deploy:prod": "better-npm-run deploy:prod", 18 | "start:dev": "node dist/bundle.js", 19 | "start:prod": "npm run deploy:prod && npm run start:dev", 20 | "prettier": "prettier --write \"{src,config}/**/*.js\"" 21 | }, 22 | "betterScripts": { 23 | "start": { 24 | "command": "npm run clean && npm run dev:server", 25 | "env": { 26 | "NODE_ENV": "development" 27 | } 28 | }, 29 | "dev:server": { 30 | "command": "webpack --config ./config/webpack.server.config.babel.js --hot --watch" 31 | }, 32 | "build:server": { 33 | "command": "webpack --config ./config/webpack.server.config.babel.js --progress" 34 | }, 35 | "build:client": { 36 | "command": "webpack --config ./config/webpack.client.config.babel.js --progress" 37 | }, 38 | "deploy": { 39 | "command": "npm run clean && npm-run-all build:*" 40 | }, 41 | "deploy:dev": { 42 | "command": "npm run deploy", 43 | "env": { 44 | "NODE_ENV": "development" 45 | } 46 | }, 47 | "deploy:staging": { 48 | "command": "npm run deploy", 49 | "env": { 50 | "NODE_ENV": "staging" 51 | } 52 | }, 53 | "deploy:prod": { 54 | "command": "npm run deploy", 55 | "env": { 56 | "NODE_ENV": "production" 57 | } 58 | } 59 | }, 60 | "author": "Antony", 61 | "license": "MIT", 62 | "jest": { 63 | "globals": { 64 | "__DEV__": false, 65 | "__TEST__": true, 66 | "__PROD__": false, 67 | "__STAG__": false 68 | } 69 | }, 70 | "dependencies": { 71 | "@babel/core": "7.14.6", 72 | "@babel/plugin-syntax-dynamic-import": "7.8.3", 73 | "@babel/polyfill": "7.12.1", 74 | "@babel/preset-env": "7.14.7", 75 | "@babel/preset-react": "7.14.5", 76 | "@babel/register": "7.14.5", 77 | "@loadable/component": "5.15.0", 78 | "@loadable/server": "^5.15.0", 79 | "axios": "0.18.0", 80 | "babel-loader": "8.2.2", 81 | "basscss": "^8.0.4", 82 | "better-npm-run": "^0.1.0", 83 | "classnames": "^2.2.5", 84 | "css-loader": "^0.28.11", 85 | "dotenv": "6.0.0", 86 | "express": "4.16.3", 87 | "file-loader": "^1.1.11", 88 | "mini-css-extract-plugin": "0.4.1", 89 | "morgan": "1.9.0", 90 | "npm-run-all": "4.1.3", 91 | "null-loader": "0.1.1", 92 | "optimize-css-assets-webpack-plugin": "^4.0.2", 93 | "prop-types": "^15.6.1", 94 | "query-string": "^6.1.0", 95 | "raf": "3.4.0", 96 | "react": "17.0.2", 97 | "react-dom": "17.0.2", 98 | "react-helmet-async": "1.0.2", 99 | "react-hot-loader": "4.13.0", 100 | "react-router-dom": "5.2.0", 101 | "rimraf": "^2.6.2", 102 | "style-loader": "^0.21.0", 103 | "url-loader": "^1.0.1", 104 | "webpack": "4", 105 | "webpack-cli": "^3.0.2", 106 | "webpack-merge": "4.1.3" 107 | }, 108 | "devDependencies": { 109 | "@babel/plugin-proposal-class-properties": "7.14.5", 110 | "@loadable/babel-plugin": "^5.13.2", 111 | "@loadable/webpack-plugin": "^5.15.0", 112 | "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", 113 | "babel-eslint": "^8.2.3", 114 | "eslint": "^5.0.1", 115 | "eslint-plugin-babel": "^5.1.0", 116 | "eslint-plugin-jest": "^21.17.0", 117 | "eslint-plugin-react": "^7.9.1", 118 | "http-proxy-middleware": "^0.18.0", 119 | "jest": "27.0.5", 120 | "prettier": "1.13.7", 121 | "react-refresh": "^0.10.0", 122 | "start-server-webpack-plugin": "^3.0.0-rc3", 123 | "uglifyjs-webpack-plugin": "^1.2.5", 124 | "webpack-bundle-analyzer": "^2.13.1", 125 | "webpack-dev-server": "^3.1.4", 126 | "webpack-hot-middleware": "^2.25.0", 127 | "webpack-manifest-plugin": "^2.0.3", 128 | "webpack-node-externals": "^1.7.2" 129 | } 130 | } 131 | --------------------------------------------------------------------------------