├── .babelrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .stylelintrc ├── LICENSE ├── README.md ├── environments ├── base.js ├── build.js ├── client.js └── setup-server-env.js ├── package.json ├── public └── favicon.ico ├── razzle.config.js ├── src ├── AppRouter.jsx ├── assets │ └── logo.svg ├── client.js ├── configs │ └── routes.js ├── index.js ├── server │ ├── handlers │ │ ├── remove-trailing-slash-handler.js │ │ └── ssr-handler.js │ ├── index.js │ ├── middlewares │ │ ├── error-middleware.js │ │ └── redirect-middleware.js │ ├── templates │ │ ├── error-template.hbs │ │ └── page-template.hbs │ └── utils.js ├── services │ ├── http.js │ └── initial-ssr-data │ │ ├── client.js │ │ ├── index.js │ │ ├── utils.js │ │ └── withSSRData.jsx ├── shared-components │ └── MetaTags.jsx ├── store │ ├── index.js │ └── reducer.js ├── styles │ ├── abstracts │ │ └── _variables.scss │ ├── base │ │ └── _reset.scss │ ├── elements │ │ ├── _container.scss │ │ └── _title.scss │ └── main.scss ├── utils │ ├── auth.js │ ├── env.js │ ├── hoc.js │ └── local-storage │ │ ├── BooleanStorageAccessor.js │ │ ├── JSONStorageAccessor.js │ │ ├── StorageAccessor.js │ │ ├── index.js │ │ └── utils.js └── views │ └── Home │ ├── Home.scss │ ├── index.jsx │ └── requests.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "razzle/babel", 4 | ], 5 | 6 | "plugins": [ 7 | "@babel/plugin-proposal-object-rest-spread", 8 | "@babel/plugin-proposal-class-properties" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "eslint:recommended", 4 | "airbnb", 5 | "plugin:react-hooks/recommended" 6 | ], 7 | "parser": "babel-eslint", 8 | "rules": { 9 | "no-plusplus": [ 10 | 2, 11 | { 12 | "allowForLoopAfterthoughts": true 13 | } 14 | ], 15 | "no-use-before-define": "off", 16 | "react/jsx-filename-extension": [ 17 | 1, 18 | { 19 | "extensions": [ 20 | ".js", 21 | ".jsx" 22 | ] 23 | } 24 | ], 25 | "lines-between-class-members": "error", 26 | "padding-line-between-statements": [ 27 | 1, 28 | { 29 | "blankLine": "always", 30 | "prev": "*", 31 | "next": "return" 32 | }, 33 | ], 34 | "object-curly-newline": [ 35 | 2, 36 | { 37 | "multiline": true, 38 | "minProperties": 4, 39 | "consistent": true 40 | }, 41 | ], 42 | "react/jsx-props-no-spreading": [ 43 | 2, 44 | { 45 | "html": "enforce", 46 | "custom": "ignore", 47 | "explicitSpread": "ignore", 48 | }, 49 | ], 50 | "import/order": [ 51 | 1, 52 | { 53 | "newlines-between": "always", 54 | "groups": [ 55 | ["builtin", "external"], 56 | "internal", 57 | ["parent", "sibling", "index"], 58 | ], 59 | } 60 | ] 61 | }, 62 | "parserOptions": { 63 | "ecmaVersion": 2018, 64 | "sourceType": "module", 65 | "ecmaFeatures": { 66 | "jsx": true 67 | } 68 | }, 69 | "env": { 70 | "es6": true, 71 | "browser": true, 72 | "node": true 73 | }, 74 | "settings": { 75 | "import/resolver": { 76 | "node": { 77 | "paths": [__dirname], 78 | }, 79 | "webpack": { 80 | "config": "webpack.config.js" 81 | } 82 | }, 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | npm-debug.log* 4 | .DS_Store 5 | 6 | coverage 7 | node_modules 8 | build 9 | .env.local 10 | .env.development.local 11 | .env.test.local 12 | .env.production.local -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "selector-class-pattern": [ 5 | "^[a-z]+(-[a-z\\d]+)*(__[a-z]+(-[a-z\\d]+)*)?(--[a-z]+(-[a-z\\d]+)*)?$", 6 | { "resolveNestedSelectors": true } 7 | ] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Divar 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 | # divar-starter-kit 2 | 3 | divar-starter-kit is a React.js SSR-ready boilerplate using Razzle by [divar.ir](https://divar.ir). 4 | 5 | **divar-starter-kit comes with the "battery-pack included"**: 6 | 7 | * [React](https://github.com/facebook/react) and [Razzle](https://razzlejs.org) features. 8 | * [Next.js](https://nextjs.org/docs/api-reference/data-fetching/getInitialProps) like SSR features. 9 | * Redux ready (CSR and SSR). 10 | * Using React-router-config. 11 | * Recommended Directory Layout. 12 | * HTTP service included (using [Axios](https://github.com/axios/axios)). 13 | * Configured ESlint and Stlyint 14 | 15 | ## Quick Start 16 | 17 | 18 | 19 | ```bash 20 | git clone https://github.com/divar-ir/divar-starter-kit 21 | cd divar-starter-kit 22 | yarn 23 | yarn start 24 | ``` 25 | 26 | Then open http://localhost:3000/ to see your app. Your console should look like this: 27 | 28 | Razzle Development Mode 29 | 30 | 31 | Below is a list of commands you will probably find useful. 32 | 33 | ### `npm start` or `yarn start` 34 | 35 | Runs the project in development mode. 36 | You can view your application at `http://localhost:3000` 37 | 38 | The page will reload if you make edits. 39 | 40 | ### `npm run build` or `yarn build` 41 | 42 | Builds the app for production to the build folder. 43 | 44 | The build is minified and the filenames include the hashes. 45 | Your app is ready to be deployed! 46 | 47 | ### `npm run start:prod` or `yarn start:prod` 48 | 49 | Runs the compiled app in production. 50 | 51 | You can again view your application at `http://localhost:3000` 52 | 53 | ### `npm test` or `yarn test` 54 | 55 | Runs the test watcher (Jest) in an interactive mode. 56 | By default, runs tests related to files changed since the last commit. 57 | 58 | ### `npm lint` or `yarn lint` 59 | 60 | Runs linter. 61 | 62 | 63 | ## Doing SSR 64 | Only views can be SSR. Views are components that directly connected to a URL. To do SSR you need just 2 simple steps: 65 | 66 | 67 | * Just add `serverSideInitial` static method to your view to do side-effects, dispatch actions to store or return values and promises. 68 | 69 | ``` 70 | static serverSideInitial({ dispatch, req }) { 71 | const isSomethingActive = req.query.something === 'active'; 72 | 73 | if (isSomethingActive) { 74 | dispatch( 75 | setSomethingActiveAction(), 76 | ); 77 | } 78 | 79 | return getPosts().then(posts => { 80 | return { 81 | posts, 82 | } 83 | }); 84 | } 85 | 86 | ``` 87 | 88 | * Now get value returned by `serverSideInitial` as `props.initialSSRData`. 89 | 90 | ``` 91 | Component.propTypes = { 92 | initialSSRData: PropTypes.shape({ 93 | posts: PropTypes.array(), 94 | }), 95 | }; 96 | 97 | export default Component; 98 | ``` 99 | 100 | ## Directory Layout 101 | 102 | divar-starter-kit comes with a suggested project structure looks like: 103 | 104 | ``` 105 | . 106 | ├── /environments/ # Client, build and setup server enviroment values. 107 | ├── /build/ # The folder for compiled output. 108 | ├── /public/ # Static files which are copied into the folder 109 | | /build/public and served by server, like sitemap or favicon. 110 | ├── /src/ # The source code of the application. 111 | ├── /configs/ # Project wide configs like 112 | | router config, paths, constants, API endoints. 113 | ├── /services/ # Project wide services like HTTP or GTM. 114 | ├── /shared-components/ # Components that used in many components/views [*]. 115 | ├── /store/ # Stores will be here. 116 | | DUCKS structure suggested for stores. 117 | ├── /styles/ # Shared styles. 118 | ├── /utils/ # Project wide helper functions. 119 | ├── /views/ # Pages/Views/Screens [*]. 120 | | Components that directly connected to a URL. 121 | ├── /client.js # Client-side startup script. 122 | ``` 123 | 124 | [*]: Views/Shared-components can be a single JSX (like ComponentName.jsx) 125 | or a folder containing: 126 | 127 | ``` 128 | ComponentName 129 | ├── /index.jsx # Component file. 130 | ├── /ComponentName.[scss,sass,css,module.*] 131 | | # Component style file. 132 | ├── /compponents/ # Component child components will be here. 133 | ├── /requests.js # API call/transformer functions. 134 | ├── /configs/ # Component config file or folder for 135 | | Constants, router config (for nested routing), etc. 136 | ├── /utils.js # Component helper functions. 137 | ``` 138 | 139 | ## Inspiration 140 | 141 | * [zeit/next.js](https://github.com/zeit/next.js) 142 | 143 | ## Contributing 144 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 145 | 146 | ## License 147 | [MIT](https://choosealicense.com/licenses/mit/) 148 | -------------------------------------------------------------------------------- /environments/base.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | API_BASE: 'https://test.com', 3 | PRELOADED_DATA_KEY: '__PRELOADED_DATA__', 4 | PRELOADED_STATE_KEY: '__PRELOADED_STATE__', 5 | HAS_PRELOADED_DATA_KEY: 'HAS_PRELOADED_DATA', 6 | }; 7 | -------------------------------------------------------------------------------- /environments/build.js: -------------------------------------------------------------------------------- 1 | const baseEnv = require('./base'); 2 | 3 | module.exports = { 4 | ...baseEnv, 5 | PUBLIC_DIR: 'public', 6 | }; 7 | -------------------------------------------------------------------------------- /environments/client.js: -------------------------------------------------------------------------------- 1 | const { isDev } = require('src/utils/env'); 2 | 3 | const baseEnv = require('./base'); 4 | 5 | let clientEnv = { 6 | ...baseEnv, 7 | SOME_KEY: 'xxxx-yyyy-zzzz-ssss', 8 | }; 9 | 10 | const devEnv = { 11 | API_BASE: 'https://mock.com', 12 | }; 13 | 14 | if (isDev) { 15 | clientEnv = { 16 | ...clientEnv, 17 | ...devEnv, 18 | }; 19 | } 20 | 21 | module.exports = clientEnv; 22 | -------------------------------------------------------------------------------- /environments/setup-server-env.js: -------------------------------------------------------------------------------- 1 | const baseEnv = require('./base'); 2 | 3 | global.env = { 4 | ...process.env, 5 | ...baseEnv, 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "divar-starter-kit", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "description": "SSR-ready react boilerplate 🚀.", 6 | "author": "Divar Front-End Chapter", 7 | "scripts": { 8 | "start": "razzle start", 9 | "build": "razzle build", 10 | "test": "razzle test --env=jsdom", 11 | "start:prod": "NODE_ENV=production node build/server.js", 12 | "lint": "yarn lint:script && yarn lint:style", 13 | "lint:script": "eslint --ext .jsx,.js src/", 14 | "lint:style": "stylelint 'src/**/*.scss'" 15 | }, 16 | "dependencies": { 17 | "axios": "^0.19.2", 18 | "express": "^4.17.1", 19 | "lodash.clonedeep": "^4.5.0", 20 | "prop-types": "^15.7.2", 21 | "react": "^16.12.0", 22 | "react-dom": "^16.12.0", 23 | "react-helmet": "^5.2.1", 24 | "react-redux": "^7.1.3", 25 | "react-router": "^5.1.2", 26 | "react-router-config": "^5.1.1", 27 | "react-router-dom": "^5.1.2", 28 | "redux": "^4.0.5", 29 | "redux-thunk": "^2.3.0", 30 | "stylelint": "^13.7.2", 31 | "stylelint-config-standard": "^20.0.0" 32 | }, 33 | "devDependencies": { 34 | "@babel/plugin-proposal-class-properties": "^7.7.4", 35 | "@babel/plugin-proposal-object-rest-spread": "^7.7.7", 36 | "babel-eslint": "^10.0.3", 37 | "eslint": "^6.8.0", 38 | "eslint-config-airbnb": "^18.0.1", 39 | "eslint-import-resolver-webpack": "^0.12.0", 40 | "eslint-plugin-import": "^2.19.1", 41 | "eslint-plugin-jsx-a11y": "^6.2.3", 42 | "eslint-plugin-react": "^7.17.0", 43 | "eslint-plugin-react-hooks": "^4.0.7", 44 | "handlebars": "^4.7.0", 45 | "handlebars-loader": "^1.7.1", 46 | "normalize.css": "^8.0.1", 47 | "razzle": "^3.1.3", 48 | "razzle-dev-utils": "^3.1.3", 49 | "razzle-plugin-scss": "^3.1.5" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divar-ir/elite-js/e5ba256b7ba239d8bfeaf7fd57163552a8867d18/public/favicon.ico -------------------------------------------------------------------------------- /razzle.config.js: -------------------------------------------------------------------------------- 1 | const cloneDeep = require('lodash.clonedeep'); 2 | const makeLoaderFinder = require('razzle-dev-utils/makeLoaderFinder'); 3 | const customConfig = require('./webpack.config'); 4 | 5 | // @TODO Change customConfig merge logic(maybe deepMerge be a good choice) 6 | module.exports = { 7 | plugins: ['scss'], 8 | modify(config) { 9 | const configClone = cloneDeep(config); 10 | const fileLoaderIndex = configClone.module.rules.findIndex(makeLoaderFinder('file-loader')); 11 | const fileLoader = configClone.module.rules[fileLoaderIndex]; 12 | 13 | configClone.resolve.alias = customConfig.resolve.alias; 14 | fileLoader.exclude.push(/\.hbs$/); 15 | configClone.module.rules = [...configClone.module.rules, ...customConfig.module.rules]; 16 | 17 | return configClone; 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/AppRouter.jsx: -------------------------------------------------------------------------------- 1 | import routes from 'src/configs/routes'; 2 | 3 | import { renderRoutes } from './server/utils'; 4 | 5 | function AppRouter() { 6 | return renderRoutes(routes); 7 | } 8 | 9 | export default AppRouter; 10 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { hydrate } from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | 6 | import store from 'src/store'; 7 | import { getContext } from 'src/services/initial-ssr-data'; 8 | // eslint-disable-next-line import/order 9 | import initialSSRDataValue from 'src/services/initial-ssr-data/client'; 10 | 11 | import 'src/styles/main.scss'; 12 | 13 | // To give components styles more priority. 14 | import AppRouter from 'src/AppRouter'; 15 | 16 | const { Provider: InitialSSRDataProvider } = getContext(); 17 | 18 | hydrate( 19 | 20 | 21 | 22 | 23 | 24 | 25 | , 26 | document.getElementById('app'), 27 | ); 28 | 29 | if (module.hot) { 30 | module.hot.accept(); 31 | } 32 | -------------------------------------------------------------------------------- /src/configs/routes.js: -------------------------------------------------------------------------------- 1 | import Home from 'src/views/Home'; 2 | 3 | const routes = [ 4 | { 5 | path: '/', 6 | component: Home, 7 | }, 8 | ]; 9 | 10 | export default routes; 11 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import 'environments/setup-server-env'; 3 | import http from 'http'; 4 | 5 | let app = require('./server').default; 6 | 7 | const server = http.createServer(app); 8 | 9 | let currentApp = app; 10 | const { PORT, npm_package_name: PACKAGE_NAME } = process.env; 11 | 12 | server.listen(PORT || 3000, (error) => { 13 | if (error) { 14 | console.log(error); 15 | } 16 | 17 | console.log(`🚀 ${PACKAGE_NAME} started`); 18 | }); 19 | 20 | if (module.hot) { 21 | console.log('✅ Server-side HMR Enabled!'); 22 | 23 | module.hot.accept('./server', () => { 24 | console.log('🔁 HMR Reloading `./server`...'); 25 | 26 | try { 27 | // eslint-disable-next-line global-require 28 | app = require('./server').default; 29 | server.removeListener('request', currentApp); 30 | server.on('request', app); 31 | currentApp = app; 32 | } catch (error) { 33 | console.error(error); 34 | } 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/server/handlers/remove-trailing-slash-handler.js: -------------------------------------------------------------------------------- 1 | import { format as formatUrl } from 'url'; 2 | 3 | export default function removeTrailingSlash(req, res) { 4 | const urlPath = formatUrl({ 5 | pathname: req.path.replace(/\/$/, ''), 6 | query: req.query, 7 | }); 8 | 9 | return res.redirect(301, urlPath); 10 | } 11 | -------------------------------------------------------------------------------- /src/server/handlers/ssr-handler.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Helmet from 'react-helmet'; 3 | import { StaticRouter } from 'react-router-dom'; 4 | import { renderToString } from 'react-dom/server'; 5 | import { matchRoutes } from 'react-router-config'; 6 | import { Provider } from 'react-redux'; 7 | 8 | import clientEnv from 'environments/client'; 9 | import routes from 'src/configs/routes'; 10 | import { createNewStore } from 'src/store'; 11 | import { createNewContext } from 'src/services/initial-ssr-data'; 12 | import { initSSRContextValue } from 'src/services/initial-ssr-data/utils'; 13 | import { getEnv, isProd } from 'src/utils/env'; 14 | import AppRouter from 'src/AppRouter'; 15 | 16 | import { findFinalComponent, getComponent } from '../utils'; 17 | import pageTemplate from '../templates/page-template.hbs'; 18 | 19 | // eslint-disable-next-line import/no-dynamic-require 20 | const assets = require(process.env.RAZZLE_ASSETS_MANIFEST); 21 | 22 | function renderPage({ appHtml, preloadedData, preloadedState }) { 23 | const head = Helmet.renderStatic(); 24 | const title = head ? head.title.toString() : ''; 25 | const meta = head ? head.meta.toString() : ''; 26 | const link = head ? head.link.toString() : ''; 27 | const script = head ? head.script.toString() : ''; 28 | const html = pageTemplate({ 29 | appHtml: appHtml.toString(), 30 | PRELOADED_DATA_KEY: getEnv('PRELOADED_DATA_KEY'), 31 | PRELOADED_STATE_KEY: getEnv('PRELOADED_STATE_KEY'), 32 | preloadedData: JSON.stringify(preloadedData), 33 | preloadedState: JSON.stringify(preloadedState), 34 | assets, 35 | meta, 36 | link, 37 | script, 38 | title, 39 | isProd, 40 | env: JSON.stringify(clientEnv), 41 | }); 42 | 43 | return html; 44 | } 45 | 46 | function getInitialPropsList({ renderBranch, store: { getState, dispatch }, req }) { 47 | return renderBranch.reduce( 48 | ( 49 | initialPropsList, 50 | { 51 | route: 52 | { 53 | component: routeComponent, 54 | render: routeRenderer, path, 55 | }, 56 | }, 57 | ) => { 58 | const component = getComponent(routeComponent, routeRenderer); 59 | 60 | // Due to usage of HOCs, we have to traverse the component tree 61 | // to find the final wrapped component which contains the static server-side methods 62 | const { 63 | component: { serverSideInitial }, 64 | hasSSRData: wrappedInWithSSRData, 65 | } = findFinalComponent(component); 66 | 67 | if (!serverSideInitial) { 68 | return initialPropsList; 69 | } 70 | 71 | const dataPromise = serverSideInitial({ getState, dispatch, req }); 72 | const serverSideInitialReturns = dataPromise !== undefined; 73 | const hasPreloadedData = serverSideInitialReturns || wrappedInWithSSRData; 74 | 75 | return initialPropsList.concat({ 76 | path, 77 | hasPreloadedData, 78 | dataPromise, 79 | }); 80 | }, 81 | [], 82 | ); 83 | } 84 | 85 | function getPreloadedData({ initialDataList, initialPropsList }) { 86 | return initialDataList.reduce((preloadedData, data, index) => { 87 | const { path, hasPreloadedData } = initialPropsList[index]; 88 | if (!hasPreloadedData) { 89 | return preloadedData; 90 | } 91 | 92 | return { 93 | ...preloadedData, 94 | [path]: data, 95 | }; 96 | }, {}); 97 | } 98 | 99 | function getPageMarkup({ 100 | store, context, preloadedData, location, 101 | }) { 102 | const { Provider: InitialSSRDataProvider } = createNewContext(); 103 | 104 | return renderToString( 105 | 106 | 107 | 108 | 109 | 110 | 111 | , 112 | ); 113 | } 114 | 115 | function handleSSR(req, res, next) { 116 | const context = {}; 117 | const store = createNewStore(); 118 | const renderBranch = matchRoutes(routes, req.url); 119 | const initialPropsList = getInitialPropsList({ 120 | renderBranch, 121 | store, 122 | req, 123 | }); 124 | 125 | const initialDataPromises = initialPropsList.map( 126 | ({ dataPromise }) => dataPromise, 127 | ); 128 | 129 | Promise.all(initialDataPromises) 130 | .then((initialDataList) => { 131 | const preloadedData = getPreloadedData({ 132 | initialDataList, 133 | initialPropsList, 134 | }); 135 | 136 | // note that calling `getPageMarkup` might cause `context` value to update! 137 | const markup = getPageMarkup({ 138 | store, 139 | context, 140 | preloadedData, 141 | location: req.url, 142 | }); 143 | 144 | if (context.url) { 145 | res.redirect(context.url); 146 | } else { 147 | res.status(200).send( 148 | renderPage({ 149 | appHtml: markup, 150 | preloadedData, 151 | preloadedState: store.getState(), 152 | }), 153 | ); 154 | } 155 | }) 156 | .catch(next); 157 | } 158 | 159 | export default handleSSR; 160 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import handleSSR from './handlers/ssr-handler'; 4 | import errorMiddleware from './middlewares/error-middleware'; 5 | import redirectMiddleware from './middlewares/redirect-middleware'; 6 | import removeTrailingSlashHandler from './handlers/remove-trailing-slash-handler'; 7 | 8 | const server = express(); 9 | 10 | server.disable('x-powered-by'); 11 | 12 | // @TODO you might need to remove this after you configured to use CDN 13 | server.use(express.static(process.env.RAZZLE_PUBLIC_DIR)); 14 | 15 | server.get('\\S+/$', removeTrailingSlashHandler); 16 | server.get('/*', handleSSR); 17 | 18 | // @TODO you might need to add sentry middleware somewhere about here 19 | server.use(redirectMiddleware, errorMiddleware); 20 | 21 | export default server; 22 | -------------------------------------------------------------------------------- /src/server/middlewares/error-middleware.js: -------------------------------------------------------------------------------- 1 | import { isProd } from 'src/utils/env'; 2 | 3 | import errorPage from '../templates/error-template.hbs'; 4 | 5 | /** 6 | * Error-handling middleware always takes four arguments. You must provide four arguments 7 | * to identify it as an error-handling middleware function. Even if you don’t need to use 8 | * the next object, you must specify it to maintain the signature. Otherwise, 9 | * the next object will be interpreted as regular middleware and will fail to handle errors. 10 | * ref: http://expressjs.com/en/guide/error-handling.html 11 | */ 12 | // eslint-disable-next-line no-unused-vars 13 | function errorMiddleware(err, req, res, next) { 14 | const status = err.status || 500; 15 | let response; 16 | 17 | if (isProd) { 18 | response = errorPage({ status }); 19 | } else { 20 | const message = err.message || `Internal Server Error (${status})`; 21 | response = `${message}
${err.stack || ''}
`; 22 | } 23 | res.status(status).send(response); 24 | } 25 | 26 | export default errorMiddleware; 27 | -------------------------------------------------------------------------------- /src/server/middlewares/redirect-middleware.js: -------------------------------------------------------------------------------- 1 | function redirectMiddleware(err, req, res, next) { // eslint-disable-line no-unused-vars 2 | if (err.status !== 301) { 3 | next(err); 4 | 5 | return; 6 | } 7 | 8 | res.redirect(301, err.redirectUrl); 9 | } 10 | 11 | export default redirectMiddleware; 12 | -------------------------------------------------------------------------------- /src/server/templates/error-template.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{!-- @TODO: change html lang, if you need to --}} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
An error has occurred: status {{{status}}}
14 | 15 | 16 | -------------------------------------------------------------------------------- /src/server/templates/page-template.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{#if assets.client.css}} 10 | 11 | {{/if}} 12 | {{#if isProd}} 13 | 14 | {{else}} 15 | 16 | {{/if}} 17 | 18 | {{{title}}} 19 | {{{meta}}} 20 | {{{link}}} 21 | {{{script}}} 22 | 23 | 24 | 25 |
{{{appHtml}}}
26 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/server/utils.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Switch, Route } from 'react-router-dom'; 3 | 4 | import { getEnv } from 'src/utils/env'; 5 | import withSSRData from 'src/services/initial-ssr-data/withSSRData'; 6 | 7 | // Due to usage of HOCs, we have to traverse the component tree to find the 8 | // final wrapped component which contains the static server-side methods 9 | // eslint-disable-next-line import/prefer-default-export 10 | export function findFinalComponent(component) { 11 | let comp = component; 12 | let isWrappedInWithSSRDataHOC = false; 13 | 14 | while (comp.WrappedComponent) { 15 | if (comp[getEnv('HAS_PRELOADED_DATA_KEY')]) { 16 | isWrappedInWithSSRDataHOC = true; 17 | } 18 | 19 | comp = comp.WrappedComponent; 20 | } 21 | 22 | return { 23 | component: comp, 24 | hasSSRData: isWrappedInWithSSRDataHOC, 25 | }; 26 | } 27 | 28 | export function getComponent(component, routeRenderer) { 29 | // because route renderers are a wrapper around the actual component, 30 | // they return the element created by react and not the actual component so we 31 | // need to point to the actual component which is stored in the 'type' property 32 | // in the returned react element 33 | const reactElement = routeRenderer ? routeRenderer() : null; 34 | 35 | return reactElement ? reactElement.type : component; 36 | } 37 | 38 | export function renderRoutes(routes, extraProps = {}, switchProps = {}) { 39 | if (!routes) return null; 40 | 41 | return ( 42 | 43 | {routes.map((route, index) => { 44 | const { component: routeComponent, render: routeRenderer, ...rest } = route; 45 | const component = getComponent(routeComponent, routeRenderer); 46 | const { hasSSRData: wrappedInWithSSRData } = findFinalComponent(component); 47 | const hasServerSideInitial = component.serverSideInitial; 48 | const needsWithSSRData = hasServerSideInitial && !wrappedInWithSSRData; 49 | 50 | /* eslint react/no-array-index-key: 0 */ 51 | return ( 52 | { 56 | const componentProps = { ...props, ...extraProps, route }; 57 | 58 | return (needsWithSSRData) 59 | ? React.createElement(withSSRData(component), componentProps) 60 | : React.createElement(component, componentProps); 61 | }} 62 | /> 63 | ); 64 | })} 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/services/http.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { getToken } from 'src/utils/auth'; 4 | import { getEnv } from 'src/utils/env'; 5 | 6 | axios.defaults.baseURL = getEnv('API_BASE'); 7 | 8 | function withAuthorizationHeader(config) { 9 | const token = getToken(); 10 | 11 | if (!token) { 12 | return config; 13 | } 14 | 15 | return { 16 | ...config, 17 | headers: { 18 | ...config.headers, 19 | Authorization: `Basic ${token}`, 20 | }, 21 | }; 22 | } 23 | 24 | function request(url, config = {}) { 25 | const { withToken, ...baseConfig } = config; 26 | let options = baseConfig; 27 | 28 | if (withToken) { 29 | options = withAuthorizationHeader(baseConfig); 30 | } 31 | 32 | return axios.request(url, options); 33 | } 34 | 35 | const http = { 36 | get: request, 37 | post: (url, config) => request(url, { ...config, method: 'POST' }), 38 | put: (url, config) => request(url, { ...config, method: 'PUT' }), 39 | delete: (url, config) => request(url, { ...config, method: 'DELETE' }), 40 | }; 41 | 42 | export default http; 43 | -------------------------------------------------------------------------------- /src/services/initial-ssr-data/client.js: -------------------------------------------------------------------------------- 1 | import { getEnv } from 'src/utils/env'; 2 | 3 | import { initSSRContextValue } from './utils'; 4 | 5 | const preloadedDataString = window[getEnv('PRELOADED_DATA_KEY')]; 6 | const preloadedData = typeof preloadedDataString !== 'undefined' 7 | ? JSON.parse(preloadedDataString) 8 | : {}; 9 | 10 | export default initSSRContextValue(preloadedData); 11 | -------------------------------------------------------------------------------- /src/services/initial-ssr-data/index.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | let SSRContext = createContext(); 4 | 5 | export function createNewContext() { 6 | SSRContext = createContext(); 7 | 8 | return SSRContext; 9 | } 10 | 11 | export function getContext() { 12 | return SSRContext; 13 | } 14 | -------------------------------------------------------------------------------- /src/services/initial-ssr-data/utils.js: -------------------------------------------------------------------------------- 1 | class InitialSSRData { 2 | constructor(preloadedData = {}) { 3 | this.data = preloadedData; 4 | } 5 | 6 | clearDataByKey = (key) => { 7 | const { [key]: removableItem, ...rest } = this.data; 8 | this.data = rest; 9 | } 10 | } 11 | 12 | // eslint-disable-next-line import/prefer-default-export 13 | export function initSSRContextValue(preloadedData) { 14 | return new InitialSSRData(preloadedData); 15 | } 16 | -------------------------------------------------------------------------------- /src/services/initial-ssr-data/withSSRData.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { withRouter } from 'react-router'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import { getDisplayName } from 'src/utils/hoc'; 6 | import { getEnv } from 'src/utils/env'; 7 | 8 | import { getContext } from './index'; 9 | 10 | function withSSRData(WrappedComponent) { 11 | class WithSSRDataComponent extends Component { 12 | componentWillUnmount() { 13 | const { 14 | match: { path }, 15 | } = this.props; 16 | this.resetData(path); 17 | } 18 | 19 | render() { 20 | const { 21 | match: { path }, 22 | } = this.props; 23 | const { Consumer } = getContext(); 24 | 25 | return ( 26 | 27 | {({ data: { [path]: initialSSRData = {} }, clearDataByKey }) => { 28 | this.resetData = clearDataByKey; 29 | 30 | return ( 31 | 36 | ); 37 | }} 38 | 39 | ); 40 | } 41 | } 42 | 43 | WithSSRDataComponent[getEnv('HAS_PRELOADED_DATA_KEY')] = true; 44 | WithSSRDataComponent.WrappedComponent = WrappedComponent; 45 | WithSSRDataComponent.displayName = getDisplayName({ 46 | component: WrappedComponent, 47 | hocName: 'WithSSRData', 48 | }); 49 | WithSSRDataComponent.propTypes = { 50 | match: PropTypes.shape({ 51 | path: PropTypes.string, 52 | }).isRequired, 53 | }; 54 | 55 | return withRouter(WithSSRDataComponent); 56 | } 57 | 58 | export default withSSRData; 59 | -------------------------------------------------------------------------------- /src/shared-components/MetaTags.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Helmet from 'react-helmet'; 4 | 5 | function MetaTags({ title, description, children }) { 6 | return ( 7 | 8 | {title && {title}} 9 | {description && } 10 | {children} 11 | 12 | ); 13 | } 14 | 15 | MetaTags.defaultProps = { 16 | title: undefined, 17 | description: undefined, 18 | children: null, 19 | }; 20 | MetaTags.propTypes = { 21 | title: PropTypes.string, 22 | description: PropTypes.string, 23 | children: PropTypes.node, 24 | }; 25 | 26 | export default memo(MetaTags); 27 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | 4 | import { isServerSide, getEnv } from 'src/utils/env'; 5 | 6 | import rootReducer from './reducer'; 7 | 8 | const preloadedStateString = isServerSide() 9 | ? undefined 10 | : window[getEnv('PRELOADED_STATE_KEY')]; 11 | const preLoadedState = typeof preloadedStateString !== 'undefined' 12 | ? JSON.parse(preloadedStateString) 13 | : undefined; 14 | 15 | const middlewares = [thunk]; 16 | 17 | export function createNewStore() { 18 | return createStore( 19 | rootReducer, 20 | preLoadedState, 21 | applyMiddleware(...middlewares), 22 | ); 23 | } 24 | 25 | const clientSideStore = createNewStore(); 26 | 27 | export default clientSideStore; 28 | -------------------------------------------------------------------------------- /src/store/reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | const INITIAL_STATE = { 4 | counter: 0, 5 | }; 6 | 7 | function appReducer(state = INITIAL_STATE, { type, payload }) { 8 | switch (type) { 9 | case 'ADD': 10 | return { 11 | ...state, 12 | counter: state.counter + payload.count, 13 | }; 14 | default: 15 | return state; 16 | } 17 | } 18 | 19 | const baseReducers = { 20 | app: appReducer, 21 | }; 22 | 23 | const reducer = combineReducers(baseReducers); 24 | 25 | export default reducer; 26 | -------------------------------------------------------------------------------- /src/styles/abstracts/_variables.scss: -------------------------------------------------------------------------------- 1 | $brand-primary: hsla(0, 63%, 40%, 1); 2 | 3 | $container-max-width: 992px; 4 | -------------------------------------------------------------------------------- /src/styles/base/_reset.scss: -------------------------------------------------------------------------------- 1 | @import '~normalize.css'; 2 | 3 | * { 4 | box-sizing: border-box; 5 | } 6 | -------------------------------------------------------------------------------- /src/styles/elements/_container.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: $container-max-width; 3 | width: 100%; 4 | margin: 0 auto; 5 | } 6 | -------------------------------------------------------------------------------- /src/styles/elements/_title.scss: -------------------------------------------------------------------------------- 1 | .title { 2 | font-family: 3 | -apple-system, 4 | BlinkMacSystemFont, 5 | 'Segoe UI', 6 | 'Roboto', 7 | 'Oxygen', 8 | 'Ubuntu', 9 | 'Cantarell', 10 | 'Fira Sans', 11 | 'Droid Sans', 12 | 'Helvetica Neue', 13 | sans-serif; 14 | font-size: 1rem; 15 | } 16 | -------------------------------------------------------------------------------- /src/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import 'abstracts/variables'; 2 | @import 'base/reset'; 3 | @import 'elements/container'; 4 | @import 'elements/title'; 5 | -------------------------------------------------------------------------------- /src/utils/auth.js: -------------------------------------------------------------------------------- 1 | import { StorageAccessor } from 'src/utils/local-storage'; 2 | 3 | const TOKEN_KEY = 'token'; 4 | const token = new StorageAccessor(TOKEN_KEY); 5 | 6 | export function getToken() { 7 | return token.value; 8 | } 9 | 10 | export function saveToken(value) { 11 | token.value = value; 12 | } 13 | 14 | export function removeToken() { 15 | token.reset(); 16 | } 17 | 18 | export function isUserLoggedIn() { 19 | return Boolean(getToken()); 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/env.js: -------------------------------------------------------------------------------- 1 | const { env } = isServerSide() ? global : window; 2 | 3 | function isServerSide() { 4 | return typeof window === 'undefined'; 5 | } 6 | 7 | function getEnv(key) { 8 | return env[key]; 9 | } 10 | 11 | const isDev = process.env.NODE_ENV === 'development'; 12 | const isProd = !isDev; 13 | 14 | module.exports = { 15 | getEnv, 16 | isServerSide, 17 | isDev, 18 | isProd, 19 | }; 20 | -------------------------------------------------------------------------------- /src/utils/hoc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export function getDisplayName({ component, hocName }) { 3 | const wrappedComponentName = component.displayName || component.name || 'Component'; 4 | 5 | return `${hocName}(${wrappedComponentName})`; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/local-storage/BooleanStorageAccessor.js: -------------------------------------------------------------------------------- 1 | import StorageAccessor from './StorageAccessor'; 2 | 3 | class BooleanStorageAccessor extends StorageAccessor { 4 | set value(value) { 5 | super.value = Number(Boolean(value)); 6 | } 7 | 8 | get value() { 9 | return Boolean(Number(super.value)); 10 | } 11 | } 12 | 13 | export default BooleanStorageAccessor; 14 | -------------------------------------------------------------------------------- /src/utils/local-storage/JSONStorageAccessor.js: -------------------------------------------------------------------------------- 1 | import StorageAccessor from './StorageAccessor'; 2 | 3 | class JSONStorageAccessor extends StorageAccessor { 4 | set value(value) { 5 | super.value = JSON.stringify(value); 6 | } 7 | 8 | get value() { 9 | return JSON.parse(super.value || null); 10 | } 11 | } 12 | 13 | export default JSONStorageAccessor; 14 | -------------------------------------------------------------------------------- /src/utils/local-storage/StorageAccessor.js: -------------------------------------------------------------------------------- 1 | import { getItem, setItem, removeItem } from './utils'; 2 | 3 | class StorageAccessor { 4 | constructor(key) { 5 | this.key = key; 6 | } 7 | 8 | set value(value) { 9 | setItem(this.key, value); 10 | } 11 | 12 | get value() { 13 | return getItem(this.key); 14 | } 15 | 16 | reset() { 17 | removeItem(this.key); 18 | } 19 | } 20 | 21 | export default StorageAccessor; 22 | -------------------------------------------------------------------------------- /src/utils/local-storage/index.js: -------------------------------------------------------------------------------- 1 | import StorageAccessor from './StorageAccessor'; 2 | import JSONStorageAccessor from './JSONStorageAccessor'; 3 | import BooleanStorageAccessor from './BooleanStorageAccessor'; 4 | 5 | export { 6 | StorageAccessor, 7 | JSONStorageAccessor, 8 | BooleanStorageAccessor, 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/local-storage/utils.js: -------------------------------------------------------------------------------- 1 | import { isServerSide } from 'src/utils/env'; 2 | 3 | export function getItem(key) { 4 | if (isServerSide()) { 5 | return undefined; 6 | } 7 | 8 | return localStorage.getItem(key); 9 | } 10 | 11 | export function setItem(key, value) { 12 | if (!isServerSide()) { 13 | localStorage.setItem(key, value); 14 | } 15 | } 16 | 17 | export function removeItem(key) { 18 | if (!isServerSide()) { 19 | localStorage.removeItem(key); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/views/Home/Home.scss: -------------------------------------------------------------------------------- 1 | .home { 2 | text-align: center; 3 | direction: ltr; 4 | 5 | &__logo { 6 | margin-top: 4rem; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/views/Home/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import MetaTags from 'react-helmet'; 4 | 5 | import logo from 'src/assets/logo.svg'; 6 | 7 | import { getHomeTitle } from './requests'; 8 | 9 | import './Home.scss'; 10 | 11 | class Home extends Component { 12 | static serverSideInitial() { 13 | const title = getHomeTitle(); 14 | 15 | return { 16 | title, 17 | }; 18 | } 19 | 20 | render() { 21 | const { initialSSRData: { title } } = this.props; 22 | 23 | return ( 24 |
25 | 26 | divar-stater-kit 31 |

{title}

32 |
33 | ); 34 | } 35 | } 36 | 37 | Home.propTypes = { 38 | initialSSRData: PropTypes.shape({ 39 | title: PropTypes.string, 40 | }).isRequired, 41 | }; 42 | 43 | export default Home; 44 | -------------------------------------------------------------------------------- /src/views/Home/requests.js: -------------------------------------------------------------------------------- 1 | 2 | // eslint-disable-next-line import/prefer-default-export 3 | export function getHomeTitle() { 4 | return 'Running divar-starter-kit successfully.'; 5 | } 6 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // config in this file is not directly used by Razzle. 2 | // but is used in `razzle.config.js` to override the webpack config created by Razzle. 3 | 4 | const path = require('path'); 5 | 6 | module.exports = { 7 | resolve: { 8 | alias: { 9 | src: path.resolve(__dirname, 'src'), 10 | environments: path.resolve(__dirname, 'environments'), 11 | }, 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.hbs$/, 17 | loader: 'handlebars-loader', 18 | }, 19 | ], 20 | }, 21 | }; 22 | --------------------------------------------------------------------------------