├── tests ├── _fixtures │ ├── foo.js │ ├── empty-string.ts │ └── index.html ├── setup.ts ├── client │ └── sample-test.spec.ts ├── shared │ └── PrerenderData.spec.ts └── server │ └── server.spec.ts ├── .gitignore ├── src ├── client │ ├── resources │ │ ├── favicon.ico │ │ └── images │ │ │ ├── sample-image-1.png │ │ │ └── sample-image-2.png │ ├── pages │ │ ├── HomePage.tsx │ │ ├── SamplePage2.tsx │ │ └── SamplePage1.tsx │ ├── index.html │ ├── Index.tsx │ ├── serverData.ts │ └── App.tsx ├── shared │ ├── models.ts │ ├── types.d.ts │ └── PrerenderedData.ts └── server │ ├── app.ts │ ├── configuration.ts │ ├── middleware │ ├── reactMiddleware.tsx │ ├── errorMiddleware.ts │ └── routing.ts │ ├── server.ts │ └── ssr │ └── renderReactAsync.tsx ├── tsconfig.json ├── jest.config.ts ├── readme.md ├── package.json └── webpack.config.ts /tests/_fixtures/foo.js: -------------------------------------------------------------------------------- 1 | console.log('foo'); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .vscode/ 3 | dist/ -------------------------------------------------------------------------------- /tests/_fixtures/empty-string.ts: -------------------------------------------------------------------------------- 1 | export default ""; -------------------------------------------------------------------------------- /src/client/resources/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marceloaugusto80/react-ssr-express/HEAD/src/client/resources/favicon.ico -------------------------------------------------------------------------------- /src/shared/models.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Just an example model. 3 | */ 4 | export interface ExampleModel { 5 | message: string; 6 | id: number 7 | } 8 | -------------------------------------------------------------------------------- /src/client/resources/images/sample-image-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marceloaugusto80/react-ssr-express/HEAD/src/client/resources/images/sample-image-1.png -------------------------------------------------------------------------------- /src/client/resources/images/sample-image-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marceloaugusto80/react-ssr-express/HEAD/src/client/resources/images/sample-image-2.png -------------------------------------------------------------------------------- /src/server/app.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "server/server"; 2 | import { PORT } from "server/configuration"; 3 | 4 | const server = createServer(); 5 | 6 | server.listen(PORT, () => { 7 | console.log(`Server listening to port ${PORT}`); 8 | }); -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | // here we are mocking the environment variables used by the server 4 | process.env.PUBLIC_DIR_PATH = path.join(__dirname, "_fixtures"); 5 | process.env.HTML_TEMPLATE_PATH = path.join(__dirname, "_fixtures/index.html"); -------------------------------------------------------------------------------- /tests/client/sample-test.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | // put your imports here, after jest-environemnt pragma 6 | 7 | describe("Sample", () => { 8 | 9 | test("sample test", () => { 10 | 11 | expect(window).toBeDefined(); 12 | 13 | }); 14 | 15 | }); 16 | 17 | -------------------------------------------------------------------------------- /src/client/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | /** 4 | * The home page. Nothing fancy here. 5 | * 6 | * @returns react component. 7 | */ 8 | export default function HomePage() { 9 | return ( 10 |
11 |

Home

12 |

The home page.

