Skip to content

Let's learn Server Side Rendering (in react but that doesn't matter)

Notifications You must be signed in to change notification settings

sudkumar/simple-ssr-react

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Simple Server Side Rendering (S-SSR)

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.

Setup our initial development flow with client side rendering

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 as ts-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"

Webpack as our build tool

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.

Setup server side rendering

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
}

About

Let's learn Server Side Rendering (in react but that doesn't matter)

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages