Skip to content
Yet another simple React SSR solution inspired by vue-server-render
TypeScript JavaScript
Branch: master
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
src
.eslintignore chore: bump all dependencies, use eslint to replace tslint Jul 7, 2019
.eslintrc chore: bump all dependencies, use eslint to replace tslint Jul 7, 2019
.gitignore
.huskyrc
.lintstagedrc
.prettierrc
.travis.yml
LICENSE
README.md
client-plugin.d.ts
client-plugin.js
package.json
server-plugin.d.ts
server-plugin.js
tsconfig.json
yarn.lock

README.md

react-server-renderer

Greenkeeper badge Travis David David Dev code style: prettier

Yet another simple React SSR solution inspired by vue-server-render with:

  1. Server bundle with hot reload on development and source map support
  2. prefetch/preload client injection with ClientManifest, generated by webpack-plugin inside
  3. server css support with react-style-loader
  4. Async component support with react-async-component and react-async-bootstrapper
  5. custom dynamic head management for better SEO

Real World Demo

react-hackernews

Usage

This module is heavily inspired by vue-server-render, it is recommended to read about bundle-renderer.

It uses react-router on server, so you should read about Server Rendering.

And also, data injection should be implement with asyncBootstrap.

Build Configuration

Server Config

import webpack from 'webpack'
import merge from 'webpack-merge'
import nodeExternals from 'webpack-node-externals'
import { ReactSSRServerPlugin } from 'react-server-renderer/server-plugin'

import { resolve } from './config'

import base from './base'

export default merge.smart(base, {
  // Point entry to your app's server entry file
  entry: resolve('src/entry-server.js'),

  // This allows webpack to handle dynamic imports in a Node-appropriate
  // fashion, and also tells `react-style-loader` to emit server-oriented code when
  // compiling React components.
  target: 'node',

  output: {
    path: resolve('dist'),
    filename: `[name].[chunkhash].js`,
    // This tells the server bundle to use Node-style exports
    libraryTarget: 'commonjs2',
  },

  // https://webpack.js.org/configuration/externals/#function
  // https://github.com/liady/webpack-node-externals
  // Externalize app dependencies. This makes the server build much faster
  // and generates a smaller bundle file.
  externals: nodeExternals({
    // do not externalize dependencies that need to be processed by webpack.
    // you can add more file types here
    // you should also whitelist deps that modifies `global` (e.g. polyfills)
    whitelist: /\.s?css$/,
  }),

  plugins: [
    new webpack.DefinePlugin({
      'process.env.REACT_ENV': '"server"',
      __SERVER__: true,
    }),
    // This is the plugin that turns the entire output of the server build
    // into a single JSON file. The default file name will be
    // `react-ssr-server-bundle.json`
    new ReactSSRServerPlugin(),
  ],
})

Client Config

import webpack from 'webpack'
import merge from 'webpack-merge'
// do not need 'html-webpack-plugin' any more because we will render html from server
// import HtmlWebpackPlugin from 'html-webpack-plugin'
import { ReactSSRClientPlugin } from 'react-server-renderer/client-plugin'

import { __DEV__, publicPath, resolve } from './config'

import base from './base'

export default merge.smart(base, {
  entry: {
    app: [resolve('src/entry-client.js')],
  },
  output: {
    publicPath,
    path: resolve('dist/static'),
    filename: `[name].[${__DEV__ ? 'hash' : 'chunkhash'}].js`,
  },
  plugins: [
    new webpack.DefinePlugin({
      'process.env.REACT_ENV': '"client"',
      __SERVER__: false,
    }),
    // This plugins generates `react-ssr-client-manifest.json` in the
    // output directory.
    new ReactSSRClientPlugin({
      // path relative to your output path, default to be `react-ssr-client-manifest.json`
      filename: '../react-ssr-client-manifest.json',
    }),
  ],
})

You can then use the generated client manifest, together with a page template:

const { createBundleRenderer } = require('react-server-renderer')

const template = require('fs').readFileSync('/path/to/template.html', 'utf-8')
const serverBundle = require('/path/to/react-ssr-server-bundle.json')
const clientManifest = require('/path/to/react-ssr-client-manifest.json')

const renderer = createBundleRenderer(serverBundle, {
  template,
  clientManifest,
})

With this setup, your server-rendered HTML for a build with code-splitting will look something like this (everything auto-injected):

<html>
  <head>
    <!-- chunks used for this render will be preloaded -->
    <link rel="preload" href="/manifest.js" as="script">
    <link rel="preload" href="/main.js" as="script">
    <link rel="preload" href="/0.js" as="script">
    <!-- unused async chunks will be prefetched (lower priority) -->
    <link rel="prefetch" href="/1.js" as="script">
  </head>
  <body>
    <!-- app content -->
    <div data-server-rendered="true"><div>async</div></div>
    <!-- manifest chunk should be first -->
    <script src="/manifest.js"></script>
    <!-- async chunks injected before main chunk -->
    <script src="/0.js"></script>
    <script src="/main.js"></script>
  </body>
