├── .babelrc ├── .editorconfig ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierrc ├── Dockerfile ├── README.md ├── index.js ├── package-lock.json ├── package.json ├── src ├── client.tsx ├── components │ ├── App │ │ ├── App.css │ │ └── App.tsx │ ├── Button │ │ ├── Button.css │ │ └── Button.tsx │ ├── Footer │ │ ├── Footer.css │ │ └── Footer.tsx │ ├── Header │ │ ├── Header.css │ │ ├── Header.tsx │ │ └── logo.jpg │ ├── Page │ │ ├── Page.css │ │ └── Page.tsx │ ├── PageContainer │ │ └── PageContainer.tsx │ ├── PageMeta │ │ └── PageMeta.tsx │ ├── Rect │ │ ├── Rect.css │ │ └── Rect.tsx │ ├── Sneakers │ │ ├── Sneakers.css │ │ ├── Sneakers.stub.tsx │ │ └── Sneakers.tsx │ ├── SneakersList │ │ ├── SneakersList.css │ │ ├── SneakersList.stub.tsx │ │ └── SneakersList.tsx │ └── index.ts ├── index.html ├── pages │ ├── 404 │ │ └── 404.tsx │ ├── Catalog │ │ ├── Catalog.stub.tsx │ │ └── Catalog.tsx │ ├── Home │ │ ├── Home.stub.tsx │ │ └── Home.tsx │ ├── Sneakers │ │ ├── Sneakers.css │ │ ├── Sneakers.hook.ts │ │ ├── Sneakers.stub.tsx │ │ └── Sneakers.tsx │ └── Upcoming │ │ ├── Upcoming.css │ │ ├── Upcoming.tsx │ │ └── nike-basketball-roster.jpg ├── routes.ts ├── server-render-middleware.tsx ├── server.ts ├── store │ ├── ducks │ │ ├── catalog │ │ │ ├── actions.ts │ │ │ ├── mock.json │ │ │ ├── reducer.ts │ │ │ ├── saga.ts │ │ │ ├── selectors.ts │ │ │ ├── service.ts │ │ │ └── types.ts │ │ ├── homepage │ │ │ ├── actions.ts │ │ │ ├── mock.json │ │ │ ├── reducer.ts │ │ │ ├── saga.ts │ │ │ ├── selectors.ts │ │ │ ├── service.ts │ │ │ └── types.ts │ │ ├── router │ │ │ ├── saga.ts │ │ │ ├── selectors.ts │ │ │ └── types.ts │ │ ├── shoes │ │ │ ├── actions.ts │ │ │ ├── mock.json │ │ │ ├── reducer.ts │ │ │ ├── saga.ts │ │ │ ├── selectors.ts │ │ │ ├── service.ts │ │ │ └── types.ts │ │ └── timeoutHelper.ts │ ├── getInitialState.ts │ ├── rootReducer.ts │ ├── rootSaga.ts │ └── rootStore.ts ├── styles │ ├── base.css │ └── media.css └── types │ ├── index.ts │ ├── models.ts │ └── redux.ts ├── static ├── images │ ├── 104265_131.jpg │ ├── 487471_100.jpg │ ├── 532225_006.jpg │ ├── 554724_050.jpg │ ├── 555088_081.jpg │ ├── 624041_800.jpg │ ├── 749766_408.jpg │ ├── 917165_120.jpg │ ├── AA2146_003.jpg │ ├── AH7241_118.jpg │ ├── AH7242_118.jpg │ ├── AH7368_801.jpg │ ├── AH7369_801.jpg │ ├── AJ1285_103.jpg │ ├── AJ2018_004.jpg │ ├── AJ5898_001.jpg │ ├── AJ6745_003.jpg │ ├── AJ6900_001.jpg │ ├── AJ6900_100.jpg │ ├── AO0566_440.jpg │ ├── AO1741_103.jpg │ ├── AO2409_100.jpg │ ├── AO2434_101.jpg │ ├── AO2439_401.jpg │ ├── AO2582_004.jpg │ ├── AO2924_008.jpg │ ├── AO3258_440.jpg │ ├── AO3266_410.jpg │ ├── AO3276_410.jpg │ ├── AO3277_600.jpg │ ├── AO4436_001.jpg │ ├── AO6219_100.jpg │ ├── AQ1289_100.jpg │ ├── AQ2235_100.jpg │ ├── AQ3619_400.jpg │ ├── AQ5707_002.jpg │ ├── AQ7495_100.jpg │ ├── AQ8306_600.jpg │ ├── AQ8741_300.jpg │ ├── AQ9164_005.jpg │ ├── AR4229_001.jpg │ ├── AR6631_007.jpg │ ├── AR6631_200.jpg │ ├── BQ7460_102.jpg │ ├── BQ7496_104.jpg │ ├── BV1654_002.jpg │ ├── BV7406_001.jpg │ ├── CD8238_001.jpg │ ├── CD9560_106.jpg │ ├── CI1502_001.jpg │ ├── CI1503_001.jpg │ ├── CI1504_100.jpg │ ├── CI1505_001.jpg │ ├── CI1506_001.jpg │ ├── CI1508_400.jpg │ ├── CI2668_300.jpg │ ├── CJ0767_400.jpg │ ├── CJ1436_100.jpg │ ├── CK6643_100.jpg │ ├── bs2mikksg5tqynlbq3sl.jpg │ ├── favicon.png │ ├── o8pc93ifccwo1qihdpqp.jpg │ ├── pljue2bfqbmq6ruw0ht1.jpg │ ├── tqbhkhohrwonshwh7srl.jpg │ ├── ugc_659925883.tif.jpg │ ├── ul2yvanoaw2ntfhslqd2.jpg │ └── ux4wrrodlumtnefucnq8.jpg ├── preview-preload-bundles.gif └── preview.gif ├── tsconfig.json ├── webpack.config.ts └── webpack ├── client.config.ts ├── env.ts ├── loaders ├── css.ts ├── file.ts └── js.ts └── server.config.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript", 5 | "@babel/preset-react" 6 | ], 7 | "plugins": [ 8 | "react-hot-loader/babel", 9 | "@babel/plugin-proposal-class-properties", 10 | "@babel/plugin-syntax-dynamic-import", 11 | "@loadable/babel-plugin" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 4 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [{*.json,*.yml}] 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .idea 4 | temp 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save=true 2 | save-exact=true 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.13.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "arrowParens": "avoid", 4 | "semi": true, 5 | "jsxBracketSameLine": false, 6 | "jsxSingleQuote": false, 7 | "bracketSpacing": true, 8 | "singleQuote": true, 9 | "tabWidth": 4, 10 | "printWidth": 80, 11 | "trailingComma": "es5" 12 | } 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 2 | 3 | COPY node_modules /app/node_modules 4 | COPY dist /app/dist 5 | COPY static /app/static 6 | COPY index.js /app/ 7 | COPY package.json /app/ 8 | COPY package-lock.json /app/ 9 | 10 | WORKDIR /app 11 | 12 | ENV NODE_ENV production 13 | ENV PORT 80 14 | 15 | EXPOSE 80 16 | 17 | CMD node /app/index.js 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Server Side Rendering Tutorial 2 | 3 | > :rocket: A project starter with server side rendering for React application with react-router, redux state, react-helmet, redux-saga and code-splitting. See live demo: [http://bit.do/react-ssr-demo](http://bit.do/react-ssr-demo). 4 | 5 | ![](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/raw/master/static/preview.gif) 6 | 7 | ### Features 8 | 9 | - **Actual stack** (React / Hooks / Redux / Redux-saga) 10 | - Fully typed by **Typescript** 11 | - **A lot of small improvements**: component to control code status, pages stub, fetch data on server, code splitting by loadable-components and preload bundles by hover: 12 | 13 | ![](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/raw/master/static/preview-preload-bundles.gif) 14 | 15 | ### Step-by-step branches 16 | 17 | You can choose a specific branch at a specific development step: 18 | 19 | - [client-side-version](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/tree/client-side-version) - Basic version of app without SSR 20 | - [01-prepare-webpack-and-express-server](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/tree/01-prepare-webpack-and-express-server) - Prepare webpack config for server bundle and setup express server 21 | - [02-add-redux-and-react-router](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/tree/) - Integrate redux and react-router with SSR 22 | - [03-add-react-helmet](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/tree/03-add-react-helmet) - Add React-helmet for metatags 23 | - [04-add-redux-saga-and-async-data](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/tree/04-add-redux-saga-and-async-data) - Run Redux-saga and fetch data on server side 24 | - [05-add-code-splitting](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/tree/05-add-code-splitting) - Add cofe-splitting and lazy loading 25 | 26 | ### Getting started 27 | 28 | Install dependencies: 29 | 30 | ``` 31 | npm install 32 | ``` 33 | 34 | Run development mode: 35 | 36 | ``` 37 | npm start 38 | ``` 39 | 40 | Now application is available in [http://localhost:9001](http://localhost:9001). 41 | 42 | Build production bundle: 43 | 44 | ``` 45 | npm run build 46 | ``` 47 | 48 | Or run inside docker container: 49 | 50 | ``` 51 | npm run build 52 | npm run docker 53 | ``` 54 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { app } = require('./dist/server.js'); 2 | 3 | const port = process.env.PORT || 9001; 4 | 5 | app.listen(port, () => { 6 | console.log(`Application is started on localhost:${port}`); 7 | }); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ssr-tutorial", 3 | "private": true, 4 | "engines": { 5 | "node": "12" 6 | }, 7 | "scripts": { 8 | "clean": "rimraf dist", 9 | "build": "NODE_ENV=production NODE_PATH=. npm run clean && webpack --mode production", 10 | "format": "prettier --write \"src/**/*.{ts,tsx}\" \"webpack/**/*.ts\"", 11 | "check-types": "tsc --noEmit", 12 | "start:webpack": "webpack --mode=development --watch", 13 | "start:server": "nodemon index.js --watch dist/server.js", 14 | "start": "NODE_ENV=development npm-run-all --print-label --parallel start:*", 15 | "docker:build": "docker build -t $npm_package_name .", 16 | "docker:run": "docker run -p 9001:80 $npm_package_name", 17 | "docker": "npm-run-all docker:*", 18 | "heroku:login": "heroku container:login", 19 | "heroku:push": "heroku container:push web", 20 | "heroku:release": "heroku container:release web", 21 | "heroku:open": "heroku open", 22 | "heroku": "npm-run-all heroku:*", 23 | "heroku-init": "heroku login && heroku create $npm_package_name" 24 | }, 25 | "dependencies": { 26 | "@hot-loader/react-dom": "16.8.4", 27 | "@loadable/component": "5.14.1", 28 | "@loadable/server": "5.14.0", 29 | "b_": "1.3.4", 30 | "babel-polyfill": "6.26.0", 31 | "compression": "1.7.4", 32 | "connected-react-router": "6.3.2", 33 | "express": "4.17.1", 34 | "history": "4.9.0", 35 | "immer": "6.0.3", 36 | "react": "17.0.1", 37 | "react-dom": "17.0.1", 38 | "react-helmet": "6.1.0", 39 | "react-hot-loader": "4.8.3", 40 | "react-redux": "7.2.0", 41 | "react-router-dom": "5.1.2", 42 | "react-slick": "0.23.2", 43 | "redux": "4.0.1", 44 | "redux-saga": "1.0.2", 45 | "slick-carousel": "1.8.1", 46 | "webpack-dev-middleware": "3.7.2", 47 | "webpack-hot-middleware": "2.24.3" 48 | }, 49 | "devDependencies": { 50 | "@babel/core": "7.3.4", 51 | "@babel/plugin-proposal-class-properties": "7.4.0", 52 | "@babel/plugin-syntax-dynamic-import": "7.2.0", 53 | "@babel/preset-env": "7.3.4", 54 | "@babel/preset-react": "7.0.0", 55 | "@babel/preset-typescript": "7.9.0", 56 | "@babel/register": "7.4.0", 57 | "@loadable/babel-plugin": "5.13.2", 58 | "@loadable/webpack-plugin": "5.14.0", 59 | "@types/b_": "1.3.1", 60 | "@types/compression": "1.7.0", 61 | "@types/compression-webpack-plugin": "2.0.1", 62 | "@types/copy-webpack-plugin": "6.0.0", 63 | "@types/express": "4.17.4", 64 | "@types/html-webpack-plugin": "3.2.3", 65 | "@types/loadable__component": "5.13.1", 66 | "@types/loadable__server": "5.12.3", 67 | "@types/loadable__webpack-plugin": "5.7.1", 68 | "@types/mini-css-extract-plugin": "0.9.1", 69 | "@types/node": "13.11.0", 70 | "@types/react": "16.9.52", 71 | "@types/react-dom": "16.9.8", 72 | "@types/react-helmet": "6.1.0", 73 | "@types/react-redux": "7.1.7", 74 | "@types/react-router-dom": "5.1.3", 75 | "@types/react-slick": "0.23.4", 76 | "@types/redux": "3.6.0", 77 | "@types/webpack": "4.41.22", 78 | "@types/webpack-dev-middleware": "3.7.0", 79 | "@types/webpack-dev-server": "3.11.0", 80 | "@types/webpack-hot-middleware": "2.25.0", 81 | "@types/webpack-node-externals": "2.5.0", 82 | "autoprefixer": "9.4.10", 83 | "babel-loader": "8.0.5", 84 | "compression-webpack-plugin": "2.0.0", 85 | "copy-webpack-plugin": "5.1.1", 86 | "css-hot-loader": "1.4.4", 87 | "css-loader": "2.1.1", 88 | "cssnano": "4.1.10", 89 | "file-loader": "3.0.1", 90 | "html-webpack-plugin": "3.2.0", 91 | "husky": "1.3.1", 92 | "mini-css-extract-plugin": "0.5.0", 93 | "nodemon": "2.0.5", 94 | "npm-run-all": "4.1.5", 95 | "null-loader": "4.0.1", 96 | "postcss": "7.0.14", 97 | "postcss-custom-media": "7.0.7", 98 | "postcss-import": "12.0.1", 99 | "postcss-import-alias-resolver": "0.1.1", 100 | "postcss-load-config": "2.0.0", 101 | "postcss-loader": "3.0.0", 102 | "postcss-modules": "1.4.1", 103 | "postcss-nested": "4.1.2", 104 | "prettier": "2.0.4", 105 | "pretty-quick": "3.1.0", 106 | "rimraf": "2.6.3", 107 | "source-map-loader": "0.2.4", 108 | "style-loader": "0.23.1", 109 | "ts-loader": "6.2.2", 110 | "tsconfig-paths-webpack-plugin": "3.2.0", 111 | "type-fest": "0.17.0", 112 | "typescript": "4.0.3", 113 | "url-loader": "1.1.2", 114 | "webpack": "4.30.0", 115 | "webpack-cli": "4.0.0", 116 | "webpack-dev-server": "3.11.0", 117 | "webpack-node-externals": "2.5.2", 118 | "write-file-webpack-plugin": "4.5.0" 119 | }, 120 | "browserslist": [ 121 | "last 2 versions" 122 | ], 123 | "husky": { 124 | "hooks": { 125 | "pre-commit": "npm run check-types && pretty-quick --staged" 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/client.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { hydrate } from 'react-dom'; 3 | import { ConnectedRouter } from 'connected-react-router'; 4 | import { Provider as ReduxProvider } from 'react-redux'; 5 | import { loadableReady } from '@loadable/component'; 6 | import 'babel-polyfill'; 7 | 8 | import { App } from 'components'; 9 | import { State } from 'types'; 10 | import { configureStore } from './store/rootStore'; 11 | 12 | const { store, history } = configureStore(window.__INITIAL_STATE__); 13 | 14 | // global redeclared types 15 | declare global { 16 | interface Window { 17 | __INITIAL_STATE__: State; 18 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: Function; 19 | } 20 | } 21 | 22 | loadableReady(() => { 23 | hydrate( 24 | 25 | 26 | 27 | 28 | , 29 | document.getElementById('mount') 30 | ); 31 | }); 32 | -------------------------------------------------------------------------------- /src/components/App/App.css: -------------------------------------------------------------------------------- 1 | @import '~styles/base.css'; 2 | 3 | .app { 4 | width: 100%; 5 | color: #000; 6 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen-Sans, Ubuntu, Cantarell, Helvetica Neue, sans-serif; 7 | 8 | display: flex; 9 | 10 | flex-direction: column; 11 | flex: 1 1; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Switch, Route } from 'react-router-dom'; 3 | import { hot } from 'react-hot-loader/root'; 4 | import { Header, Footer } from 'components'; 5 | import routes from 'routes'; 6 | 7 | import './App.css'; 8 | 9 | function App() { 10 | return ( 11 |
12 |
13 | 14 | {routes.map(({ fetchData, ...routeProps }) => ( 15 | 16 | ))} 17 | 18 |
20 | ); 21 | } 22 | 23 | const Component = hot(App); 24 | 25 | export { Component as App }; 26 | -------------------------------------------------------------------------------- /src/components/Button/Button.css: -------------------------------------------------------------------------------- 1 | .button { 2 | display: inline-block; 3 | border: 3px solid #c4c4c4; 4 | border-radius: 20px; 5 | padding: 10px 20px; 6 | color: #000; 7 | cursor: pointer; 8 | background: transparent; 9 | font-size: 16px; 10 | margin-bottom: 5px; 11 | transition: all .1s ease; 12 | 13 | &:hover { 14 | border: 3px solid #ff5c28; 15 | } 16 | 17 | &_size_xl { 18 | font-size: 26px; 19 | padding: 15px 30px; 20 | border-radius: 30px; 21 | } 22 | 23 | &_size_xs { 24 | font-size: 12px; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as bem from 'b_'; 3 | 4 | import './Button.css'; 5 | 6 | export enum ButtonSizes { 7 | L = 'l', 8 | M = 'm', 9 | S = 's', 10 | } 11 | 12 | type Props = { 13 | size: ButtonSizes; 14 | type: 'submit' | 'button' | 'reset'; 15 | children: React.ReactNode; 16 | }; 17 | 18 | const b = bem.with('button'); 19 | 20 | const Button = (props: Props) => { 21 | const { size, type, children } = props; 22 | 23 | return ( 24 | 27 | ); 28 | }; 29 | 30 | Button.defaultProps = { 31 | size: ButtonSizes.M, 32 | type: 'button', 33 | children: null, 34 | }; 35 | 36 | export { Button }; 37 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | padding: 50px; 3 | text-align: center; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as bem from 'b_'; 3 | 4 | import './Footer.css'; 5 | 6 | const b = bem.with('footer'); 7 | 8 | export function Footer() { 9 | const currentYear = new Date().getFullYear(); 10 | 11 | return ( 12 |
13 | {currentYear}, React Server Side rendering example  14 | 15 | (Source code) 16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Header/Header.css: -------------------------------------------------------------------------------- 1 | @import '~styles/media.css'; 2 | 3 | .header { 4 | max-width: 800px; 5 | margin: 0 auto; 6 | padding: 35px; 7 | align-items: center; 8 | justify-content: center; 9 | text-align: center; 10 | } 11 | 12 | .header__logo { 13 | background-image: url("./logo.jpg"); 14 | width: 100px; 15 | height: 120px; 16 | background-position: center; 17 | background-size: cover; 18 | margin: 0 auto; 19 | margin-bottom: 25px; 20 | } 21 | 22 | .header__nav { 23 | } 24 | 25 | .header__nav-item { 26 | margin-right: 15px; 27 | font-weight: bold; 28 | color: #000; 29 | font-size: 16px; 30 | text-decoration: none; 31 | text-transform: uppercase; 32 | font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; 33 | 34 | &:hover { 35 | color: #aaa; 36 | } 37 | 38 | &_active { 39 | color: #aaa; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | import loadable from '@loadable/component'; 4 | import * as bem from 'b_'; 5 | 6 | import './Header.css'; 7 | 8 | enum PageName { 9 | Home = 'Home', 10 | Catalog = 'Catalog', 11 | Upcoming = 'Upcoming', 12 | } 13 | 14 | const menu = [ 15 | { to: '/', exact: true, page: PageName.Home }, 16 | { to: '/catalog', exact: true, page: PageName.Catalog }, 17 | { to: '/upcoming', exact: true, page: PageName.Upcoming }, 18 | ]; 19 | 20 | const preloadPage = (pageName: string) => 21 | loadable(() => import(`../../pages/${pageName}/${pageName}`)); 22 | 23 | const b = bem.with('header'); 24 | 25 | export function Header() { 26 | return ( 27 |
28 |
29 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/Header/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/src/components/Header/logo.jpg -------------------------------------------------------------------------------- /src/components/Page/Page.css: -------------------------------------------------------------------------------- 1 | @import '~styles/media.css'; 2 | 3 | .page { 4 | flex: 1 0 auto; 5 | flex-grow: 1; 6 | 7 | &_align_center { 8 | text-align: center; 9 | } 10 | 11 | &__container { 12 | max-width: 760px; 13 | margin: 25px auto 80px; 14 | 15 | @media (--viewport-mobile) { 16 | padding: 0 15px; 17 | } 18 | 19 | &_clear::after { 20 | content: ""; 21 | clear: both; 22 | display: table; 23 | } 24 | } 25 | 26 | &__container-more { 27 | margin-top: 40px; 28 | } 29 | 30 | &__container h2 { 31 | text-align: left; 32 | padding-left: 10px; 33 | 34 | @media (--viewport-mobile) { 35 | text-align: center; 36 | } 37 | 38 | > .rect { 39 | @media (--viewport-mobile) { 40 | margin: 0 auto; 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/components/Page/Page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as bem from 'b_'; 3 | 4 | import './Page.css'; 5 | 6 | type Props = { 7 | children: React.ReactNode[] | React.ReactNode; 8 | align: string; 9 | mix?: string; 10 | }; 11 | 12 | const b = bem.with('page'); 13 | 14 | function Page(props: Props) { 15 | const { align, children, mix } = props; 16 | const cls = `${b({ align })} ${mix}`; 17 | 18 | return
{children}
; 19 | } 20 | 21 | Page.defaultProps = { 22 | align: 'center', 23 | mix: '', 24 | }; 25 | 26 | export { Page }; 27 | -------------------------------------------------------------------------------- /src/components/PageContainer/PageContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as bem from 'b_'; 3 | import { Link } from 'react-router-dom'; 4 | import { Button, Page } from '..'; 5 | 6 | type Props = { 7 | children: React.ReactNode[] | React.ReactNode; 8 | btn?: { 9 | to: string; 10 | text: string; 11 | }; 12 | mix?: string; 13 | }; 14 | 15 | const b = bem.with('page'); 16 | 17 | function PageContainer(props: Props) { 18 | const { children, btn, mix } = props; 19 | const cls = `${b('container')} ${mix}`; 20 | 21 | return ( 22 |
23 | {children} 24 | {btn && ( 25 |
26 | 27 | 28 | 29 |
30 | )} 31 |
32 | ); 33 | } 34 | 35 | PageContainer.defaultProps = { 36 | mix: '', 37 | }; 38 | 39 | export { PageContainer }; 40 | -------------------------------------------------------------------------------- /src/components/PageMeta/PageMeta.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Helmet from 'react-helmet'; 3 | 4 | type Props = { 5 | title?: string; 6 | description?: string; 7 | image?: string; 8 | }; 9 | 10 | const cutTags = (text: string = '') => { 11 | return text.replace(/<\/?.+?>/gi, ''); 12 | }; 13 | 14 | const prepareData = ({ title, description, image }: Props) => { 15 | return { 16 | title: cutTags(title), 17 | description: cutTags(description).substr(0, 250), 18 | image, 19 | }; 20 | }; 21 | 22 | function PageMeta(props: Props) { 23 | const { title, description, image } = prepareData(props); 24 | 25 | return ( 26 | 27 | {title} 28 | 29 | 30 | {Boolean(description) && ( 31 | 32 | )} 33 | {Boolean(description) && ( 34 | 35 | )} 36 | {Boolean(description) && ( 37 | 38 | )} 39 | {Boolean(image) && } 40 | 41 | ); 42 | } 43 | 44 | PageMeta.defaultProps = { 45 | title: 'Site', 46 | description: null, 47 | image: null, 48 | }; 49 | 50 | export { PageMeta }; 51 | -------------------------------------------------------------------------------- /src/components/Rect/Rect.css: -------------------------------------------------------------------------------- 1 | .rect 2 | { 3 | &_type 4 | { 5 | &_default 6 | { 7 | background: #e6e6e6; 8 | } 9 | 10 | &_black 11 | { 12 | background: #aaa; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Rect/Rect.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as bem from 'b_'; 3 | 4 | import './Rect.css'; 5 | 6 | type Props = { 7 | type?: string; 8 | width?: string; 9 | height?: string; 10 | className?: string; 11 | }; 12 | 13 | const b = bem.with('rect'); 14 | 15 | function Rect({ width, height, type, className }: Props) { 16 | const classes = className ? `${b({ type })} ${className}` : b({ type }); 17 | 18 | return
; 19 | } 20 | 21 | Rect.defaultProps = { 22 | type: 'default', 23 | width: null, 24 | height: null, 25 | className: null, 26 | }; 27 | 28 | export { Rect }; 29 | -------------------------------------------------------------------------------- /src/components/Sneakers/Sneakers.css: -------------------------------------------------------------------------------- 1 | @import '~styles/media.css'; 2 | 3 | .sneakers a { 4 | color: inherit; 5 | text-decoration: none; 6 | } 7 | 8 | .sneakers { 9 | text-align: left; 10 | box-sizing: border-box; 11 | padding: 10px 0; 12 | margin: 10px; 13 | position: relative; 14 | width: 220px; 15 | height: 280px; 16 | display: inline-block; 17 | 18 | @media (--viewport-mobile) { 19 | margin: 5px auto; 20 | } 21 | } 22 | 23 | .sneakers__image { 24 | height: 210px; 25 | width: 220px; 26 | margin-bottom: 10px; 27 | } 28 | 29 | .sneakers__title { 30 | font-weight: bold; 31 | font-size: 18px; 32 | } 33 | 34 | .sneakers__category { 35 | font-size: 14px; 36 | padding-top: 5px; 37 | color: #999; 38 | } 39 | 40 | .sneakers-stub { 41 | padding: 0; 42 | margin: 10px 15px 0; 43 | display: inline-block; 44 | } 45 | 46 | .sneakers-stub .rect { 47 | margin-bottom: 10px; 48 | 49 | @media (--viewport-mobile) { 50 | margin: 10px auto; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Sneakers/Sneakers.stub.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Rect } from 'components'; 3 | import './Sneakers.css'; 4 | 5 | export function SneakersStub() { 6 | return ( 7 |
8 | 9 | 10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Sneakers/Sneakers.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as bem from 'b_'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | import './Sneakers.css'; 6 | 7 | const b = bem.with('sneakers'); 8 | 9 | type Props = { 10 | title: string; 11 | image: string; 12 | subtitle: string; 13 | price: string; 14 | url: string; 15 | }; 16 | 17 | export function Sneakers(props: Props) { 18 | const { title, image, subtitle, price, url } = props; 19 | 20 | return ( 21 |
22 | 23 |
27 | 28 |
29 | {title} 30 |
31 |
32 | {subtitle} 33 | ,  34 | {price} 35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/SneakersList/SneakersList.css: -------------------------------------------------------------------------------- 1 | @import '~styles/media.css'; 2 | 3 | .sneakers-list-stub { 4 | padding-top: 8px; 5 | } 6 | 7 | .sneakers-list { 8 | display: flex; 9 | flex-wrap: wrap; 10 | justify-content: space-between; 11 | 12 | @media (--viewport-mobile) { 13 | justify-content: space-around; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/SneakersList/SneakersList.stub.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SneakersStub } from 'components'; 3 | import './SneakersList.css'; 4 | 5 | type Props = { 6 | count: number; 7 | }; 8 | 9 | export function SneakersListStub(props: Props) { 10 | return ( 11 |
12 | {Array(props.count) 13 | .fill(0) 14 | .map((_, idx: number) => ( 15 | 16 | ))} 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/SneakersList/SneakersList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as bem from 'b_'; 3 | import { Sneakers } from '../Sneakers/Sneakers'; 4 | import { Sneakers as SneakersType } from '../../types'; 5 | 6 | import './SneakersList.css'; 7 | 8 | const b = bem.with('sneakers-list'); 9 | 10 | type Props = { 11 | items: SneakersType[]; 12 | }; 13 | 14 | export function SneakersList(props: Props) { 15 | const { items } = props; 16 | 17 | return ( 18 |
19 | {items.map(sneakers => ( 20 | 21 | ))} 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { App } from './App/App'; 2 | 3 | export { Button, ButtonSizes } from './Button/Button'; 4 | export { Header } from './Header/Header'; 5 | export { Footer } from './Footer/Footer'; 6 | export { Rect } from './Rect/Rect'; 7 | export { Page } from './Page/Page'; 8 | export { PageContainer } from './PageContainer/PageContainer'; 9 | export { PageMeta } from './PageMeta/PageMeta'; 10 | 11 | export { Sneakers } from './Sneakers/Sneakers'; 12 | export { SneakersStub } from './Sneakers/Sneakers.stub'; 13 | export { SneakersList } from './SneakersList/SneakersList'; 14 | export { SneakersListStub } from './SneakersList/SneakersList.stub'; 15 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Sneakers shop 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/pages/404/404.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Route, RouteComponentProps } from 'react-router-dom'; 3 | import { Page, PageContainer } from 'components'; 4 | 5 | type Props = { 6 | code: number; 7 | children: React.ReactNode; 8 | }; 9 | 10 | // Component is used for passing http status for server side rendering; 11 | // For example, if page isn't found, client give page with 404 status code 12 | const Status = ({ code, children }: Props) => { 13 | const render = ({ staticContext }: RouteComponentProps) => { 14 | if (staticContext) { 15 | staticContext.statusCode = code; 16 | } 17 | 18 | return children; 19 | }; 20 | 21 | return ; 22 | }; 23 | 24 | export default function NotFoundPage() { 25 | return ( 26 | 27 | 28 | 29 |

Page not found

30 |
31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/pages/Catalog/Catalog.stub.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Page, PageContainer, Rect, SneakersListStub } from 'components'; 3 | 4 | export function CatalogStub() { 5 | return ( 6 | 7 | 8 |

9 | 10 |

11 | 12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/Catalog/Catalog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { State, Sneakers } from 'types'; 4 | import { fetchCatalog } from 'store/ducks/catalog/actions'; 5 | import { getCatalog, isLoading } from 'store/ducks/catalog/selectors'; 6 | import { SneakersList, PageMeta, PageContainer, Page } from 'components'; 7 | import { CatalogStub } from './Catalog.stub'; 8 | 9 | type Props = { 10 | data: Sneakers[]; 11 | fetchCatalog: () => void; 12 | isLoading: boolean; 13 | }; 14 | 15 | function Catalog(props: Props) { 16 | const { isLoading, data, fetchCatalog } = props; 17 | 18 | React.useEffect(() => { 19 | if (!data.length) { 20 | fetchCatalog(); 21 | } 22 | }, []); 23 | 24 | if (isLoading || !data.length) { 25 | return ; 26 | } 27 | 28 | return ( 29 | 30 | 34 | 35 |

Catalog

36 | 37 |
38 |
39 | ); 40 | } 41 | 42 | const mapStateToProps = (state: State) => ({ 43 | data: getCatalog(state), 44 | isLoading: isLoading(state), 45 | }); 46 | const mapDispatchToProps = { fetchCatalog }; 47 | 48 | export default connect( 49 | mapStateToProps, 50 | mapDispatchToProps 51 | )(Catalog) as React.ComponentType; 52 | -------------------------------------------------------------------------------- /src/pages/Home/Home.stub.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Page, PageContainer, Rect, SneakersListStub } from 'components'; 3 | 4 | export function HomeStub() { 5 | return ( 6 | 7 | 8 |

9 | 10 |

11 | 12 |
13 | 14 |

15 | 16 |

17 | 18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/pages/Home/Home.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import * as bem from 'b_'; 4 | import { State, Sneakers as SneakersType } from 'types'; 5 | import { PageMeta, SneakersList, Page, PageContainer } from 'components'; 6 | import { fetchHomepage } from 'store/ducks/homepage/actions'; 7 | import { getHomepage, isLoading } from 'store/ducks/homepage/selectors'; 8 | import { HomeStub } from './Home.stub'; 9 | 10 | type Props = { 11 | data: { 12 | popular: SneakersType[]; 13 | newest: SneakersType[]; 14 | }; 15 | fetchHomepage: () => void; 16 | isLoading: boolean; 17 | }; 18 | 19 | const b = bem.with('page'); 20 | 21 | function Home(props: Props) { 22 | const { isLoading, data, fetchHomepage } = props; 23 | 24 | React.useEffect(() => { 25 | if (!data.popular.length) { 26 | fetchHomepage(); 27 | } 28 | }, []); 29 | 30 | if (isLoading) { 31 | return ; 32 | } 33 | 34 | return ( 35 | 36 | 37 | 38 |

Popular

39 | 40 |
41 | 42 |

Newest

43 | 44 |
45 |
46 | ); 47 | } 48 | 49 | const mapStateToProps = (state: State) => ({ 50 | data: getHomepage(state), 51 | isLoading: isLoading(state), 52 | }); 53 | const mapDispatchToProps = { fetchHomepage }; 54 | 55 | export default connect( 56 | mapStateToProps, 57 | mapDispatchToProps 58 | )(Home) as React.ComponentType; 59 | -------------------------------------------------------------------------------- /src/pages/Sneakers/Sneakers.css: -------------------------------------------------------------------------------- 1 | .sneakers-page__slider { 2 | height: 230px; 3 | } 4 | 5 | .sneakers-page__category { 6 | margin-bottom: -15px; 7 | color: #777; 8 | font-weight: 500; 9 | } 10 | 11 | .sneakers-page__image { 12 | width: 230px; 13 | height: 230px; 14 | margin: 30px 15px; 15 | } 16 | 17 | .sneakers-page__description { 18 | color: #777; 19 | padding: 40px 0; 20 | } 21 | 22 | .sneakers-page .slick-prev:before, .sneakers-page .slick-next:before { 23 | color: #333 !important; 24 | } 25 | 26 | .sneakers-page-stub { 27 | display: flex; 28 | flex-direction: column; 29 | align-items: center; 30 | } 31 | 32 | .sneakers-page-stub > .rect { 33 | margin-bottom: 10px; 34 | } 35 | -------------------------------------------------------------------------------- /src/pages/Sneakers/Sneakers.hook.ts: -------------------------------------------------------------------------------- 1 | import { useRouteMatch } from 'react-router'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as React from 'react'; 4 | import { 5 | getShoes, 6 | isLoading as isLoadingSelector, 7 | } from 'store/ducks/shoes/selectors'; 8 | import { getHomepage } from 'store/ducks/homepage/selectors'; 9 | import { fetchShoes as fetchShoesActionCreator } from 'store/ducks/shoes/actions'; 10 | import { fetchHomepage as fetchHomepageActionCreator } from 'store/ducks/homepage/actions'; 11 | 12 | export function useSneakers() { 13 | const match = useRouteMatch<{ slug: string }>(); 14 | 15 | const { popular } = useSelector(getHomepage); 16 | const isLoading = useSelector(isLoadingSelector); 17 | const data = useSelector(getShoes); 18 | const dispatch = useDispatch(); 19 | const fetchShoes = React.useCallback( 20 | (slug: string) => dispatch(fetchShoesActionCreator(slug)), 21 | [dispatch] 22 | ); 23 | const fetchHomepage = React.useCallback( 24 | () => dispatch(fetchHomepageActionCreator()), 25 | [dispatch] 26 | ); 27 | 28 | return { 29 | match, 30 | popular, 31 | isLoading, 32 | data, 33 | fetchShoes, 34 | fetchHomepage, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/pages/Sneakers/Sneakers.stub.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Rect, Page, PageContainer } from 'components'; 3 | 4 | export function SneakersStub() { 5 | return ( 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/pages/Sneakers/Sneakers.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Slider from 'react-slick'; 3 | import * as bem from 'b_'; 4 | import { 5 | Page, 6 | Button, 7 | ButtonSizes, 8 | PageMeta, 9 | SneakersList, 10 | PageContainer, 11 | } from 'components'; 12 | import { SneakersStub } from './Sneakers.stub'; 13 | import { useSneakers } from './Sneakers.hook'; 14 | 15 | import 'slick-carousel/slick/slick.css'; 16 | import 'slick-carousel/slick/slick-theme.css'; 17 | import './Sneakers.css'; 18 | 19 | const b = bem.with('sneakers-page'); 20 | 21 | const settings = { 22 | dots: false, 23 | infinite: true, 24 | speed: 500, 25 | slidesToShow: 3, 26 | slidesToScroll: 1, 27 | arrows: true, 28 | className: 'sneakers-page__slider', 29 | responsive: [ 30 | { 31 | breakpoint: 480, 32 | settings: { 33 | slidesToShow: 1, 34 | slidesToScroll: 1, 35 | dots: true, 36 | arrows: false, 37 | }, 38 | }, 39 | ], 40 | }; 41 | 42 | function SneakersPage() { 43 | const { 44 | match, 45 | popular, 46 | isLoading, 47 | data, 48 | fetchShoes, 49 | fetchHomepage, 50 | } = useSneakers(); 51 | 52 | React.useEffect(() => { 53 | if (!data || data.slug !== match.params.slug) { 54 | fetchShoes(match.params.slug); 55 | } 56 | }, [match]); 57 | 58 | React.useEffect(() => { 59 | if (!popular.length) { 60 | fetchHomepage(); 61 | } 62 | }, []); 63 | 64 | if (isLoading || !data) { 65 | return ; 66 | } 67 | 68 | const { title, category, price, images, description } = data; 69 | 70 | return ( 71 | 72 | 77 | 78 | 79 |
{category}
80 |

{title}

81 | {price} 82 | 83 | {images.map(url => ( 84 |
85 | 86 |
87 | ))} 88 |
89 |
{description}
90 | 91 |
92 | {Boolean(popular.length) && ( 93 | 94 |

See also

95 | 96 |
97 | )} 98 |
99 | ); 100 | } 101 | 102 | export default SneakersPage; 103 | -------------------------------------------------------------------------------- /src/pages/Upcoming/Upcoming.css: -------------------------------------------------------------------------------- 1 | .upcoming-banner { 2 | background-image: url('./nike-basketball-roster.jpg'); 3 | width: 100%; 4 | height: 500px; 5 | background-size: cover; 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/Upcoming/Upcoming.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Page, PageContainer, PageMeta } from 'components'; 3 | import './Upcoming.css'; 4 | 5 | export default function Upcoming() { 6 | return ( 7 | 8 | 9 | 10 |

Upcoming...

11 |
12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/Upcoming/nike-basketball-roster.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/src/pages/Upcoming/nike-basketball-roster.jpg -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | import loadable from '@loadable/component'; 2 | import { fetchCatalog } from 'store/ducks/catalog/actions'; 3 | import { fetchHomepage } from 'store/ducks/homepage/actions'; 4 | import { fetchShoes } from 'store/ducks/shoes/actions'; 5 | import { RouterFetchDataArgs } from 'types'; 6 | 7 | const CatalogPage = loadable(() => import('./pages/Catalog/Catalog')); 8 | const UpcomingPage = loadable(() => import('./pages/Upcoming/Upcoming')); 9 | const SneakersPage = loadable(() => import('./pages/Sneakers/Sneakers')); 10 | const HomePage = loadable(() => import('./pages/Home/Home')); 11 | const NotFoundPage = loadable(() => import('./pages/404/404')); 12 | 13 | /** 14 | * Routes are moved to a separate file, 15 | * so that you can use the asyncFetchData method on the component on the server (by url path) 16 | * which load all the necessary data for rendering the page. 17 | */ 18 | export default [ 19 | { 20 | path: '/', 21 | component: HomePage, 22 | exact: true, 23 | fetchData({ dispatch }: RouterFetchDataArgs) { 24 | dispatch(fetchHomepage()); 25 | }, 26 | }, 27 | { 28 | path: '/catalog', 29 | component: CatalogPage, 30 | exact: true, 31 | fetchData({ dispatch }: RouterFetchDataArgs) { 32 | dispatch(fetchCatalog()); 33 | }, 34 | }, 35 | { 36 | path: '/sneakers/:slug', 37 | component: SneakersPage, 38 | exact: true, 39 | fetchData({ dispatch, match }: RouterFetchDataArgs) { 40 | dispatch(fetchShoes(match.params.slug)); 41 | dispatch(fetchHomepage()); 42 | }, 43 | }, 44 | { 45 | path: '/upcoming', 46 | component: UpcomingPage, 47 | exact: true, 48 | }, 49 | { 50 | path: '*', 51 | component: NotFoundPage, 52 | exact: true, 53 | }, 54 | ]; 55 | -------------------------------------------------------------------------------- /src/server-render-middleware.tsx: -------------------------------------------------------------------------------- 1 | import url from 'url'; 2 | import path from 'path'; 3 | import React from 'react'; 4 | import { renderToString } from 'react-dom/server'; 5 | import { Request, Response } from 'express'; 6 | import { StaticRouter, matchPath } from 'react-router-dom'; 7 | import { StaticRouterContext } from 'react-router'; 8 | import { Provider as ReduxProvider } from 'react-redux'; 9 | import Helmet, { HelmetData } from 'react-helmet'; 10 | import { ChunkExtractor } from '@loadable/server'; 11 | import { App } from './components/App/App'; 12 | import { configureStore } from './store/rootStore'; 13 | import rootSaga from './store/rootSaga'; 14 | import { getInitialState } from './store/getInitialState'; 15 | import routes from './routes'; 16 | 17 | export default (req: Request, res: Response) => { 18 | const location = req.url; 19 | const context: StaticRouterContext = {}; 20 | const { store } = configureStore(getInitialState(location), location); 21 | 22 | function renderApp() { 23 | const statsFile = path.resolve('./dist/loadable-stats.json'); 24 | const chunkExtractor = new ChunkExtractor({ statsFile }); 25 | 26 | const jsx = chunkExtractor.collectChunks( 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | const reactHtml = renderToString(jsx); 34 | const reduxState = store.getState(); 35 | const helmetData = Helmet.renderStatic(); 36 | 37 | if (context.url) { 38 | res.redirect(context.url); 39 | return; 40 | } 41 | 42 | res.status(context.statusCode || 200).send( 43 | getHtml(reactHtml, reduxState, helmetData, chunkExtractor) 44 | ); 45 | } 46 | 47 | store 48 | .runSaga(rootSaga) 49 | .toPromise() 50 | .then(() => renderApp()) 51 | .catch(err => { 52 | throw err; 53 | }); 54 | 55 | const dataRequirements: (Promise | void)[] = []; 56 | 57 | /** 58 | * Call the fetchData method on the component-page 59 | * that corresponds to the current url (by router). 60 | * 61 | * We use `some` method to simulate working of the routes in react-router-dom 62 | * inside the Switch — selects the first found route. 63 | */ 64 | routes.some(route => { 65 | const { fetchData: fetchMethod } = route; 66 | const match = matchPath<{ slug: string }>( 67 | url.parse(location).pathname, 68 | route 69 | ); 70 | 71 | if (match && fetchMethod) { 72 | dataRequirements.push( 73 | fetchMethod({ 74 | dispatch: store.dispatch, 75 | match, 76 | }) 77 | ); 78 | } 79 | 80 | return Boolean(match); 81 | }); 82 | 83 | // When all async actions will be finished, 84 | // dispatch action END to close saga 85 | return Promise.all(dataRequirements) 86 | .then(() => store.close()) 87 | .catch(err => { 88 | throw err; 89 | }); 90 | }; 91 | 92 | function getHtml( 93 | reactHtml: string, 94 | reduxState = {}, 95 | helmetData: HelmetData, 96 | chunkExtractor: ChunkExtractor 97 | ) { 98 | const scriptTags = chunkExtractor.getScriptTags(); 99 | const linkTags = chunkExtractor.getLinkTags(); 100 | const styleTags = chunkExtractor.getStyleTags(); 101 | 102 | return ` 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | ${helmetData.title.toString()} 112 | ${helmetData.meta.toString()} 113 | ${linkTags} 114 | ${styleTags} 115 | 116 | 117 |
${reactHtml}
118 | 121 | ${scriptTags} 122 | 123 | 124 | `; 125 | } 126 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import express from 'express'; 3 | import compression from 'compression'; 4 | import 'babel-polyfill'; 5 | import serverRenderMiddleware from './server-render-middleware'; 6 | 7 | const app = express(); 8 | 9 | // I recommend use it only for development 10 | // In production env you can use Nginx or CDN 11 | app.use(compression()) 12 | .use(express.static(path.resolve(__dirname, '../dist'))) 13 | .use(express.static(path.resolve(__dirname, '../static'))); 14 | 15 | app.get('/*', serverRenderMiddleware); 16 | 17 | export { app }; 18 | -------------------------------------------------------------------------------- /src/store/ducks/catalog/actions.ts: -------------------------------------------------------------------------------- 1 | import { Sneakers } from 'types'; 2 | import * as types from './types'; 3 | 4 | // action creators 5 | export function fetchCatalog(): types.FetchCatalogAction { 6 | return { type: types.FETCH_CATALOG }; 7 | } 8 | 9 | export function fetchCatalogSuccess( 10 | data: Sneakers[] 11 | ): types.FetchCatalogSuccessAction { 12 | return { type: types.FETCH_CATALOG_SUCCESS, payload: data }; 13 | } 14 | 15 | export function fetchCatalogError( 16 | error: string 17 | ): types.FetchCatalogFailureAction { 18 | return { type: types.FETCH_CATALOG_FAILURE, payload: error }; 19 | } 20 | -------------------------------------------------------------------------------- /src/store/ducks/catalog/reducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CatalogActionTypes, 3 | FETCH_CATALOG, 4 | FETCH_CATALOG_FAILURE, 5 | FETCH_CATALOG_SUCCESS, 6 | } from './types'; 7 | import { Sneakers } from 'types'; 8 | import produce, { Draft } from 'immer'; 9 | 10 | export interface CatalogState { 11 | readonly data: Sneakers[]; 12 | readonly isLoading: boolean; 13 | readonly error?: string; 14 | } 15 | 16 | export const initialState: CatalogState = { 17 | data: [], 18 | isLoading: false, 19 | error: undefined, 20 | }; 21 | 22 | export default produce( 23 | (draft: Draft = initialState, action: CatalogActionTypes) => { 24 | switch (action.type) { 25 | case FETCH_CATALOG: 26 | draft.isLoading = true; 27 | draft.error = undefined; 28 | return; 29 | case FETCH_CATALOG_SUCCESS: 30 | draft.data = action.payload; 31 | draft.isLoading = false; 32 | draft.error = undefined; 33 | return; 34 | case FETCH_CATALOG_FAILURE: 35 | draft.data = initialState.data; 36 | draft.isLoading = false; 37 | draft.error = action.payload; 38 | return; 39 | } 40 | return draft; 41 | } 42 | ); 43 | -------------------------------------------------------------------------------- /src/store/ducks/catalog/saga.ts: -------------------------------------------------------------------------------- 1 | import { put, takeLatest, call } from 'redux-saga/effects'; 2 | 3 | import * as actions from './actions'; 4 | import * as types from './types'; 5 | import * as service from './service'; 6 | 7 | function* fetchCatalog() { 8 | try { 9 | const data = yield call(service.fetchCatalog); 10 | 11 | yield put(actions.fetchCatalogSuccess(data)); 12 | } catch (error) { 13 | yield put(actions.fetchCatalogError(error.message)); 14 | } 15 | } 16 | 17 | export function* catalogSaga() { 18 | yield takeLatest(types.FETCH_CATALOG, fetchCatalog); 19 | } 20 | -------------------------------------------------------------------------------- /src/store/ducks/catalog/selectors.ts: -------------------------------------------------------------------------------- 1 | import { State } from 'types'; 2 | 3 | export function getCatalog(state: State) { 4 | return state.catalog.data; 5 | } 6 | 7 | export function isLoading(state: State) { 8 | return state.catalog.isLoading; 9 | } 10 | -------------------------------------------------------------------------------- /src/store/ducks/catalog/service.ts: -------------------------------------------------------------------------------- 1 | import { serializer as shoesSerializer } from '../shoes/service'; 2 | import { timeout } from '../timeoutHelper'; 3 | 4 | // Emulate api request 5 | export const fetchCatalog = () => 6 | timeout(500) 7 | .then(() => import('./mock.json')) 8 | .then(loaded => 9 | loaded.sections[0].items.slice(30).map(shoesSerializer) 10 | ); 11 | -------------------------------------------------------------------------------- /src/store/ducks/catalog/types.ts: -------------------------------------------------------------------------------- 1 | import { Sneakers, ReduxAction } from 'types'; 2 | 3 | // types 4 | export const FETCH_CATALOG = '@@catalog/FETCH_CATALOG'; 5 | export const FETCH_CATALOG_SUCCESS = '@@catalog/FETCH_CATALOG_SUCCESS'; 6 | export const FETCH_CATALOG_FAILURE = '@@catalog/FETCH_CATALOG_FAILURE'; 7 | 8 | // action types 9 | export type FetchCatalogAction = ReduxAction; 10 | export type FetchCatalogSuccessAction = ReduxAction< 11 | typeof FETCH_CATALOG_SUCCESS, 12 | Sneakers[] 13 | >; 14 | export type FetchCatalogFailureAction = ReduxAction< 15 | typeof FETCH_CATALOG_FAILURE, 16 | string 17 | >; 18 | 19 | export type CatalogActionTypes = 20 | | FetchCatalogAction 21 | | FetchCatalogSuccessAction 22 | | FetchCatalogFailureAction; 23 | -------------------------------------------------------------------------------- /src/store/ducks/homepage/actions.ts: -------------------------------------------------------------------------------- 1 | import { Sneakers } from 'types'; 2 | import * as types from './types'; 3 | 4 | // action creators 5 | export function fetchHomepage(): types.FetchHomepageAction { 6 | return { type: types.FETCH_HOMEPAGE }; 7 | } 8 | 9 | export function fetchHomepageSuccess(data: { 10 | popular: Sneakers[]; 11 | newest: Sneakers[]; 12 | }): types.FetchHomepageSuccessAction { 13 | return { type: types.FETCH_HOMEPAGE_SUCCESS, payload: data }; 14 | } 15 | 16 | export function fetchHomepageError( 17 | error: string 18 | ): types.FetchHomepageFailureAction { 19 | return { type: types.FETCH_HOMEPAGE_FAILURE, payload: error }; 20 | } 21 | -------------------------------------------------------------------------------- /src/store/ducks/homepage/reducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HomepageActionTypes, 3 | FETCH_HOMEPAGE, 4 | FETCH_HOMEPAGE_FAILURE, 5 | FETCH_HOMEPAGE_SUCCESS, 6 | } from './types'; 7 | import { Sneakers } from 'types'; 8 | import produce, { Draft } from 'immer'; 9 | 10 | export interface HomepageState { 11 | readonly data: { 12 | popular: Sneakers[]; 13 | newest: Sneakers[]; 14 | }; 15 | readonly isLoading: boolean; 16 | readonly error?: string; 17 | } 18 | 19 | export const initialState: HomepageState = { 20 | data: { 21 | popular: [], 22 | newest: [], 23 | }, 24 | isLoading: false, 25 | error: undefined, 26 | }; 27 | 28 | export default produce( 29 | ( 30 | draft: Draft = initialState, 31 | action: HomepageActionTypes 32 | ) => { 33 | switch (action.type) { 34 | case FETCH_HOMEPAGE: 35 | draft.isLoading = true; 36 | draft.error = undefined; 37 | return; 38 | case FETCH_HOMEPAGE_SUCCESS: 39 | draft.data = action.payload; 40 | draft.isLoading = false; 41 | draft.error = undefined; 42 | return; 43 | case FETCH_HOMEPAGE_FAILURE: 44 | draft.data = initialState.data; 45 | draft.isLoading = false; 46 | draft.error = action.payload; 47 | return; 48 | } 49 | return draft; 50 | } 51 | ); 52 | -------------------------------------------------------------------------------- /src/store/ducks/homepage/saga.ts: -------------------------------------------------------------------------------- 1 | import { put, takeLatest, call } from 'redux-saga/effects'; 2 | 3 | import * as actions from './actions'; 4 | import * as types from './types'; 5 | import * as service from './service'; 6 | 7 | function* fetchHomepage() { 8 | try { 9 | const data = yield call(service.fetchHomepage); 10 | 11 | yield put(actions.fetchHomepageSuccess(data)); 12 | } catch (error) { 13 | yield put(actions.fetchHomepageError(error.message)); 14 | } 15 | } 16 | 17 | export function* homepageSaga() { 18 | yield takeLatest(types.FETCH_HOMEPAGE, fetchHomepage); 19 | } 20 | -------------------------------------------------------------------------------- /src/store/ducks/homepage/selectors.ts: -------------------------------------------------------------------------------- 1 | import { State } from 'types'; 2 | 3 | export function getHomepage(state: State) { 4 | return state.homepage.data; 5 | } 6 | 7 | export function isLoading(state: State) { 8 | return state.homepage.isLoading; 9 | } 10 | -------------------------------------------------------------------------------- /src/store/ducks/homepage/service.ts: -------------------------------------------------------------------------------- 1 | import { serializer as shoesSerializer } from '../shoes/service'; 2 | import { timeout } from '../timeoutHelper'; 3 | 4 | // Emulate api request 5 | export const fetchHomepage = () => 6 | timeout(500) 7 | .then(() => import('./mock.json')) 8 | .then(loaded => ({ 9 | newest: loaded.sections[0].items.slice(0, 6).map(shoesSerializer), 10 | popular: loaded.sections[0].items.slice(6, 9).map(shoesSerializer), 11 | })); 12 | -------------------------------------------------------------------------------- /src/store/ducks/homepage/types.ts: -------------------------------------------------------------------------------- 1 | import { Sneakers, ReduxAction } from 'types'; 2 | 3 | // types 4 | export const FETCH_HOMEPAGE = '@@homepage/FETCH_HOMEPAGE'; 5 | export const FETCH_HOMEPAGE_SUCCESS = '@@homepage/FETCH_HOMEPAGE_SUCCESS'; 6 | export const FETCH_HOMEPAGE_FAILURE = '@@homepage/FETCH_HOMEPAGE_FAILURE'; 7 | 8 | // action types 9 | export type FetchHomepageAction = ReduxAction; 10 | export type FetchHomepageSuccessAction = ReduxAction< 11 | typeof FETCH_HOMEPAGE_SUCCESS, 12 | { popular: Sneakers[]; newest: Sneakers[] } 13 | >; 14 | export type FetchHomepageFailureAction = ReduxAction< 15 | typeof FETCH_HOMEPAGE_FAILURE, 16 | string 17 | >; 18 | 19 | export type HomepageActionTypes = 20 | | FetchHomepageAction 21 | | FetchHomepageSuccessAction 22 | | FetchHomepageFailureAction; 23 | -------------------------------------------------------------------------------- /src/store/ducks/router/saga.ts: -------------------------------------------------------------------------------- 1 | import { takeEvery, call } from 'redux-saga/effects'; 2 | import * as types from './types'; 3 | 4 | export function* changeLocation() { 5 | // After changing page, scroll to top 6 | yield call(window.scrollTo.bind(window), 0, 0); 7 | } 8 | 9 | export function* routerSaga() { 10 | yield takeEvery(types.LOCATION_CHANGE, changeLocation); 11 | } 12 | -------------------------------------------------------------------------------- /src/store/ducks/router/selectors.ts: -------------------------------------------------------------------------------- 1 | import { State } from 'types'; 2 | 3 | export function getCurrentPathname(state: State) { 4 | return state.router.location.pathname; 5 | } 6 | -------------------------------------------------------------------------------- /src/store/ducks/router/types.ts: -------------------------------------------------------------------------------- 1 | export const LOCATION_CHANGE = '@@router/LOCATION_CHANGE'; 2 | -------------------------------------------------------------------------------- /src/store/ducks/shoes/actions.ts: -------------------------------------------------------------------------------- 1 | import { Sneakers } from 'types'; 2 | import * as types from './types'; 3 | 4 | // action creators 5 | export function fetchShoes(slug: string): types.FetchShoesAction { 6 | return { type: types.FETCH_SHOES, payload: slug }; 7 | } 8 | 9 | export function fetchShoesSuccess( 10 | data: Sneakers 11 | ): types.FetchShoesSuccessAction { 12 | return { type: types.FETCH_SHOES_SUCCESS, payload: data }; 13 | } 14 | 15 | export function fetchShoesError(error: string): types.FetchShoesFailureAction { 16 | return { type: types.FETCH_SHOES_FAILURE, payload: error }; 17 | } 18 | -------------------------------------------------------------------------------- /src/store/ducks/shoes/mock.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Nike Air Max 97 On Air Jasmine Lasode", 3 | "slug": "AO2582-004", 4 | "localPrice": "$200", 5 | "images": [ 6 | "/images/ux4wrrodlumtnefucnq8.jpg", 7 | "/images/tqbhkhohrwonshwh7srl.jpg", 8 | "/images/o8pc93ifccwo1qihdpqp.jpg", 9 | "/images/pljue2bfqbmq6ruw0ht1.jpg", 10 | "/images/ul2yvanoaw2ntfhslqd2.jpg", 11 | "/images/bs2mikksg5tqynlbq3sl.jpg" 12 | ], 13 | "description": "Although the Air Jordan VI brought MJ his first of many championships, it also marked the end of Nike branding and visible Air windows on a Jordan silhouette. Taking inspiration from luxury sports cars with the heel tab mimicking a rear spoiler, this latest colorway features a dynamic pattern on the mesh overlays while the rubber outsole provides traction from the classroom to the playground.", 14 | "subtitle": "BIG KIDS\" AIR JORDAN VI", 15 | "spriteSheet": "https://images.nike.com/is/image/DotCom/pwp_sheet2?$NIKE_PWPx3$&$img0=CJ1436_100", 16 | "pdpUrl": "https://www.nike.com/launch/r/CJ1436-100" 17 | } 18 | -------------------------------------------------------------------------------- /src/store/ducks/shoes/reducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ShoesActionTypes, 3 | FETCH_SHOES, 4 | FETCH_SHOES_FAILURE, 5 | FETCH_SHOES_SUCCESS, 6 | } from './types'; 7 | import { Sneakers } from 'types'; 8 | import produce, { Draft } from 'immer'; 9 | 10 | export interface ShoesState { 11 | readonly data?: Sneakers; 12 | readonly isLoading: boolean; 13 | readonly error?: string; 14 | } 15 | 16 | export const initialState: ShoesState = { 17 | data: undefined, 18 | isLoading: false, 19 | error: undefined, 20 | }; 21 | 22 | export default produce( 23 | (draft: Draft = initialState, action: ShoesActionTypes) => { 24 | switch (action.type) { 25 | case FETCH_SHOES: 26 | draft.isLoading = true; 27 | draft.error = undefined; 28 | return; 29 | case FETCH_SHOES_SUCCESS: 30 | draft.data = action.payload; 31 | draft.isLoading = false; 32 | draft.error = undefined; 33 | return; 34 | case FETCH_SHOES_FAILURE: 35 | draft.data = initialState.data; 36 | draft.isLoading = false; 37 | draft.error = action.payload; 38 | return; 39 | } 40 | return draft; 41 | } 42 | ); 43 | -------------------------------------------------------------------------------- /src/store/ducks/shoes/saga.ts: -------------------------------------------------------------------------------- 1 | import { put, takeLatest, call } from 'redux-saga/effects'; 2 | 3 | import * as actions from './actions'; 4 | import * as types from './types'; 5 | import * as service from './service'; 6 | 7 | function* fetchShoes(action: types.FetchShoesAction) { 8 | try { 9 | const slug = action.payload; 10 | const data = yield call(service.fetchShoes, slug); 11 | 12 | yield put(actions.fetchShoesSuccess(data)); 13 | } catch (error) { 14 | yield put(actions.fetchShoesError(error.message)); 15 | } 16 | } 17 | 18 | export function* shoesSaga() { 19 | yield takeLatest(types.FETCH_SHOES, fetchShoes); 20 | } 21 | -------------------------------------------------------------------------------- /src/store/ducks/shoes/selectors.ts: -------------------------------------------------------------------------------- 1 | import { State } from 'types'; 2 | 3 | export function getShoes(state: State) { 4 | return state.shoes.data; 5 | } 6 | 7 | export function isLoading(state: State) { 8 | return state.shoes.isLoading; 9 | } 10 | -------------------------------------------------------------------------------- /src/store/ducks/shoes/service.ts: -------------------------------------------------------------------------------- 1 | import { timeout } from '../timeoutHelper'; 2 | // @ts-ignore 3 | import mock from './mock.json'; 4 | 5 | export const serializer = (data: any) => { 6 | const [, image] = (data.spriteSheet || '').match(/img0=([^&]+)&?/); 7 | const slug = data.slug || (data.pdpUrl.split('/') || ['']).pop(); 8 | 9 | return { 10 | slug, 11 | image: `/images/${image.replace('/', '_')}.jpg`, 12 | images: data.images, 13 | title: data.title, 14 | subtitle: data.subtitle, 15 | price: data.localPrice, 16 | description: data.description, 17 | url: `/sneakers/${slug}`, 18 | }; 19 | }; 20 | 21 | // Emulate api request 22 | export const fetchShoes = (slug: string) => 23 | timeout(500).then(() => serializer({ ...mock, slug })); 24 | -------------------------------------------------------------------------------- /src/store/ducks/shoes/types.ts: -------------------------------------------------------------------------------- 1 | import { Sneakers, ReduxAction } from 'types'; 2 | 3 | // types 4 | export const FETCH_SHOES = '@@shoes/FETCH_SHOES'; 5 | export const FETCH_SHOES_SUCCESS = '@@shoes/FETCH_SHOES_SUCCESS'; 6 | export const FETCH_SHOES_FAILURE = '@@shoes/FETCH_SHOES_FAILURE'; 7 | 8 | // action types 9 | export type FetchShoesAction = ReduxAction; 10 | export type FetchShoesSuccessAction = ReduxAction< 11 | typeof FETCH_SHOES_SUCCESS, 12 | Sneakers 13 | >; 14 | export type FetchShoesFailureAction = ReduxAction< 15 | typeof FETCH_SHOES_FAILURE, 16 | string 17 | >; 18 | 19 | export type ShoesActionTypes = 20 | | FetchShoesAction 21 | | FetchShoesSuccessAction 22 | | FetchShoesFailureAction; 23 | -------------------------------------------------------------------------------- /src/store/ducks/timeoutHelper.ts: -------------------------------------------------------------------------------- 1 | export const timeout = (ms: number) => 2 | new Promise(resolve => { 3 | setTimeout(() => { 4 | resolve(); 5 | }, ms); 6 | }); 7 | -------------------------------------------------------------------------------- /src/store/getInitialState.ts: -------------------------------------------------------------------------------- 1 | import { RouterState } from 'connected-react-router'; 2 | import { State } from 'types'; 3 | import { initialState as homepage } from './ducks/homepage/reducer'; 4 | import { initialState as catalog } from './ducks/catalog/reducer'; 5 | import { initialState as shoes } from './ducks/shoes/reducer'; 6 | 7 | export const getInitialState = (pathname: string = '/'): State => { 8 | return { 9 | homepage, 10 | catalog, 11 | shoes, 12 | router: { 13 | location: { pathname, search: '', hash: '', key: '' }, 14 | action: 'POP', 15 | } as RouterState, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/store/rootReducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { connectRouter } from 'connected-react-router'; 3 | import { History } from 'history'; 4 | 5 | import homepage from './ducks/homepage/reducer'; 6 | import catalog from './ducks/catalog/reducer'; 7 | import shoes from './ducks/shoes/reducer'; 8 | import { State } from 'types'; 9 | 10 | export default (history: History) => 11 | combineReducers({ 12 | homepage, 13 | catalog, 14 | shoes, 15 | router: connectRouter(history), 16 | }); 17 | -------------------------------------------------------------------------------- /src/store/rootSaga.ts: -------------------------------------------------------------------------------- 1 | import { fork, all } from 'redux-saga/effects'; 2 | 3 | import { catalogSaga } from './ducks/catalog/saga'; 4 | import { shoesSaga } from './ducks/shoes/saga'; 5 | import { homepageSaga } from './ducks/homepage/saga'; 6 | import { routerSaga } from './ducks/router/saga'; 7 | 8 | export default function* rootSaga() { 9 | yield all([ 10 | fork(homepageSaga), 11 | fork(routerSaga), 12 | fork(catalogSaga), 13 | fork(shoesSaga), 14 | ]); 15 | } 16 | -------------------------------------------------------------------------------- /src/store/rootStore.ts: -------------------------------------------------------------------------------- 1 | import { createStore, compose, applyMiddleware, Store } from 'redux'; 2 | import createSagaMiddleware, { END, SagaMiddleware } from 'redux-saga'; 3 | import { routerMiddleware } from 'connected-react-router'; 4 | import { createBrowserHistory, createMemoryHistory } from 'history'; 5 | import { AppStore, State } from 'types'; 6 | import createRootReducer from './rootReducer'; 7 | import rootSaga from './rootSaga'; 8 | 9 | function getComposeEnhancers() { 10 | if (process.env.NODE_ENV !== 'production' && !isServer) { 11 | return window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 12 | } 13 | 14 | return compose; 15 | } 16 | 17 | export const isServer = !( 18 | typeof window !== 'undefined' && 19 | window.document && 20 | window.document.createElement 21 | ); 22 | 23 | export function configureStore(initialState: State, url = '/') { 24 | const history = isServer 25 | ? createMemoryHistory({ initialEntries: [url] }) 26 | : createBrowserHistory(); 27 | 28 | const sagaMiddleware = createSagaMiddleware(); 29 | const composeEnhancers = getComposeEnhancers(); 30 | const middlewares = [routerMiddleware(history), sagaMiddleware]; 31 | 32 | const store = createStore( 33 | createRootReducer(history), 34 | initialState, 35 | composeEnhancers(applyMiddleware(...middlewares)) 36 | ) as AppStore; 37 | 38 | // Add methods to use in the server 39 | store.runSaga = sagaMiddleware.run; 40 | store.close = () => store.dispatch(END); 41 | 42 | if (!isServer) { 43 | sagaMiddleware.run(rootSaga); 44 | } 45 | 46 | return { store, history }; 47 | } 48 | -------------------------------------------------------------------------------- /src/styles/base.css: -------------------------------------------------------------------------------- 1 | html, body, #mount { 2 | padding: 0; 3 | margin: 0; 4 | 5 | display: flex; 6 | width: 100%; 7 | min-height: 100%; 8 | } 9 | 10 | html { 11 | overflow-y: scroll; 12 | } 13 | 14 | a { 15 | color: #777; 16 | 17 | &:hover { 18 | color: #ff5c28; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/styles/media.css: -------------------------------------------------------------------------------- 1 | @custom-media --viewport-mobile (max-width: 980px); 2 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from 'react'; 2 | import { match } from 'react-router'; 3 | import { State, AppStore, ReduxAction } from './redux'; 4 | import { Sneakers } from './models'; 5 | 6 | export type RouterFetchDataArgs = { 7 | dispatch: Dispatch; 8 | match: match<{ slug: string }>; 9 | }; 10 | 11 | export { Sneakers, State, AppStore, ReduxAction }; 12 | -------------------------------------------------------------------------------- /src/types/models.ts: -------------------------------------------------------------------------------- 1 | // Models 2 | export type Sneakers = { 3 | id: string; 4 | slug: string; 5 | title: string; 6 | subtitle: string; 7 | category: string; 8 | description: string; 9 | image: string; 10 | images: string[]; 11 | price: string; 12 | url: string; 13 | text: string; 14 | date?: string; 15 | }; 16 | -------------------------------------------------------------------------------- /src/types/redux.ts: -------------------------------------------------------------------------------- 1 | import { Action, Store } from 'redux'; 2 | import { SagaMiddleware } from '@redux-saga/core'; 3 | import { RouterState } from 'connected-react-router'; 4 | import { HomepageState } from 'store/ducks/homepage/reducer'; 5 | import { CatalogState } from 'store/ducks/catalog/reducer'; 6 | import { ShoesState } from 'store/ducks/shoes/reducer'; 7 | 8 | // Redux types 9 | export interface ReduxAction extends Action { 10 | type: T; 11 | payload?: P; 12 | } 13 | 14 | export type AppStore = Store & { 15 | runSaga: SagaMiddleware['run']; 16 | close: () => void; 17 | }; 18 | 19 | export interface State { 20 | readonly homepage: HomepageState; 21 | readonly catalog: CatalogState; 22 | readonly shoes: ShoesState; 23 | readonly router: RouterState; 24 | } 25 | -------------------------------------------------------------------------------- /static/images/104265_131.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/104265_131.jpg -------------------------------------------------------------------------------- /static/images/487471_100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/487471_100.jpg -------------------------------------------------------------------------------- /static/images/532225_006.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/532225_006.jpg -------------------------------------------------------------------------------- /static/images/554724_050.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/554724_050.jpg -------------------------------------------------------------------------------- /static/images/555088_081.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/555088_081.jpg -------------------------------------------------------------------------------- /static/images/624041_800.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/624041_800.jpg -------------------------------------------------------------------------------- /static/images/749766_408.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/749766_408.jpg -------------------------------------------------------------------------------- /static/images/917165_120.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/917165_120.jpg -------------------------------------------------------------------------------- /static/images/AA2146_003.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AA2146_003.jpg -------------------------------------------------------------------------------- /static/images/AH7241_118.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AH7241_118.jpg -------------------------------------------------------------------------------- /static/images/AH7242_118.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AH7242_118.jpg -------------------------------------------------------------------------------- /static/images/AH7368_801.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AH7368_801.jpg -------------------------------------------------------------------------------- /static/images/AH7369_801.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AH7369_801.jpg -------------------------------------------------------------------------------- /static/images/AJ1285_103.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AJ1285_103.jpg -------------------------------------------------------------------------------- /static/images/AJ2018_004.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AJ2018_004.jpg -------------------------------------------------------------------------------- /static/images/AJ5898_001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AJ5898_001.jpg -------------------------------------------------------------------------------- /static/images/AJ6745_003.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AJ6745_003.jpg -------------------------------------------------------------------------------- /static/images/AJ6900_001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AJ6900_001.jpg -------------------------------------------------------------------------------- /static/images/AJ6900_100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AJ6900_100.jpg -------------------------------------------------------------------------------- /static/images/AO0566_440.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO0566_440.jpg -------------------------------------------------------------------------------- /static/images/AO1741_103.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO1741_103.jpg -------------------------------------------------------------------------------- /static/images/AO2409_100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO2409_100.jpg -------------------------------------------------------------------------------- /static/images/AO2434_101.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO2434_101.jpg -------------------------------------------------------------------------------- /static/images/AO2439_401.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO2439_401.jpg -------------------------------------------------------------------------------- /static/images/AO2582_004.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO2582_004.jpg -------------------------------------------------------------------------------- /static/images/AO2924_008.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO2924_008.jpg -------------------------------------------------------------------------------- /static/images/AO3258_440.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO3258_440.jpg -------------------------------------------------------------------------------- /static/images/AO3266_410.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO3266_410.jpg -------------------------------------------------------------------------------- /static/images/AO3276_410.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO3276_410.jpg -------------------------------------------------------------------------------- /static/images/AO3277_600.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO3277_600.jpg -------------------------------------------------------------------------------- /static/images/AO4436_001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO4436_001.jpg -------------------------------------------------------------------------------- /static/images/AO6219_100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO6219_100.jpg -------------------------------------------------------------------------------- /static/images/AQ1289_100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ1289_100.jpg -------------------------------------------------------------------------------- /static/images/AQ2235_100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ2235_100.jpg -------------------------------------------------------------------------------- /static/images/AQ3619_400.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ3619_400.jpg -------------------------------------------------------------------------------- /static/images/AQ5707_002.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ5707_002.jpg -------------------------------------------------------------------------------- /static/images/AQ7495_100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ7495_100.jpg -------------------------------------------------------------------------------- /static/images/AQ8306_600.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ8306_600.jpg -------------------------------------------------------------------------------- /static/images/AQ8741_300.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ8741_300.jpg -------------------------------------------------------------------------------- /static/images/AQ9164_005.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ9164_005.jpg -------------------------------------------------------------------------------- /static/images/AR4229_001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AR4229_001.jpg -------------------------------------------------------------------------------- /static/images/AR6631_007.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AR6631_007.jpg -------------------------------------------------------------------------------- /static/images/AR6631_200.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AR6631_200.jpg -------------------------------------------------------------------------------- /static/images/BQ7460_102.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/BQ7460_102.jpg -------------------------------------------------------------------------------- /static/images/BQ7496_104.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/BQ7496_104.jpg -------------------------------------------------------------------------------- /static/images/BV1654_002.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/BV1654_002.jpg -------------------------------------------------------------------------------- /static/images/BV7406_001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/BV7406_001.jpg -------------------------------------------------------------------------------- /static/images/CD8238_001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CD8238_001.jpg -------------------------------------------------------------------------------- /static/images/CD9560_106.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CD9560_106.jpg -------------------------------------------------------------------------------- /static/images/CI1502_001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI1502_001.jpg -------------------------------------------------------------------------------- /static/images/CI1503_001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI1503_001.jpg -------------------------------------------------------------------------------- /static/images/CI1504_100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI1504_100.jpg -------------------------------------------------------------------------------- /static/images/CI1505_001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI1505_001.jpg -------------------------------------------------------------------------------- /static/images/CI1506_001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI1506_001.jpg -------------------------------------------------------------------------------- /static/images/CI1508_400.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI1508_400.jpg -------------------------------------------------------------------------------- /static/images/CI2668_300.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI2668_300.jpg -------------------------------------------------------------------------------- /static/images/CJ0767_400.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CJ0767_400.jpg -------------------------------------------------------------------------------- /static/images/CJ1436_100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CJ1436_100.jpg -------------------------------------------------------------------------------- /static/images/CK6643_100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CK6643_100.jpg -------------------------------------------------------------------------------- /static/images/bs2mikksg5tqynlbq3sl.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/bs2mikksg5tqynlbq3sl.jpg -------------------------------------------------------------------------------- /static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/favicon.png -------------------------------------------------------------------------------- /static/images/o8pc93ifccwo1qihdpqp.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/o8pc93ifccwo1qihdpqp.jpg -------------------------------------------------------------------------------- /static/images/pljue2bfqbmq6ruw0ht1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/pljue2bfqbmq6ruw0ht1.jpg -------------------------------------------------------------------------------- /static/images/tqbhkhohrwonshwh7srl.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/tqbhkhohrwonshwh7srl.jpg -------------------------------------------------------------------------------- /static/images/ugc_659925883.tif.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/ugc_659925883.tif.jpg -------------------------------------------------------------------------------- /static/images/ul2yvanoaw2ntfhslqd2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/ul2yvanoaw2ntfhslqd2.jpg -------------------------------------------------------------------------------- /static/images/ux4wrrodlumtnefucnq8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/ux4wrrodlumtnefucnq8.jpg -------------------------------------------------------------------------------- /static/preview-preload-bundles.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/preview-preload-bundles.gif -------------------------------------------------------------------------------- /static/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/preview.gif -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "noImplicitAny": true, 6 | "noImplicitThis": true, 7 | "resolveJsonModule": true, 8 | "allowSyntheticDefaultImports": true, 9 | "module": "commonjs", 10 | "target": "es5", 11 | "jsx": "react", 12 | "baseUrl": "src" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import clientConfig from './webpack/client.config'; 2 | import serverConfig from './webpack/server.config'; 3 | 4 | export default [ 5 | clientConfig, 6 | serverConfig 7 | ]; 8 | -------------------------------------------------------------------------------- /webpack/client.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { Configuration, Plugin, Entry } from 'webpack'; 3 | import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'; 4 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 5 | import CompressionPlugin from 'compression-webpack-plugin'; 6 | import LoadablePlugin from '@loadable/webpack-plugin'; 7 | 8 | import { IS_DEV, DIST_DIR, SRC_DIR } from './env'; 9 | import fileLoader from './loaders/file'; 10 | import cssLoader from './loaders/css'; 11 | import jsLoader from './loaders/js'; 12 | 13 | const config: Configuration = { 14 | entry: ([ 15 | IS_DEV && 'react-hot-loader/patch', 16 | // IS_DEV && 'webpack-hot-middleware/client', 17 | IS_DEV && 'css-hot-loader/hotModuleReplacement', 18 | path.join(SRC_DIR, 'client'), 19 | ].filter(Boolean) as unknown) as Entry, 20 | module: { 21 | rules: [fileLoader.client, cssLoader.client, jsLoader.client], 22 | }, 23 | output: { 24 | path: DIST_DIR, 25 | filename: '[name].js', 26 | publicPath: '/', 27 | }, 28 | resolve: { 29 | modules: ['src', 'node_modules'], 30 | alias: { 'react-dom': '@hot-loader/react-dom' }, 31 | extensions: ['*', '.js', '.jsx', '.json', '.ts', '.tsx'], 32 | plugins: [new TsconfigPathsPlugin({ configFile: './tsconfig.json' })], 33 | }, 34 | plugins: [ 35 | new MiniCssExtractPlugin({ filename: '[name].css' }), 36 | !IS_DEV && new CompressionPlugin(), 37 | new LoadablePlugin(), 38 | ].filter(Boolean) as Plugin[], 39 | 40 | devtool: 'source-map', 41 | 42 | performance: { 43 | hints: IS_DEV ? false : 'warning', 44 | }, 45 | }; 46 | 47 | export default config; 48 | -------------------------------------------------------------------------------- /webpack/env.ts: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const IS_DEV = process.env.NODE_ENV !== 'production'; 4 | const SRC_DIR = path.join(__dirname, '../src'); 5 | const DIST_DIR = path.join(__dirname, '../dist'); 6 | 7 | export { IS_DEV, SRC_DIR, DIST_DIR }; 8 | -------------------------------------------------------------------------------- /webpack/loaders/css.ts: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const postcssNested = require('postcss-nested'); 3 | const postcssCustomMedia = require('postcss-custom-media'); 4 | const postcssImport = require('postcss-import'); 5 | const postcssImportAliasResolver = require('postcss-import-alias-resolver'); 6 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 7 | const cssnano = require('cssnano'); 8 | 9 | const { IS_DEV } = require('../env'); 10 | 11 | const resolverOptions = { 12 | alias: { styles: path.resolve('src/styles') }, 13 | mergeExtensions: 'extend', 14 | }; 15 | 16 | export default { 17 | client: { 18 | test: /\.css$/, 19 | use: [ 20 | IS_DEV && 'css-hot-loader', 21 | MiniCssExtractPlugin.loader, 22 | 'css-loader', 23 | { 24 | loader: 'postcss-loader', 25 | options: { 26 | plugins: [ 27 | postcssImport({ 28 | resolve: postcssImportAliasResolver( 29 | resolverOptions 30 | ), 31 | }), 32 | postcssCustomMedia(), 33 | postcssNested(), 34 | !IS_DEV && cssnano({ preset: 'default' }), 35 | ].filter(Boolean), 36 | }, 37 | }, 38 | ].filter(Boolean), 39 | }, 40 | server: { 41 | test: /\.css$/, 42 | loader: 'null-loader', 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /webpack/loaders/file.ts: -------------------------------------------------------------------------------- 1 | const fileRegex = /^(?!.*\.inline).*\.(svg|jpe?g|png|gif|eot|woff2?|ttf)$/; 2 | 3 | export default { 4 | client: { 5 | loader: 'url-loader', 6 | test: fileRegex, 7 | }, 8 | server: { 9 | loader: 'null-loader', 10 | test: fileRegex, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /webpack/loaders/js.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | client: { 3 | test: /\.ts(x?)$/, 4 | exclude: /node_modules/, 5 | use: { loader: 'babel-loader' }, 6 | }, 7 | server: { 8 | test: /\.ts(x?)$/, 9 | exclude: /node_modules/, 10 | use: { loader: 'babel-loader' }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /webpack/server.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { Configuration } from 'webpack'; 3 | import nodeExternals from 'webpack-node-externals'; 4 | import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'; 5 | 6 | import { IS_DEV, DIST_DIR, SRC_DIR } from './env'; 7 | import fileLoader from './loaders/file'; 8 | import cssLoader from './loaders/css'; 9 | import jsLoader from './loaders/js'; 10 | 11 | const config: Configuration = { 12 | name: 'server', 13 | target: 'node', 14 | node: { __dirname: false }, 15 | entry: path.join(SRC_DIR, 'server'), 16 | module: { 17 | rules: [fileLoader.server, cssLoader.server, jsLoader.server], 18 | }, 19 | output: { 20 | filename: 'server.js', 21 | libraryTarget: 'commonjs2', 22 | path: DIST_DIR, 23 | publicPath: '/static/', 24 | }, 25 | resolve: { 26 | modules: ['src', 'node_modules'], 27 | extensions: ['*', '.js', '.jsx', '.json', '.ts', '.tsx'], 28 | plugins: [new TsconfigPathsPlugin({ configFile: './tsconfig.json' })], 29 | }, 30 | 31 | devtool: 'source-map', 32 | 33 | performance: { 34 | hints: IS_DEV ? false : 'warning', 35 | }, 36 | 37 | externals: [nodeExternals({ allowlist: [/\.(?!(?:tsx?|json)$).{1,5}$/i] })], 38 | 39 | optimization: { nodeEnv: false }, 40 | }; 41 | 42 | export default config; 43 | --------------------------------------------------------------------------------