This is a sample repository where we explore the server side rendering in a modern app. We will use React
as UI
library but same concepts apply to others.
We start by initializing the node repository with basic setup that we normally do for any new project.
We will use yarn
as node package manager and TypeScript
as our Javascript languge.
We install the dependencies that we normally install for a node project like linters, fixers and project management tools like lerna, lerna-changelog.
mkdir s-ssr && cd s-ssr
yarn init --yes
yarn add -D typescript ts-node
yarn add -D prettier tslint tslint-blueprint tslint-config-prettier
yarn add -D husky lint-staged lerna lerna-changelog
- typescript - support for typescript compiler and server (tsc, tsserver)
- ts-node - to run
node something.js
asts-node something.ts
- prettier - common code styling fixer
- tslint - linting support for typescript
- tslint-blueprint - tslint standard configuration plugin
- tslint-config-prettier - let tslint play well with prettier
- husky - to use the git hooks like pre-commit or pre-push to run some scripts like
tests
- list-staged - to do something (e.g.
linters
) with staged files when commited - lerna - a monorepo package manager. we will lerna more about it when we use it
- lerna-changelog - to fetch github PRs messages and help us in creating the release changelog. pretty handly for automation
Some of these tools need some configuration like tslint and prettier. So let's create some files in our root folder.
<rootDir>/tsconfig.json
: Used by Typescript compiler
{
"compilerOptions": {
"baseUrl": ".",
"esModuleInterop": true,
"jsx": "react",
"lib": ["es6", "dom"],
"noUnusedLocals": true,
"noUnusedParameters": true,
"outDir": "dist/ts",
"removeComments": true,
"strict": true,
"target": "esnext",
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"strictNullChecks": true,
"moduleResolution": "node"
},
"exclude": ["node_modules", "build", "dist"]
}
<rootDir>/.prettierrc
: Used by prettier cs fixer
{
"semi": false,
"singleQuote": false,
"trailingComma": "es5",
"arrowParens": "always",
"jsxBracketSameLine": true,
"tabWidth": 2
}
<rootDir>/package.json
: We will setup our hooks for husky
and lint-staged
. In our existing package.json, we will
two more keys for husky and lint-staged
{
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{md,css,scss,json,yml,html}": ["prettier --write", "git add"],
"*.{js,jsx}": ["prettier --write", "git add"],
"*.{ts,tsx}": ["tslint --fix", "prettier --write", "git add"],
"yarn.lock": ["git rm --cached"]
}
}
<rootDir>/.editorconfig
: This is a sharable configuration file across editors. Install Editor Config
plugin for your
choice of editor. We will setup some basic cofiguration like indent size, charset etc.
# httv://editorconfig.org
# A special property that should be specified at the top of the file outside of
# any sections. Set to true to stop .editor config file search on current file
root = true
[*]
# Indentation style
# Possible values - tab, space
indent_style = space
# Indentation size in single-spaced characters
# Possible values - an integer, tab
indent_size = 2
# Line ending file format
# Possible values - lf, crlf, cr
end_of_line = lf
# File character encoding
# Possible values - latin1, utf-8, utf-16be, utf-16le
charset = utf-8
# Denotes whether to trim whitespace at the end of lines
# Possible values - true, false
trim_trailing_whitespace = true
# Denotes whether file should end with a newline
# Possible values - true, false
Now we will initialize this as git repository and will add our gitignore
git init
<rootDir>/.gitignore
: Let's ignore some common files. Generated by https://gitignore.io.
node_modules/
.DS_Store
/.changelog
dist
build
lerna-debug.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Vim ###
# Swap
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
# Session
Session.vim
# Temporary
.netrwhist
*~
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~
Now time to commit our basic setup
git add .
git commit -m "core(init): setup typescript with linter, prettier and git hooks"
We will use webpack as our build tool to automatically compile our typescript code to plain old javascript. We will start by installing webpack with some other packages to work with typescript
yarn add -D webpack awesome-typescript-loader source-map-loader
Let's write some configuration files for our build setup
<rootDir/config/paths.ts
: Provides our project's path related configuration like src directory
, packages directory
etc.
import fs from "fs"
import path from "path"
import url from "url"
// app root directory
const appDirectory = fs.realpathSync(process.cwd())
// resolve any path relative to app root
const resolveApp = (relativePath: string) =>
path.resolve(appDirectory, relativePath)
// public url from env if set
// for development, set it to the root "/"
const envPublicUrl =
process.env.NODE_ENV === "development" ? "/" : process.env.PUBLIC_URL
function ensureSlash(p: string, needsSlash: boolean): string {
const hasSlash = p.endsWith("/")
if (hasSlash && !needsSlash) {
return p.substr(0, p.length - 1)
} else if (!hasSlash && needsSlash) {
return `${p}/`
} else {
return p
}
}
const getPublicUrl = (appPackageJson: string): string =>
envPublicUrl || require(appPackageJson).homepage
// "public path" at which the app is served.
// Webpack needs to know it to put the right <script> hrefs into HTML even in
// single-page apps that may serve index.html for nested URLs like /todos/42.
// We can"t use a relative path in HTML because we don"t want to load something
// like /todos/42/static/js/bundle.7289d.js. We have to know the root.
function getServedPath(appPackageJson: string): string {
const publicUrl = getPublicUrl(appPackageJson)
const servedUrl =
envPublicUrl || (publicUrl ? new url.URL(publicUrl).pathname : "/")
return ensureSlash(servedUrl, true)
}
const appBuild: string = resolveApp("build")
const appPath: string = resolveApp(".")
const appPublic: string = resolveApp("public")
const publicUrl: string = getPublicUrl(resolveApp("package.json"))
const servedPath: string = getServedPath(resolveApp("package.json"))
const srcPath: string = resolveApp("src")
const packagesPath: string = resolveApp("packages")
const clientPath: string = resolveApp("src/client.tsx")
const serverPath: string = resolveApp("src/server.tsx")
export {
appBuild,
appDirectory,
appPath,
appPublic,
clientPath,
packagesPath,
publicUrl,
servedPath,
serverPath,
srcPath,
}
<rootDir/config/webpack.config.ts
: Let's now write our webpack configuration file
import webpack from "webpack"
import * as paths from "./paths"
// Webpack uses `publicPath` to determine where the app is being served from.
// In development, we always serve from the root. This makes config easier.
const publicPath = paths.servedPath
// what is environment
const isDevelopment = process.env.NODE_ENV === "development"
// base webpack configuration
const base: webpack.Configuration = {
context: paths.appPath,
devtool: isDevelopment ? "source-map" : false,
mode: isDevelopment ? "development" : "production",
module: {
rules: [
// All files with a '.ts' or '.tsx' extension will be handled by 'awesome-typescript-loader'.
{
test: /\.tsx?$/,
use: [{ loader: "awesome-typescript-loader" }],
},
// All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'.
{ enforce: "pre", test: /\.js$/, loader: "source-map-loader" },
],
},
resolve: {
extensions: [".ts", ".tsx", ".js", "jsx", ".json"],
modules: [paths.srcPath, paths.packagesPath, "node_modules"],
},
target: "web",
}
// client webpack configuration
const client: webpack.Configuration = {
...base,
entry: {
main: [paths.clientPath],
},
name: "client",
output: {
filename: "client.js",
path: paths.appBuild,
publicPath,
},
}
export default client
Enought configuration. Let's write our first line of code. Install react
, react-dom
yarn add react react-dom
# type defs for react and react-dom
yarn add -D @types/react @types/react-dom
<rootDir>/src/client.jsx
import * as React from "react"
import ReactDOM from "react-dom"
function App() {
return <div>Hello World!</div>
}
ReactDOM.render(<App />, document.getElementById("app"))
To create the webpack build, we will write a simple script.
<rootDir/scripts/start.ts
process.env.NODE_ENV = "development"
process.env.BABEL_ENV = "development"
import webpack from "webpack"
import webpackConfig from "./../config/webpack.config"
const compiler: webpack.MultiCompiler = webpack(webpackConfig)
// we will run in the watch mode to look for any changes
compiler.watch({}, function(err, stats) {
if (err) {
throw err
}
console.log(stats.toJson("normal"))
console.log("Compiled successfully.")
})
Now let's add a script to our package.json
to run this script
<rootDir>/package.json
{
"scripts": {
"start": "ts-node scripts/start"
}
}
Let's create our first build. We will run following from our terminal
yarn start
We will see that webpack has create a <rootDir>/build
folder which contains client.js
file. Changing our
<rootDir>/src/client.tsx
will update the create buidle file.
So far, we have <rootDir>/src/client.tsx
that gets bundled to <rootDir>/build/client.js
. We can create an
<rootDir>/build/index.html
, that sources our javascript bundle, to view our application.
To do the server side rendering, we need a server which will respond with server side rendered markup
. So let install
some dependencies to create a server. We will express library.
yarn add express @types/express
We will start by creating a <rootDir>/src/server.jsx
file for our server side entry point.
<rootDir>/src/server.jsx
/**
* This file contains the server side rendering middleware
*/
import express from "express"
import * as React from "react"
import { renderToNodeStream, renderToStaticMarkup } from "react-dom/server"
import { HelmetProvider } from "react-helmet-async"
import { StaticRouter } from "react-router"
import { ServerStyleSheet } from "styled-components"
import App from "./App"
// This middleware should be envoked with the stats for js/css assets and publicUrl
export default function createHtmlMiddleware({
clientStats,
publicUrl,
}: {
clientStats: { assetsByChunkName: { [key: string]: [string] } }
publicUrl: string
}) {
// { main: [Array]} stats from webpack
const assetsByChunkName = clientStats.assetsByChunkName
const handler: express.RequestHandler = (req, res) => {
// set the content type
res.setHeader("Content-Type", "text/html; charset=utf-8")
/**
* We need to pass the context to the StaticRouter so that we can be notified for any redirection
* or status for response
*/
const routerContext: { url?: string } = {}
/**
* react-helmet-async usage context to fix the issue of react-side-effects created by react-helmet by
* creating an instance per request
*/
const helmetContext: {
helmet?: { [key: string]: { toString: () => string } }
} = {}
/**
* Our application
* We will match it with the `src/client.tsx` with different component like BrowserRouter instead of StaticRouter
* for any interactivity
*/
const app = (
<HelmetProvider context={helmetContext}>
<StaticRouter location={req.url} context={routerContext}>
<App />
</StaticRouter>
</HelmetProvider>
)
/**
* Styled component will add it's styled for the streamed app
*/
const sheet = new ServerStyleSheet()
const jsx = sheet.collectStyles(app)
/**
* There are few things in the render process that requires the parsing of React tree e.g. react-helpmet-async,
* so we only render it as a static markup which is less expensive then renderToString
* This way, our router will also know for any redirection. We can use this to handle the data fetching like
* react-apollo#getDataFromTree does for our state initialization
*/
renderToStaticMarkup(jsx)
// check for redirection in process
if (routerContext.url) {
res.redirect(301, routerContext.url)
res.end()
return
}
// get the helmet content
const { helmet } = helmetContext
res.status(200)
// start by sending the head and initial body content
res.write(`
<!doctype html>
<html lang="en" ${helmet ? helmet.htmlAttributes.toString() : ""}>
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="${publicUrl}favicon.ico">
<meta name="description" content="This is a simple example for server side rendering an react application">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="manifest" href="${publicUrl}manifest.json">
${helmet && helmet.title.toString()}
${helmet && helmet.meta.toString()}
${helmet && helmet.link.toString()}
</head>
<body ${helmet && helmet.bodyAttributes.toString()}>
<div id="app">`)
const stream = sheet.interleaveWithNodeStream(renderToNodeStream(jsx))
stream.pipe(
res,
{ end: false }
)
// one end, send the closing scripts
stream.on("end", () => {
res.end(`</div>
<script>
window.__SSR__ = true
</script>
${assetsByChunkName.main
.filter((path) => path.endsWith(".js"))
.map((path) => `<script src="${path}"></script>`)
.join("\n")}
</body>
</html>
`)
})
}
return handler
}