</html>`

Server bundle

All you need to do is for hot reload on development:

  1. compile server webpack config via node.js API like: const const serverCompiler = webpack(serverConfig)
  2. watch serverCompiler and replace server bundle on change

Example: https://github.com/JounQin/react-hackernews/blob/master/server/dev.js

Your server bundle entry should export a function with a context param which return a promise, and it should resolve a react component instance.

Example: https://github.com/JounQin/react-hackernews/blob/master/src/entry-server.js

When you need to redirect on server or an error occurs, you should reject inside promise so that we can handle it.

renderToString and renderToStream(use ReactDomServer.renderToNodeStream inside)

Since you generate server bundle renderer as above, you can easily call renderer.renderToString(context) or renderer.renderToStream(context), where context should be a singloton of every request.

renderToString is very simple, just try/catch error to handle it.

renderToStream is a tiny complicated to handle, you can rediect or reject request by listening error event and handle error param. If you want to render application but change response status, you can listen afterRender event and handle with your own context, for example maybe you want to render 404 Not Found page via React Component but respond with 404 status.

State management

If you set context.state on server, it will auto inject a script contains window.__INITIAL_STATE__ in output, so that you can resue server state on client.

Style injection and Head Management

Without SSR, we can easily use style-loader, however we need to collect rendered components with their styles together on runtime, so we choose to use react-style-loader which forked vue-style-loader indeed.

Let's create a simple HOC for server style, title management and http injection.

import axios from 'axios'
import hoistStatics from 'hoist-non-react-statics'
import PropTypes from 'prop-types'
import React from 'react'
import { withRouter } from 'react-router'

// custom dynamic title for better SEO both on server and client
const setTitle = (title, self) => {
  title = typeof title === 'function' ? title.call(self, self) : title

  if (!title) {
    return
  }

  if (__SERVER__) {
    self.props.staticContext.title = `React Server Renderer | ${title}`
  } else {
    // `title` here on client can be promise, but you should not and do not need to do that on server,
    // because on server async data will be fetched in asyncBootstrap first and set into store,
    // then title function will be called again when you call `renderToString` or `renderToStream`.
    // But on client, when you change route, maybe you need to fetch async data first
    // Example: https://github.com/JounQin/react-hackernews/blob/master/src/views/UserView/index.js#L18
    // And also, you need put `@withSsr` under `@connect` with `react-redux` for get store injected in your title function
    Promise.resolve(title).then(title => {
      if (title) {
        document.title = `React Server Renderer | ${title}`
      }
    })
  }
}

export const withSsr = (styles, router = true, title) => {
  if (typeof router !== 'boolean') {
    title = router
    router = true
  }

  return Component => {
    class SsrConmponent extends React.PureComponent {
      static displayName = `Ssr${Component.displayName ||
        Component.name ||
        'Component'}`

      static propTypes = {
        staticContext: PropTypes.object,
      }

      componentWillMount() {
        // `styles.__inject__` will only be exist on server, and inject into `staticContext`
        if (styles.__inject__) {
          styles.__inject__(this.props.staticContext)
        }

        setTitle(title, this)
      }

      render() {
        return (
          <Component
            {...this.props}
            // use different axios instance on server to handle different user client headers
            http={__SERVER__ ? this.props.staticContext.axios : axios}
          />
        )
      }
    }

    return hoistStatics(
      router ? withRouter(SsrConmponent) : SsrConmponent,
      Component,
    )
  }
}

Then use it:

import PropTypes from 'prop-types'
import React from 'react'
import { connect } from 'react-redux'

import { setCounter, increase, decrease } from 'store'
import { withSsr } from 'utils'

import styles from './styles'

@connect(
  ({ counter }) => ({ counter }),
  dispatch => ({
    setCounter: counter => dispatch(setCounter(counter)),
    increase: () => dispatch(increase),
    decrease: () => dispatch(decrease),
  }),
)
@withSsr(styles, false, ({ props }) => props.counter)
export default class Home extends React.PureComponent {
  static propTypes = {
    counter: PropTypes.number.isRequired,
    setCounter: PropTypes.func.isRequired,
    increase: PropTypes.func.isRequired,
    decrease: PropTypes.func.isRequired,
  }

  asyncBootstrap() {
    if (this.props.counter) {
      return true
    }

    return new Promise(resolve =>
      setTimeout(() => {
        this.props.setCounter(~~(Math.random() * 100))
        resolve(true)
      }, 500),
    )
  }

  render() {
    return (
      <div className="container">
        <h2 className={styles.heading}>Counter</h2>
        <button className="btn btn-primary" onClick={this.props.decrease}>
          -
        </button>
        {this.props.counter}
        <button className="btn btn-primary" onClick={this.props.increase}>
          +
        </button>
      </div>
    )
  }
}

And inside the template passed title to bundle renderer:

<html>
  <head>
    <title>{{ title }}</title>
  </head>
  <body>
    ...
  </body>
</html>

Then react-server-renderer will automatically collect user styles and title on server and render them into output!

Notes:

  • Use double-mustache (HTML-escaped interpolation) to avoid XSS attacks.
  • You should provide a default title when creating the context object in case no component has set a title during render.

Using the same strategy, you can easily expand it into a generic head management utility.


So actually it's not so simple right? Yes and no, if you choose to start using SSR, it is certain that you need pay for it, and after digging exist react SSR solutions like react-universally or any other, I find out Vue's solution is really great and simple.

Feature Request or Troubleshooting

Feel free to create an issue.

You can’t perform that action at this time.