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