React series part 2: SSR

React series part 2: SSR

The goal of this post is to provide a rather comprehensive overview of React server-side rendering (SSR). SSR is not a new but definitely important and interesting concept. You have probably heard of terms like SSR, SSG, pre-render, dynamic render, ReactDOMServer or SEO and social sharing issues with React. This post tries to explain those terms and give you answers for questions like what is SSR and why and how to use it.

Introducing SSR

React renders components/views to HTML on the browser which is called client-side rendering (CSR) but this can be done on the server also. Generating the HTML on the server is called server-side rendering (SSR). SSR means rendering the application to HTML on the server at request time. To be able to use SSR the application needs to be universal (also term ’isomorphic’ is used) which means that the application can be rendered on the client and on the server. Static site generator (SSG) and pre-rendering techniques are usually mentioned when talking about SSR because these are rather closely related to each other. All these techniques are used for rendering HTML from the content created by JavaScript. SSGs create a completely static site (HTML, CSS, JavaScript, images, etc) which can be hosted on a static site hosting service (AWS S3 for example). Sites generated by SSGs don’t have any server code. Pre-rendering means generating HTML of the application by using a headless browser to crawl the page and execute Javascript. There is also a technique called dynamic rendering, which means switching between client-side rendered and server-side rendered (or pre-rendered) content for specific user agents (search engine crawlers for example). Dynamic rendering is not generally considered as cloaking (https://support.google.com/webmasters/answer/66355) as long as dynamic rendering produces similar content. 

SSR, SSG or Pre-rendering can be used for improving SEO, UX and performance. The issue with SEO is that all search engine crawlers still don’t fully support single-page applications (SPAs) because all crawlers won’t execute JavaScript, or if they do, there are some limitations. With CSR the HTML is generated on the client by executing the React application (JavaScript) so the crawlers could end up seeing only a blank page, which might make the site not indexed by the crawler. The same applies for social media sharing. CSR also affects UX and performance because users need to wait until the React application is downloaded and rendered to see the content. This is noticeable especially with large applications and users with slow internet connection. Also users won’t be able to see any content if the JavaScript download fails or the user has disabled JavaScript from the browser. 

Using SSR can help with these issues. The idea of SSR is that server (typically a Node.js server) renders the application and returns the HTML to the client. After the client has received the HTML it can be used as a normal SPA. With SSR users will see some content immediately after receiving the HTML from the server. Note that you can also use serverless functions (AWS Lambda for example) for SSR instead of a server (AWS EC2/ECS for example). Let’s compare SSR and CSR flows to understand the benefits of SSR more clearly. 

CSR flow

  1. Browser makes a request to the server.
  2. Browser receives almost empty HTML which is a script tag pointing to the JavaScript file containing the application code is.
  3. Browser makes a request and starts downloading the JavaScript file.
  4. Browser executes the JavaScript (React application) when the download is completed.
  5. User sees some content (which doesn’t require data fetching).
  6. Application fetches the data (API-requests).
  7. Application updates the state of the application when data is fetched.
  8. Re-render some components after state update.
  9. Application is ready.

SSR flow

  1. Browser makes a request to the server.
  2. Server receives the request.
  3. Server fetches data for the application (API-requests) if required.
  4. Server renders the application.
  5. Rendered application is wrapped with HTML.
  6. Return HTML to client (crawlers get the HTML which can be indexed).
  7. Browser receives and renders the HTML.
  8. User can see the content.
  9. Browser starts downloading the application JavaScript.
  10. Browser will execute the JavaScript in the background when the download is completed (React code is using hydrate instead of render).
  11. No need to re-render the application because DOM is the same between the server version and client version.

CSR and SSR are both making network requests, executing JavaScript and rendering content. The main difference is when the user will see the content. With CSR it’ll be after downloading and executing the JavaScript and with SSR right after server sends the response. With SSR First Meaningful Paint is usually lower (better) if there aren’t any performance issues with server-side code. Also Time To First Byte (TTFB) will be higher with SSR compared to CSR.

Implementation

React provides ReactDOMServer (react-dom/server) module which enables the server to render React components to HTML. There are also many other libraries and frameworks for React SSR (and SSG) and we’ll go through the most popular ones briefly after this section. With ReactDOMServer React applications can be rendered to an HTML string with the renderToString method or to a readable stream that outputs an HTML string (same string what renderToString would return) with renderToNodeStream method. Both of these methods have static versions (renderToStaticMarkup and renderToStaticNodeStream) that won’t create extra DOM attributes that React uses internally (for example data-reactroot). These static versions are useful when creating static content without using React on the client because stripping the React attributes can reduce the size. It’s important to use ReactDOM.hydrate instead of ReactDOM.render on the client when using renderToString or renderToNodeStream. Calling ReactDOM.hydrate method on the client preserves the server rendered HTML and only attaches event handlers. The hydrate method also calls componentDidMount lifecycle method and useEffect hook. useLayoutEffect hook won’t be called – actually React gives a warning message when using useLayoutEffect with SSR. React-dom gives an error message in case of mismatch between the server and client markup. These errors can be silenced by adding suppressHydrationWarning to the element. This should only be used when element’s attribute or content is unavoidably different between server and client version and it only works one level deep.

Simple example with renderToString:

import { renderToString } from 'react-dom/server'
...
const markup = renderToString(<App />)
const html = `
<!DOCTYPE html>
<html>
  <head>...</head>
  <body>
    <div id="root">${markup}</div>
    ...
  </body>
</html>
`
res.send(html)

Simple example with renderToNodeStream:

import { renderToNodeStream } from 'react-dom/server'
...
const stream = renderToNodeStream(<App />)
const htmlStart = `
<!DOCTYPE html>
<html>
  <head>...</head>
  <body>
    <div id="root">
`
res.write(htmlStart)
stream.pipe(res, { end: false })
const htmlEnd = `</div>
  ...
  </body>
</html>
`
stream.on('end', () => {
  res.end(htmlEnd)
})


Usage on the client:

import { hydrate } from 'react-dom'
...
hydrate(<App />, document.getElementById('root'))

External libraries

React applications usually have one or more of the following technologies: react-router, state management (MobX, Redux, React context, etc), code splitting (React.lazy, @loadable/component), and css-in-js (styled-components, emotion, etc). Each of these technologies requires special attention when it comes to SSR. This section explains how to use these common libraries with SSR starting from react-router. 

react-router

First thing to notice when using react-router on the server is to use stateless StaticRouter instead of BrowserRouter. StaticRouter gets usually two props: location and context. The location prop is used to pass the current route to the router. The context prop is an empty object but after the render it contains the result of the render in the router scope. So after rendering there is a context.url property if <Redirect> has been rendered. You can also add custom properties to the context by using staticContext.

Using react-router (StaticRouter):

import { StaticRouter } from 'react-router-dom'
...
const context = {}
const markup = renderToString(
  <StaticRouter location={req.url} context={context}>
    <App />
  </StaticRouter>
)
// context.url is available if <Redirect> was rendered


State management

Application state will most likely change on the client when using the application. It’s a very typical case that some components are rendered based on the application state, so it would be great to initialize application state on the server and pass it to the client. If the view requires some data to be fetched then all data or at least part of the data could be fetched on the server before rendering the application. This way the application can be rendered with all the data or part of the data it needs. A common approach for passing the preloaded data from the server to the client is to do it by using window object. For example window.__MY_PRELOADED_DATA__ could be used and assign preloaded data to its value. Then that data can be used to initialize the application state on the client. Also Redux side effect libraries like redux-saga can be used with SSR. The following simple application requires data fetching (preload data) and is using Redux as a state management solution.

Using Redux with preloaded data:

// Server
import { renderToString } from 'react-dom/server'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
...
const myData = await fetchMyData()
const preloadedState = { myData }
const store = createStore(rootReducer, preloadedState)
const markup = renderToString(
  <Provider store={store}>
    <App />
  </Provider>
)
const reduxState = store.getState()
const html = `
<!DOCTYPE html>
<html>
  <head>...</head>
  <body>
    <div id="root">${markup}</div>
    <script type="text/javascript">
      // Strip HTML tags
      window.__MY_PRELOADED_DATA__ = ${JSON.stringify(reduxState).replace(/</g, '\\u003c')}
    </script>
    ...
  </body>
</html>
`
res.send(html)

// Client
import React from 'react'
import { hydrate } from 'react-dom'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
...
const preloadedState = window.__MY_PRELOADED_DATA__
delete window.__MY_PRELOADED_DATA__
const store = createStore(rootReducer, preloadedState)
hydrate(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)


Alternatively we could make the data fetch as part of the client side initialization process and add the data to the store. But that approach would make two data fetch: one on the server and one on the client. 

Code splitting

Code splitting is very useful technique for splitting the application code into various bundles which can then be loaded on demand and in parallel. Code splitting in React applications is usually done with React.lazy. React.lazy is a function which renders a dynamic import as a regular component. However React.lazy doesn’t support SSR yet. Luckily there’s a library called loadable-components (https://github.com/smooth-code/loadable-components) which provides code splitting with SSR support. To get started with loadable-components first you need to install @loadable/babel-plugin and @loadable/webpack-plugin to add support for SSR (automatic chunk names and to create loadable-stats.json file for the ChunkExtractor). The next step is to use ChunkExtractor or ChunkExtractorManager on the server for collecting the chunks and adding link and script-tags to the HTML. Finally on the client you need to use loadable to create loadable components and loadableReady to wait for all loadable components to be loaded (in parallel). Loadable-components provide plenty of other useful features too.

Code splitting with loadable-components:

// .babelrc
{
  "plugins": ["@loadable/babel-plugin"]
}

// webpack.config.js
const LoadablePlugin = require('@loadable/webpack-plugin')
...
module.exports = {
  ...
  plugins: [new LoadablePlugin()],
}

// Server
import { ChunkExtractor } from '@loadable/server'
import { renderToString } from 'react-dom/server'
...
const statsFile = path.resolve(__dirname, 'path/to/loadable-stats.json')
const extractor = new ChunkExtractor({ statsFile, entrypoints: [‘myEntrypointName’] })
const jsx = extractor.collectChunks(<App />)
const markup = renderToString(jsx)
const html = `
<!DOCTYPE html>
<html>
  <head>
    ...
    ${extractor.getLinkTags()}
  </head>
  <body>
    <div id="root">${markup}</div>
    ${extractor.getScriptTags()}
    ...
  </body>
</html>
`
res.send(html)

// Client
import loadable, { loadableReady } from '@loadable/component'
import { hydrate } from 'react-dom'
...
const MyLoadableComponent = loadable(() => import('path/to/some/component'))
const App = () => (
  <>
    <h1>Demo app</h1>
    <MyLoadableComponent /> 
  </>
)
loadableReady(() => {
  hydrate(<App />, document.getElementById('root'))
})

CSS-in-JS

When using CSS-in-JS solution it’s important to note which libraries support SSR. For example styled-components, which is one of the most popular CSS-in-JS libraries does support SSR (renderToString and renderToNodeStream). Styled-components supports concurrent server side rendering with stylesheet rehydration. It uses ServerStyleSheet and adds a provider to the React application tree. The provider accepts styles via a context API and can be used through collectStyles method (it wraps provided element with the provider) or by using the StyleSheetManager provider directly. ServerStyleSheet instance’s interleaveWithNodeStream method can be used with streaming rendering. This method checks if any styles are ready to be rendered and if so the style block is prepended to the HTML.

Using styled-components with SSR:

import { ServerStyleSheet } from 'styled-components'
...
const sheet = new ServerStyleSheet()
const markup = renderToString(sheet.collectStyles(<App />))
const styleTags = sheet.getStyleTags()
...
// With streaming rendering:
const markup = sheet.collectStyles(<App />)
const stream = sheet.interleaveWithNodeStream(renderToNodeStream(markup))
...

So configuring different libraries for SSR clearly adds some complexity to codebase. And these aren’t the only libraries that are requiring configuration for SSR. In fact there are plenty of other popular libraries as well like react-i18next, react-helmet, etc that require configuring for SSR. The point of this section was to show how to configure the most popular libraries for SSR and pointing out that adding new library could potentially mean that it needs to be configured for SSR.

Frameworks

There are plenty of SSR and SSG libraries and frameworks such as: Next.js, Gatsby, After.js, Razzle, react-server, Reframe, Fusion.js, Electrode, Hypernova. For pre-rendering there are SaaS options like prerender.io (https://prerender.io/), prerender.cloud (https://www.prerender.cloud/) and libraries like react-snap (https://github.com/stereobooster/react-snap). Next.js and Gatsby are currently the most popular ones.

Next.js is a React framework that supports SSR, static export and other features. Next.js takes away the “pain” to implement SSR from scratch and basically offers SSR out-of-the-box for you. When working with Next.js or any other framework there are always some conventions so developers need to implement code in the ”framework way”. For example with Next.js developers need to use specified directories for pages, configure framework with next.config, use Next.js version of react-router, append elements to <head> with next/head, use getInitialProps just to mention a few. In the client code you add the page specific views to pages directory and Next.js handles for example mounting, hydrating and code splitting for you so you don’t need to import React or setup routes. In the server code you can just require next and create a new instance of it, call getRequestHandler and prepare then use it together with express.js for example. When making a request to a Next.js app the request goes to the Node.js server which renders the application on the server with Next.js and returns the rendered application (HTML) to the client where Next.js handles hydrating, among other things.

Gatsby is a framework based on React and a SSG. This means that before deploying and running the React application, Gatsby needs to generate the static site first with build command. The Gatsby build command produces a directory of static HTML and JavaScript files, which you can deploy to a static site hosting service. Gatsby can also pull data from specified sources. With Gatsby there won’t be any servers required and therefore no server-side code because all is done at build time of the application. So when making a request to a Gatsby app you get pre-rendered HTML file which has script-tag pointing to the React application. One thing to notice with Gatsby is that number of pages affects the build time.

SSR Performance

There are some performance challenges with SSR. If the application needs some data then the data must be fetched before rendering the application. However, the data can be cached for some time if possible to prevent data fetch related performance issues. SSR with or without data fetch is increasing time to first byte (TTFB) compared to CSR. But the HTML could also be cached for some time to reduce the TTFB. TTFB can be reduced also using SSR with streaming. Caching HTML also reduces the server load because each requests won’t trigger SSR because the HTML of the rendered React application can be returned from the cache. 

When using renderToString method HTML can be added to the cache after the render and when using renderToNodeStream the HTML can be added to the cache by using transform stream. I recommend to cache as much as possible to make SSR as fast as possible. It’s also possible to add caching to the component level. There are libraries for different React versions but for the current version you can check out react-component-caching (https://github.com/rookLab/react-component-caching) and react-prerendered-component (https://github.com/theKashey/react-prerendered-component). 

Developers can also decide if some views should not have SSR. For example, if SSR is used only because of SEO it’s not necessary to SSR content which requires users to log-in because crawlers won’t index these views. 

It’s important to know at least basic React performance optimization techniques because unoptimized React application adds more latency to the server response since the render takes longer. SSR can also be optimized by not rendering everything on server. Some components can be rendered only on the client. Placeholder or spinner can be used to indicate that there will be some more content. One technique for this is to use double render so setting boolean variable to true in componentDidMount or in useEffect and render component based on the value of the variable. However, this causes component to rerender and that’s why it shouldn’t be overused because of the performance hit.

Conclusion

SSR is a technique for rendering application on the server and returning HTML to the client. It’s used primarily for SEO, social sharing and to improve performance. You can use ReactDOMServer when implementing SSR or use some framework like Next.js or Gatsby. It’s important to remember that you may need to add some configurations or new code to your server-side code when adding a new libraries to your project. Performance of the application is important since inefficient application uses more time for rendering and thus increasing the latency. Also caching should be used whenever suitable to reduce the latency. Think at least about SEO, social sharing, performance and budget (servers and development isn’t free) if you are wondering whether or not using SSR. I hope this post was able to give you an overview of SSR and maybe you will be using it on your next project.

Vastaa

Sähköpostiosoitettasi ei julkaista. Pakolliset kentät on merkitty *