├── .gitignore
├── src
├── pages
│ ├── About.tsx
│ └── Home.tsx
├── entry-client.tsx
├── entry-server.tsx
└── App.tsx
├── vite.config.js
├── index.html
├── tsconfig.json
├── package.json
├── prerender.js
└── server.js
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | node_modules
3 | dist
--------------------------------------------------------------------------------
/src/pages/About.tsx:
--------------------------------------------------------------------------------
1 |
2 | export default function About() {
3 | return (
4 | <>
5 |
About
6 |
7 | >
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | export default defineConfig({
5 | plugins: [react()],
6 | build: {
7 | minify: false
8 | }
9 | })
10 |
--------------------------------------------------------------------------------
/src/entry-client.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client'
2 | import { BrowserRouter } from 'react-router-dom'
3 | import { App } from './App'
4 |
5 | ReactDOM.hydrateRoot(
6 | document.getElementById('app')!,
7 |
8 |
9 |
10 | )
11 |
--------------------------------------------------------------------------------
/src/entry-server.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOMServer from 'react-dom/server'
2 | import { StaticRouter } from 'react-router-dom/server'
3 | import { App } from './App'
4 |
5 | export function SSRRender(url: string | Partial) {
6 | return ReactDOMServer.renderToString(
7 |
8 |
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react"
2 |
3 | export default function Home() {
4 | const [counter, setCounter] = useState(0)
5 | return (
6 | <>
7 | Home
8 |
9 | Button clicked {counter} times
10 |
11 | >
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | SSR React/Typescript App
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "ESNext",
5 | "allowJs": true,
6 | "jsx": "react-jsx",
7 | "jsxImportSource": "react",
8 | "noEmit": true,
9 | "strict": true,
10 | "moduleResolution": "node",
11 | "esModuleInterop": true,
12 | "skipLibCheck": true,
13 | "useUnknownInCatchVariables": false
14 | },
15 | "include": ["src"]
16 | }
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "test-ssr-react",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "node server",
8 | "build": "npm run build:client && npm run build:server",
9 | "build:client": "vite build --outDir dist/client",
10 | "build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
11 | "generate": "vite build --outDir dist/static && npm run build:server && node prerender",
12 | "serve": "cross-env NODE_ENV=production node server",
13 | "debug": "node --inspect-brk server"
14 | },
15 | "dependencies": {
16 | "react": "^18.2.0",
17 | "react-dom": "^18.2.0",
18 | "react-router-dom": "^6.3.0"
19 | },
20 | "devDependencies": {
21 | "@types/react": "^18.0.15",
22 | "@types/react-dom": "^18.0.6",
23 | "@vitejs/plugin-react": "^2.0.0",
24 | "compression": "^1.7.4",
25 | "cross-env": "^7.0.3",
26 | "express": "^4.18.1",
27 | "serve-static": "^1.15.0",
28 | "typescript": "^4.7.4",
29 | "vite": "^3.0.0",
30 | "prettier": "^2.7.1"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/prerender.js:
--------------------------------------------------------------------------------
1 | // Pre-render the app into static HTML.
2 | // run `yarn generate` and then `dist/static` can be served as a static site.
3 |
4 | import fs from 'node:fs'
5 | import path from 'node:path'
6 | import { fileURLToPath } from 'url'
7 |
8 | const __dirname = path.dirname(fileURLToPath(import.meta.url))
9 | const toAbsolute = (p) => path.resolve(__dirname, p)
10 |
11 | const template = fs.readFileSync(toAbsolute('dist/static/index.html'), 'utf-8')
12 | const render = (await import('./dist/server/entry-server.js')).SSRRender
13 |
14 | // determine routes to pre-render from src/pages
15 | const routesToPrerender = fs
16 | .readdirSync(toAbsolute('src/pages'))
17 | .map((file) => {
18 | const name = file.replace(/\.tsx$/, '').toLowerCase()
19 | return name === 'home' ? `/` : `/${name}`
20 | })
21 |
22 | ;(async () => {
23 | // pre-render each route...
24 | for (const url of routesToPrerender) {
25 | const appHtml = render(url)
26 |
27 | const html = template.replace(``, appHtml)
28 |
29 | const filePath = `dist/static${url === '/' ? '/index' : url}.html`
30 | fs.writeFileSync(toAbsolute(filePath), html)
31 | console.log('pre-rendered:', filePath)
32 | }
33 | })()
34 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Link, Route, Routes } from 'react-router-dom'
2 |
3 | //@ts-ignore
4 | const PagePathsWithComponents = import.meta.glob('./pages/*.tsx', { eager: true })
5 | //Example Output:
6 | // const modules = {
7 | // './pages/About.tsx': () => import('./pages/About.js'),
8 | // './pages/Home.tsx': () => import('./pages/Home.tsx')
9 | // }
10 |
11 |
12 | const routes = Object.keys(PagePathsWithComponents).map((path: string) => {
13 | const name = path.match(/\.\/pages\/(.*)\.tsx$/)![1]
14 | return {
15 | name,
16 | path: name === 'Home' ? '/' : `/${name.toLowerCase()}`,
17 | component: PagePathsWithComponents[path].default
18 | }
19 | })
20 |
21 | export function App() {
22 | return (
23 | <>
24 |
35 |
36 | {routes.map(({ path, component: RouteComp }) => {
37 | return }/>
38 | })}
39 |
40 | >
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs'
2 | import path from 'node:path'
3 | import { fileURLToPath } from 'url'
4 | import express from 'express'
5 |
6 | const __dirname = path.dirname(fileURLToPath(import.meta.url))
7 | const app = express()
8 |
9 | export async function createServer(
10 | root = process.cwd(),
11 | isProd = process.env.NODE_ENV === 'production',
12 | hmrPort
13 | ) {
14 | const resolve = (p) => path.resolve(__dirname, p)
15 |
16 | let vite = null
17 | if (!isProd) {
18 | vite = await (
19 | await import('vite')
20 | ).createServer({
21 | root,
22 | logLevel: 'info',
23 | server: {
24 | middlewareMode: true,
25 | hmr: {
26 | port: hmrPort
27 | }
28 | },
29 | appType: 'custom'
30 | })
31 | app.use(vite.middlewares)
32 | } else {
33 | app.use((await import('compression')).default())
34 | app.use(
35 | (await import('serve-static')).default(resolve('dist/client'), {
36 | index: false
37 | })
38 | )
39 | }
40 |
41 | app.use('*', async (req, res) => {
42 | const url = '/'
43 | let template, render
44 | if (!isProd && vite) {
45 | template = fs.readFileSync(resolve('index.html'), 'utf-8')
46 | template = await vite.transformIndexHtml(url, template) // Inserting react-refresh for local development
47 | render = (await vite.ssrLoadModule('/src/entry-server.tsx')).SSRRender
48 | } else {
49 | template = fs.readFileSync(resolve('dist/client/index.html'), 'utf-8')
50 | render = (await import('./dist/server/entry-server.js')).SSRRender
51 | }
52 |
53 | const appHtml = render(url) //Rendering component without any client side logic de-hydrated like a dry sponge
54 | const html = template.replace(``, appHtml) //Replacing placeholder with SSR rendered components
55 |
56 | res.status(200).set({ 'Content-Type': 'text/html' }).end(html) //Outputing final html
57 | })
58 |
59 | return { app, vite }
60 | }
61 |
62 | createServer().then(({ app }) =>
63 | app.listen(5173, () => {
64 | console.log('http://localhost:5173')
65 | })
66 | )
67 |
--------------------------------------------------------------------------------