Skip to content

Instantly share code, notes, and snippets.

@anthonyjoeseph
Last active July 4, 2024 01:00
Show Gist options
  • Save anthonyjoeseph/bdcf9be5cfc515cad334b687237c1556 to your computer and use it in GitHub Desktop.
Save anthonyjoeseph/bdcf9be5cfc515cad334b687237c1556 to your computer and use it in GitHub Desktop.
Typescript React + Express SSR

Typescript React + Express SSR

Usage with create-react-app

  1. Create-React-App with typescript
    • npx create-react-app my-app --template typescript
  2. Add files from this gist:
    1. cd my-app
    2. mkdir server
    3. touch server.tsconfig.json webpack.server.js server/index.html server/server.tsx server/GenerateClient.ts server/hydrate.tsx
    4. Copy and paste code from this gist into the appropriate files
    5. modify package.json from the file in this gist and run yarn or npm install
    • if you're using vscode, add the debug config
      1. mkdir .vscode
      2. touch .vscode/launch.json
      3. copy and paste .vscode/launch.json from this gist
  3. Run
    • yarn dev or npm run dev (this will appear to crash the first time you run it b/c node tries to run the server file before it's been compiled. If you give it a few seconds it will sort itself out.)
    • alternatively, yarn dev:build-server and yarn dev:start in separate consoles
  4. Debug (vscode)
    • Debug Client
      1. yarn start
      2. run debug task 'Debug Create React App'
    • Debug Server
      1. yarn dev:build-server
      2. run debug task 'Debug SSR Server'
    • Debug Client rendered by Server
      1. yarn dev
      2. run debug task 'Debug SSR Client'
import serialize from 'serialize-javascript';
import path from 'path';
import fs from 'fs';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
export default <P>(
App: React.ReactElement<P>,
globalState: string | undefined,
): Promise<string> => new Promise((resolve, reject) => {
const app = ReactDOMServer.renderToString(App);
const indexFile = path.resolve('./server/index.html');
fs.readFile(indexFile, 'utf8', (err, data) => {
if (err) {
reject(err);
}
const clientString = data
.replace(
'<div id="root"></div>',
`<div id="root">${app}</div>`,
)
.replace(
'<head>',
`<head><script>window.__INITIAL__DATA__ = ${serialize(globalState)}</script>`,
);
resolve(clientString);
});
})
// server/hydrate.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import '../src/index.css';
import App from '../src/App';
ReactDOM.hydrate(
<App />,
document.getElementById('root')
);
<!-- server/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="logo192.png" />
<link rel="manifest" href="manifest.json" />
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/server-build/hydrate.js"></script>
</body>
</html>
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Debug Create React App",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
},
{
"type": "chrome",
"request": "launch",
"name": "Debug SSR Client",
"url": "http://localhost:3006",
"webRoot": "${workspaceFolder}",
"sourceMaps": true
},
{
"name": "Debug SSR Server",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"program": "${workspaceRoot}/server/server.tsx",
"outFiles": [
"${workspaceRoot}/server-build/**/*.js"
],
"sourceMaps": true
}
]
}
{
...
"dependencies": {
...
"@types/express": "^4.17.6",
"@types/serialize-javascript": "^1.5.0",
"css-loader": "^3.6.0",
"express": "^4.17.1",
"file-loader": "^6.0.0",
"nodemon": "^2.0.4",
"npm-run-all": "^4.1.5",
"serialize-javascript": "^3.1.0",
"style-loader": "^1.2.1",
"ts-loader": "^7.0.5",
"webpack-cli": "^3.3.11",
"webpack-node-externals": "^1.7.2"
},
"scripts": {
...
"dev:build-server": "NODE_ENV=development webpack --config webpack.server.js --mode=development -w",
"dev:start": "nodemon --exec 'node' ./server-build/server.js",
"dev": "npm-run-all --parallel dev:*"
},
...
}
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react",
"outDir": "./dist/",
"noEmit": false,
"sourceMap": true
},
"include": [
"server",
"src/react-app-env.d.ts"
]
}
// server/server.tsx
import express from 'express';
import React from 'react';
import App from '../src/App';
import generateClient from './GenerateClient';
const PORT = process.env.PORT || 3006;
const app = express();
app.use(express.static('./public', {
index: false,
}));
app.use('/server-build', express.static('./server-build'));
app.get('*', async (req, res) => {
try {
const clientString = await generateClient(
<App />,
undefined
)
return res.send(clientString);
} catch (untypedErr) {
const err: NodeJS.ErrnoException = untypedErr;
console.error('Something went wrong:', err);
return res.status(500).send('Oops, better luck next time!');
}
});
app.listen(PORT, () => {
console.log(`😎 Server is listening on port ${PORT}`);
});
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const serverConfig = {
entry: './server/server.tsx',
target: 'node',
externals: [nodeExternals()],
output: {
path: path.resolve('server-build'),
filename: 'server.js',
// Bundle absolute resource paths in the source-map,
// so VSCode can match the source file.
devtoolModuleFilenameTemplate: '[absolute-resource-path]'
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
exclude: /node_modules/,
options: {
allowTsInNodeModules: true,
configFile: 'server.tsconfig.json'
},
},
{
test: /\.css$/i,
use: ['css-loader'],
},
{
test: /\.(jpg|jpeg|png|svg|gif)$/,
use: [{
loader: 'file-loader',
options: {
name: '[md5:hash:hex].[ext]',
publicPath: '/server-build/img',
outputPath: 'img',
}
}]
}
]
},
devtool: 'source-map',
resolve: {
extensions: [ '.tsx', '.ts', '.js', '.css', '.svg', '.png' ],
},
};
const clientConfig = {
entry: './server/hydrate.tsx',
target: 'web',
output: {
path: path.resolve('server-build'),
filename: 'hydrate.js',
// Bundle absolute resource paths in the source-map,
// so VSCode can match the source file.
devtoolModuleFilenameTemplate: '[absolute-resource-path]'
},
module: {
rules: [
{
test: /\.(ts|tsx)?$/,
loader: 'ts-loader',
exclude: /node_modules/,
options: {
allowTsInNodeModules: true,
configFile: 'server.tsconfig.json'
},
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(jpg|jpeg|png|svg|gif)$/,
use: [{
loader: 'file-loader',
options: {
name: '[md5:hash:hex].[ext]',
publicPath: '/server-build/img',
outputPath: 'img',
}
}]
}
]
},
devtool: 'source-map',
resolve: {
extensions: [ '.tsx', '.ts', '.js', '.css', '.svg', '.png' ],
},
};
module.exports = [serverConfig, clientConfig];
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment