├── .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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------