13 |
14 | ) 15 | } 16 | 17 | -------------------------------------------------------------------------------- /src/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 11 |
12 | 13 | -------------------------------------------------------------------------------- /tests/_fixtures/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 11 |
12 | 13 | -------------------------------------------------------------------------------- /src/client/Index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {hydrate} from "react-dom"; 3 | import App from "./App"; 4 | import {BrowserRouter} from "react-router-dom"; 5 | 6 | function WrappedApp(): JSX.Element { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | } 13 | 14 | hydrate(, document.getElementById("root")); -------------------------------------------------------------------------------- /src/server/configuration.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | /* 4 | Environment configuration. 5 | You can replace or adapt this logic to use .env files 6 | */ 7 | 8 | export const PORT = process.env.PORT ?? 9000; 9 | 10 | export const PUBLIC_DIR_PATH = process.env.PUBLIC_DIR_PATH ?? path.join(__dirname, "public"); 11 | 12 | export const HTML_TEMPLATE_PATH = process.env.HTML_TEMPLATE_PATH ?? path.join(__dirname, "public", "index.html"); 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "compilerOptions": { 4 | "target": "es6", 5 | "module": "commonjs", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "jsx": "react", 9 | "resolveJsonModule": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "baseUrl": "src", 13 | "paths": { 14 | "client/*": ["client/*"], 15 | "shared/*": ["shared/*"], 16 | "server/*": ["server/*"] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/server/middleware/reactMiddleware.tsx: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import { renderReactAsync } from "server/ssr/renderReactAsync"; 3 | 4 | /** 5 | * Creates a React Server Side Rendering middleware. 6 | * 7 | * Install it right after the static files middleware. 8 | * 9 | * @returns The react SSR middleware function. 10 | */ 11 | export function reactMiddleware() { 12 | 13 | return async function (req: Request, res: Response, next: NextFunction) { 14 | try { 15 | // TODO some caching, maybe? 16 | const reactHtml = await renderReactAsync(req.url) 17 | res.set("content-type", "text/html").status(200).send(reactHtml); 18 | } 19 | catch (error) { 20 | next(error); 21 | } 22 | 23 | } 24 | 25 | 26 | } -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "@jest/types"; 2 | 3 | const options: Config.InitialOptions = { 4 | 5 | preset: "ts-jest", 6 | 7 | testEnvironment: "node", 8 | 9 | testTimeout: 10000, 10 | 11 | roots: [ 12 | "/tests" 13 | ], 14 | 15 | testRegex: "\.spec\.tsx?$", 16 | 17 | setupFiles: ["/tests/setup.ts"], 18 | 19 | globals: { 20 | // mock injected build variables. See DefinePlugin options on webpack configuration files. 21 | __PRODUCTION__: true, 22 | __SERVER__: true 23 | }, 24 | 25 | moduleNameMapper: { 26 | "^client\/(.*)$": "/src/client/$1", 27 | "^server\/(.*)$": "/src/server/$1", 28 | "^shared\/(.*)$": "/src/shared/$1", 29 | 30 | '\\.(jpe?g|png|gif|svg|woff2?|eot|ttf|otf)$': '/tests/_fixtures/empty-string.ts', 31 | } 32 | 33 | } 34 | 35 | export default options; -------------------------------------------------------------------------------- /src/server/middleware/errorMiddleware.ts: -------------------------------------------------------------------------------- 1 | import {Request, Response, NextFunction} from "express"; 2 | 3 | /** 4 | * Custom middleware for error handling. 5 | * 6 | * In development environment, it will write the stack in the response. 7 | * 8 | * Always put error middlewares last in the request pipeline. 9 | * 10 | * @returns The error middleware function. 11 | */ 12 | export function errorMiddleware() { 13 | 14 | if(__PRODUCTION__) { 15 | 16 | return function(err: Error, req: Request, res: Response, next: NextFunction) { 17 | console.error(err.stack); 18 | res.status(500).send("Internal server error."); 19 | } 20 | 21 | } 22 | 23 | return function(err: Error, req: Request, res: Response, next: NextFunction) { 24 | console.error(err); 25 | res.status(500).send("Internal server error. Stack: " + err.stack); 26 | } 27 | 28 | 29 | } -------------------------------------------------------------------------------- /src/client/serverData.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import { PrerenderData } from "shared/PrerenderedData"; 3 | 4 | const ServerDataContext = createContext(null); 5 | 6 | /** Set prerender data as context. */ 7 | const ServerDataProvider = ServerDataContext.Provider; 8 | 9 | /** 10 | * Get server prerendered data, if any. 11 | * @returns The prerendered data if defined. Null otherwise. 12 | */ 13 | const useServerData = () => { 14 | const contextData = useContext(ServerDataContext); 15 | if (contextData) return contextData as T; 16 | 17 | // beware, using React.StrictMode will render the document twice and the data will be null. 18 | // Use readFromDom(false) if you're having problems with this hook returning null values. 19 | const prerenderData = PrerenderData.readFromDom(true); 20 | return prerenderData; 21 | } 22 | 23 | export { ServerDataProvider, useServerData }; -------------------------------------------------------------------------------- /src/client/pages/SamplePage2.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import sampleImage1 from "../resources/images/sample-image-1.png"; 4 | import sampleImage2 from "../resources/images/sample-image-2.png"; 5 | 6 | export default function SamplePage2() { 7 | 8 | return ( 9 | 10 | 11 |

Sample page 2

12 |

Static content

13 |

The images below were fetched from the server.

14 |
15 | 16 | 17 |
18 | 19 |
20 | ) 21 | } 22 | 23 | const Wrapper = styled.div` 24 | div.images-sample { 25 | display: flex; 26 | flex-flow: row wrap; 27 | gap: 36px; 28 | align-items: center; 29 | margin: 48px; 30 | padding: 36px; 31 | } 32 | 33 | `; -------------------------------------------------------------------------------- /src/shared/types.d.ts: -------------------------------------------------------------------------------- 1 | // static files declarations 2 | 3 | declare module "*.jpg"; 4 | declare module "*.png"; 5 | declare module "*.gif"; 6 | declare module "*.svg"; 7 | 8 | /** Indicates we are in a server (node) environment. Injected via webpack's DefinePlugin. */ 9 | declare const __SERVER__: boolean; 10 | 11 | /** Indicates we are in a production build. Injected via webpack's DefinePlugin. */ 12 | declare const __PRODUCTION__: boolean; 13 | 14 | // server environment vars 15 | declare namespace NodeJS { 16 | interface Process { 17 | env: { 18 | /** The port the server will be listening to. */ 19 | PORT?: number | string; 20 | /** Absolute path to the public directory where static the files will be served */ 21 | PUBLIC_DIR_PATH?: string; 22 | /** Absolute path to the html template file witch will be used by the react ssr */ 23 | HTML_TEMPLATE_PATH: string; 24 | } 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { errorMiddleware } from "server/middleware/errorMiddleware"; 3 | import { reactMiddleware } from "server/middleware/reactMiddleware"; 4 | import { useRouting } from "server/middleware/routing"; 5 | import { PUBLIC_DIR_PATH } from "server/configuration"; 6 | 7 | // we split the express app definition in a module separated from the entry point because its easier to test. 8 | 9 | export function createServer() { 10 | 11 | const server = express(); 12 | 13 | server.use(express.static(PUBLIC_DIR_PATH, { 14 | index: false // we don want the static middleware to serve index.html. The ssr content won't be serverd otherwise. 15 | })); 16 | 17 | useRouting(server); 18 | 19 | // renders the react app as fallback. The corresponding route will be handled by react router 20 | server.use(/.*/, reactMiddleware()); 21 | 22 | server.use(errorMiddleware()); 23 | 24 | return server; 25 | } 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/server/middleware/routing.ts: -------------------------------------------------------------------------------- 1 | import { Express } from "express"; 2 | import { ExampleModel } from "shared/models"; 3 | import { renderReactAsync } from "server/ssr/renderReactAsync"; 4 | 5 | /** Defines the server routings. */ 6 | export function useRouting(app: Express) { 7 | 8 | app.get("/sample-page-1", async (req, res) => { 9 | 10 | const model: ExampleModel = { 11 | id: 123, 12 | message: "This data came from the server" 13 | }; 14 | 15 | try { 16 | const html = await renderReactAsync(req.url, model); 17 | return res.status(200).contentType("text/html").send(html); 18 | } 19 | catch { 20 | return res.status(500).send("Internal server error"); 21 | } 22 | }); 23 | 24 | /** 25 | * put other routes here like: 26 | * 27 | * app.post("/foobar", (req, res) => { 28 | * 29 | * ...stuff 30 | * 31 | * }) 32 | * 33 | */ 34 | 35 | } -------------------------------------------------------------------------------- /tests/shared/PrerenderData.spec.ts: -------------------------------------------------------------------------------- 1 | import { PrerenderData } from "../../src/shared/PrerenderedData"; 2 | import { JSDOM } from "jsdom"; 3 | 4 | const getHtml = () => { 5 | return ` 6 | 7 | 8 | 9 | 10 | 11 | `; 12 | } 13 | 14 | describe("PrerenderData", () => { 15 | 16 | test("general tests", () => { 17 | 18 | const expected = { 19 | name: "name", 20 | value: 1, 21 | nested: { 22 | otherName: "other name", 23 | otherData: true 24 | } 25 | }; 26 | 27 | const dataTag = PrerenderData.saveToDom(expected); 28 | const html = getHtml().replace("", "" + dataTag); 29 | globalThis.window = new JSDOM(html, { 30 | runScripts: "dangerously" 31 | }).window as unknown as (Window & typeof globalThis); 32 | 33 | let actual = PrerenderData.readFromDom(true); 34 | expect(actual).toEqual(expected); 35 | 36 | actual = PrerenderData.readFromDom(); 37 | expect(actual).toBeNull(); 38 | 39 | }); 40 | 41 | }); -------------------------------------------------------------------------------- /src/client/pages/SamplePage1.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useContext, useEffect, useState } from 'react' 2 | import styled from 'styled-components'; 3 | import { ExampleModel } from 'shared/models'; 4 | import { useServerData } from 'client/serverData'; 5 | 6 | export default function SamplePage1() { 7 | 8 | // you can check this variable state in the useEffect hook and make an api call if it's null. 9 | // that will depends on your requirements. 10 | // Mind that this variable will only receive the prerender data if you're navigating here by a common link, not a Router link. 11 | const model = useServerData(); 12 | 13 | return ( 14 | 15 |

Sample page 1

16 | { 17 | model && 18 |

{model.message}

19 | } 20 | { 21 | !model && 22 | 23 |

If you're seeing this, means that you navigated to this page by the BrowserRouter.

24 |

If you're running the application server, refresh the page to receive a ssr version of it.

25 |
26 | } 27 |
28 | ) 29 | } 30 | 31 | const Wrapper = styled.div` 32 | h2 { 33 | background-color: #dfdfdf; 34 | width: 100%; 35 | padding: 8px; 36 | margin-top: 36px; 37 | } 38 | `; -------------------------------------------------------------------------------- /tests/server/server.spec.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "../../src/server/server"; 2 | import { Express } from "express"; 3 | import request from "supertest"; 4 | 5 | let server: Express; 6 | 7 | 8 | beforeAll(() => { 9 | server = createServer(); 10 | }) 11 | 12 | describe("Server requests", ()=> { 13 | 14 | test("test home route", async () => { 15 | 16 | const response = await request(server).get("/"); 17 | 18 | expect(response.statusCode).toBe(200); 19 | expect(response.headers["content-type"]).toMatch(/text\/html/); 20 | expect(response.text).toMatch(/

Home<\/h1>/); 21 | 22 | }); 23 | 24 | test("test serving static content", async () => { 25 | 26 | const response = await request(server).get("/foo.js"); 27 | 28 | expect(response.statusCode).toBe(200); 29 | expect(response.header["content-type"]).toMatch("application\/javascript"); 30 | expect(response.text).toMatch(/console.log\('foo'\);/); 31 | 32 | }); 33 | 34 | 35 | test("test sample-page-1 route", async () => { 36 | 37 | const response = await request(server).get("/sample-page-1"); 38 | 39 | expect(response.statusCode).toBe(200); 40 | expect(response.headers["content-type"]).toMatch(/text\/html/); 41 | expect(response.text).toMatch(/This\sdata\scame\sfrom\sthe\sserver/); 42 | 43 | }); 44 | 45 | }); -------------------------------------------------------------------------------- /src/shared/PrerenderedData.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | prerenderData: unknown; 4 | } 5 | } 6 | 7 | /** 8 | * Prerendered data utility function. 9 | */ 10 | namespace PrerenderData { 11 | 12 | /** 13 | * In the server side, saves an abitrary object into the dom. This data can be retrieved in the client. 14 | * @param data An object or any data you want to pass down to the client. 15 | * @param domString The html string that will be rendered in the client. 16 | * @returns A new html tag string containing the injected data. 17 | */ 18 | export function saveToDom(data: unknown):string { 19 | 20 | const jsonDataElement = ``; 21 | 22 | return jsonDataElement; 23 | } 24 | 25 | /** 26 | * In the client side, reads any arbitrary object injected by the server. 27 | * @param disposeData True if you want to save some memory and clear the data after reading it. False, otherwise. 28 | * @returns The data, if any. 29 | */ 30 | export function readFromDom(disposeData?: boolean): T | null { 31 | 32 | if(typeof window == "undefined" || !window.prerenderData) return null; 33 | 34 | const data = window.prerenderData as T; 35 | 36 | if(disposeData) window.prerenderData = null; 37 | 38 | return data; 39 | } 40 | 41 | } 42 | 43 | export { PrerenderData }; -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # SSR: Typescript + React + Router + Express + Jest 2 | 3 | **React server side rendering with persistent server data** 4 | 5 | This template has the following main dependencies: 6 | * [Typescript](https://www.typescriptlang.org/) 7 | * [React](https://reactjs.org/) 8 | * [React Router](https://github.com/remix-run/react-router) 9 | * [Styled Components](https://styled-components.com/) 10 | * [Express](https://expressjs.com/) 11 | * [Webpack](https://webpack.js.org/) 12 | * [Jest](https://jestjs.io/) 13 | * [SuperTest](https://www.npmjs.com/package/supertest) 14 | --- 15 | 16 | ### Instalation 17 | 1. Clone the repo: `https://github.com/marceloaugusto80/react-ssr-express.git` 18 | 2. Install dependencies: 19 | ``` bash 20 | $ npm install 21 | ``` 22 | 23 | ### Usage 24 | 25 | Use one of the following commands: 26 | * run server in watch mode: 27 | ``` bash 28 | $ npm run start:server 29 | ``` 30 | * run client app in dev server with Hot Reload: 31 | ``` bash 32 | $ npm run start:client 33 | ``` 34 | * build the application for production: 35 | ``` bash 36 | $ npm run build:prod 37 | ``` 38 | * test: 39 | ``` bash 40 | $ npm test 41 | ``` 42 | 43 | #### Compilation output 44 | After compilation, all output will be available in the `./dist` folder. The server logic will be bundled in the `./dist/app.js` file and client assets will be in the `./dist/public/` folder. 45 | 46 | ### Client vs Server side branching 47 | The global variable `__SERVER__` will be set to `true` if the code was compiled to target the server (Node) environment. Otherwise, it will have a value of `false`. 48 | 49 | ### Prerendered data persistence 50 | Check the following modules to see how server side data are passed and persisted in the prerendered dom: 51 | ``` 52 | ./src/shared/PrerenderData.ts 53 | ./src/client/serverData.ts 54 | ``` 55 | The examples how to use these modules are in: 56 | ``` 57 | ./src/server/middleware/routing.ts 58 | ./src/client/pages/SamplePage1.tsx 59 | ``` 60 | 61 | 62 | --- 63 | Any bug or improvement, please let me know. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jsblog", 3 | "version": "1.0.0", 4 | "description": "React SSR template project", 5 | "main": "./dist/app.js", 6 | "author": "Your name here", 7 | "license": "MIT", 8 | "workspaces": [ 9 | "server", 10 | "client" 11 | ], 12 | "scripts": { 13 | "test": "npx jest", 14 | "build:prod": "npx webpack --env PRODUCTION", 15 | "start:client": "npx webpack serve --config-name client --env HOT", 16 | "start:server": "npx concurrently \"npx webpack -w\" \"npx nodemon ./dist/app.js\"" 17 | }, 18 | "dependencies": { 19 | "express": "^4.17.2", 20 | "react": "^17.0.2", 21 | "react-dom": "^17.0.2", 22 | "react-router-dom": "^6.2.1", 23 | "styled-components": "^5.3.3" 24 | }, 25 | "devDependencies": { 26 | "@babel/plugin-transform-runtime": "^7.16.8", 27 | "@babel/preset-env": "^7.16.8", 28 | "@babel/preset-react": "^7.16.7", 29 | "@babel/preset-typescript": "^7.16.7", 30 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.4", 31 | "@types/copy-webpack-plugin": "^10.1.0", 32 | "@types/express": "^4.17.13", 33 | "@types/jest": "^27.4.0", 34 | "@types/jsdom": "^16.2.14", 35 | "@types/node": "^17.0.8", 36 | "@types/react": "^17.0.38", 37 | "@types/react-dom": "^17.0.11", 38 | "@types/styled-components": "^5.1.20", 39 | "@types/supertest": "^2.0.11", 40 | "@types/webpack": "^5.28.0", 41 | "@types/webpack-dev-server": "^4.7.1", 42 | "babel": "^6.23.0", 43 | "babel-loader": "^8.2.3", 44 | "clean-webpack-plugin": "^4.0.0", 45 | "concurrently": "^7.0.0", 46 | "copy-webpack-plugin": "^10.2.0", 47 | "file-loader": "^6.2.0", 48 | "html-webpack-plugin": "^5.5.0", 49 | "jest": "^27.4.7", 50 | "jsdom": "^19.0.0", 51 | "nodemon": "^2.0.15", 52 | "react-refresh": "^0.11.0", 53 | "supertest": "^6.2.1", 54 | "ts-jest": "^27.1.2", 55 | "ts-loader": "^9.2.6", 56 | "ts-node": "^10.4.0", 57 | "tsconfig-paths-webpack-plugin": "^3.5.2", 58 | "typescript": "^4.5.4", 59 | "webpack": "^5.66.0", 60 | "webpack-cli": "^4.9.1", 61 | "webpack-dev-server": "^4.7.3", 62 | "webpack-merge": "^5.8.0" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/client/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react' 2 | import { Link, Routes, Route } from 'react-router-dom'; 3 | import styled from "styled-components"; 4 | import HomePage from './pages/HomePage'; 5 | import SamplePage1 from './pages/SamplePage1'; 6 | import SamplePage2 from './pages/SamplePage2'; 7 | import { ServerDataProvider } from './serverData'; 8 | 9 | interface Props { 10 | /** Data used in the react prerender process. Use only in the server side. */ 11 | serverData?: unknown; 12 | } 13 | 14 | /** * The root react component for both client side rendering and server side rendering */ 15 | export default function App(props: Props) { 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 |
React SSR Template
23 | 24 |
25 | Home 26 | {/* use a common anchor () below if you want this route to always be rendered by the server */} 27 | Sample page 1 28 | Sample page 2 29 |
30 | 31 |
32 | 33 | } /> 34 | } /> 35 | } /> 36 | 37 |
38 | 39 |
40 |
41 | ); 42 | 43 | } 44 | 45 | const Wrapper = styled.div` 46 | font-family: Arial, Helvetica, sans-serif; 47 | font-weight: bold; 48 | min-height: 100vh; 49 | display: grid; 50 | grid-template-areas: 51 | "header header" 52 | "sidebar content"; 53 | grid-template-columns: 200px 1fr; 54 | grid-template-rows: 50px 1fr; 55 | 56 | 57 | div.header { 58 | grid-area: header; 59 | display: flex; 60 | flex-flow: column nowrap; 61 | justify-content: center; 62 | padding: 8px; 63 | font-size: 22px; 64 | background-color: #087db3; 65 | color: white; 66 | } 67 | div.sidebar { 68 | grid-area: sidebar; 69 | display: flex; 70 | flex-flow: column nowrap; 71 | justify-content: right; 72 | gap: 36px; 73 | padding: 16px; 74 | background-color: #bedceb; 75 | 76 | a:visited { 77 | text-decoration: none; 78 | } 79 | } 80 | div.content { 81 | grid-area: content; 82 | padding: 8px; 83 | } 84 | `; 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /src/server/ssr/renderReactAsync.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import App from "client/App"; 3 | import { renderToString } from "react-dom/server"; 4 | import { StaticRouter } from "react-router-dom/server"; 5 | import fs from "fs"; 6 | import {HTML_TEMPLATE_PATH} from "server/configuration"; 7 | import { PrerenderData } from "shared/PrerenderedData"; 8 | import {ServerStyleSheet} from "styled-components"; 9 | 10 | /** 11 | * Renders the react App as a html string. 12 | * @param url The render url. It will be injected in the react router so it can render the corresponding route. 13 | * @param prerenderedObject An object created in the server that can be accessed in the client side. 14 | * @returns A html string; 15 | */ 16 | export async function renderReactAsync(url: string, prerenderedObject?: unknown) { 17 | 18 | // read the html template file 19 | 20 | const staticHtmlContent = await fs.promises.readFile(HTML_TEMPLATE_PATH, { encoding: "utf-8" }); 21 | 22 | // create an element to store server side data 23 | 24 | const dataElement = PrerenderData.saveToDom(prerenderedObject); 25 | 26 | // In SSR, using react-router-dom/BrowserRouter will throw an exception. 27 | // Instead, we use react-router-dom/server/StaticRouter. 28 | // In the client compilation, we still use BrowserRouter (see: src/client/Index.tsx) 29 | 30 | const WrappedApp = ( 31 | 32 | 33 | 34 | ); 35 | 36 | /* 37 | render the react html content and the styled-component style sheet as string. 38 | without prerendering styled-components, the page will flash a styleless version of it 39 | */ 40 | 41 | const [reactContent, styleTags] = renderToStringWithStyles(WrappedApp); 42 | 43 | // finally combine all parts together 44 | 45 | const renderedHtml = buildHtml(staticHtmlContent, reactContent, styleTags, dataElement); 46 | 47 | return renderedHtml; 48 | } 49 | 50 | 51 | function buildHtml(templateHtml: string, reactHtml: string, styleTags: string, dataTag: string) { 52 | 53 | const pattern = /(?)|(?)/g; 54 | 55 | return templateHtml.replace(pattern, (match, ...params: any[]) => { 56 | const groups = params.pop(); 57 | 58 | if (groups.head) return groups.head + styleTags; 59 | if (groups.root) return dataTag + groups.root + reactHtml; 60 | 61 | return match; 62 | }); 63 | 64 | } 65 | 66 | function renderToStringWithStyles(component: JSX.Element) { 67 | const sheet = new ServerStyleSheet(); 68 | try { 69 | const reactHtml = renderToString(sheet.collectStyles(component)) 70 | const styleTags = sheet.getStyleTags(); 71 | return [reactHtml, styleTags] 72 | } 73 | finally { 74 | sheet.seal(); 75 | } 76 | } -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import { DefinePlugin, Configuration } from "webpack"; 2 | import "webpack-dev-server"; 3 | import path from "path"; 4 | import { CleanWebpackPlugin } from "clean-webpack-plugin"; 5 | import HtmlWebpackPlugin from "html-webpack-plugin"; 6 | import ReactRefreshPlugin from "@pmmmwh/react-refresh-webpack-plugin"; 7 | import { merge } from "webpack-merge"; 8 | import { TsconfigPathsPlugin } from "tsconfig-paths-webpack-plugin"; 9 | import CopyWebpackPlugin from "copy-webpack-plugin"; 10 | 11 | interface Env { 12 | production: boolean; 13 | hot: boolean; 14 | } 15 | 16 | function createBaseConfig(env: Env): Configuration { 17 | return { 18 | 19 | mode: env.production ? "production" : "development", 20 | 21 | devtool: env.production ? false : "source-map", 22 | 23 | resolve: { 24 | extensions: [".ts", ".tsx", ".js"], 25 | plugins: [new TsconfigPathsPlugin()] 26 | }, 27 | 28 | plugins: [ 29 | 30 | new DefinePlugin({ 31 | __PRODUCTION__: JSON.stringify(env.production), 32 | }) 33 | ] 34 | } 35 | } // end base config 36 | 37 | function createServerConfig(env: Env): Configuration { 38 | return { 39 | 40 | name: "server", 41 | 42 | target: "node", 43 | 44 | context: path.resolve(__dirname, "src/server"), 45 | 46 | externalsPresets: { 47 | node: true 48 | }, 49 | 50 | ignoreWarnings: [ 51 | { 52 | /* 53 | * Express compilation issue: 54 | * WARNING in ../node_modules/express/lib/view.js 81:13-25 Critical dependency: the request of a dependency is an expression 55 | * more at: https://github.com/webpack/webpack/issues/1576 56 | */ 57 | module: /express/, 58 | message: /Critical\sdependency:\sthe\srequest\sof\sa\sdependency\sis\san\sexpression/, 59 | } 60 | ], 61 | 62 | entry: "./app.ts", 63 | 64 | output: { 65 | path: path.resolve(__dirname, "dist"), 66 | filename: "app.js", 67 | publicPath: "./" // file-loader prepends publicPath to the emited url. without this, react will complain about server and client mismatch 68 | }, 69 | 70 | module: { 71 | rules: [ 72 | 73 | { test: /\.tsx?$/, loader: "ts-loader", exclude: /node_modules/ }, 74 | 75 | { 76 | // file-loader config must match client's (except 'emitFile' property) 77 | test: /\.(jpg|png|gif|svg)$/, 78 | use: { 79 | loader: "file-loader", 80 | options: { 81 | outputPath: "images", 82 | name: "[name].[contenthash].[ext]", 83 | emitFile: false 84 | }} 85 | } 86 | ] 87 | }, 88 | 89 | plugins: [ 90 | new CleanWebpackPlugin({ 91 | cleanOnceBeforeBuildPatterns: ["!public/**"] 92 | }), 93 | 94 | new DefinePlugin({ 95 | __Server__: JSON.stringify(true) 96 | }), 97 | ] 98 | 99 | } 100 | } // end server configuration 101 | 102 | function createClientConfig(env: Env): Configuration { 103 | 104 | const babelConfig = { 105 | presets: [ 106 | "@babel/preset-env", 107 | "@babel/preset-react", 108 | "@babel/preset-typescript" 109 | ], 110 | plugins: [ 111 | "@babel/plugin-transform-runtime", 112 | env.hot && require.resolve("react-refresh/babel") 113 | ].filter(Boolean) 114 | } 115 | 116 | return { 117 | 118 | name: "client", 119 | 120 | target: "web", 121 | 122 | context: path.resolve(__dirname, "src/client"), 123 | 124 | optimization: { 125 | splitChunks: { 126 | chunks: "all" 127 | } 128 | }, 129 | 130 | entry: { 131 | index: "./Index.tsx" 132 | }, 133 | 134 | output: { 135 | path: path.resolve(__dirname, "dist", "public"), 136 | filename: env.production ? "js/[name].[chunkhash].js" : "js/[name].js", 137 | }, 138 | 139 | module: { 140 | rules: [ 141 | { 142 | test: /\.tsx?$/, 143 | exclude: /node_modules/, 144 | use: { loader: "babel-loader", options: babelConfig }, 145 | }, 146 | 147 | { 148 | test: /\.(jpg|png|gif|svg)$/, 149 | use: { 150 | loader: "file-loader", 151 | options: { 152 | outputPath: "images", 153 | name: "[name].[contenthash].[ext]" 154 | }} 155 | } 156 | ] 157 | }, 158 | 159 | plugins: [ 160 | new CleanWebpackPlugin(), 161 | 162 | new HtmlWebpackPlugin({ 163 | template: "./index.html" 164 | }), 165 | 166 | new CopyWebpackPlugin({ 167 | patterns: [ 168 | {from: "resources/favicon.ico"} 169 | ] 170 | }), 171 | 172 | new DefinePlugin({ 173 | __SERVER__: JSON.stringify(false), 174 | }), 175 | 176 | (env.hot && new ReactRefreshPlugin()) as any // casting so tsc will stop complaining 177 | 178 | ].filter(Boolean), 179 | 180 | devServer: { 181 | hot: env.hot, 182 | port: 9000, 183 | historyApiFallback: true 184 | } 185 | 186 | }; 187 | 188 | } // end client configuration 189 | 190 | export default function (e: any) { 191 | 192 | const env: Env = { 193 | hot: !!e["HOT"], 194 | production: !!e["PRODUCTION"] 195 | } 196 | 197 | const baseConfig = createBaseConfig(env); 198 | const clientConfig = merge(baseConfig, createClientConfig(env)); 199 | const serverConfig = merge(baseConfig, createServerConfig(env)); 200 | 201 | return [clientConfig, serverConfig]; 202 | 203 | } --------------------------------------------------------------------------------