Version: 5.x

Server Side Rendering

In the context of single page applications (SPAs), server-side rendering (SSR) refers to dynamic generation of the HTML page that is sent from web server to browser. In a single page application, the server only generates the very first page that the user requests, leaving all subsequent pages to be rendered by the browser.

To accomplish server-side rendering of an SPA, javascript code is executed in NodeJS to generate the initial HTML. In the browser, the same javascript code is executed during a “hydration” process, which attaches event listeners to the HTML. Most popular UI Frameworks (Vue, React, Angular, etc) are capable of executing in both NodeJS and the browser, and offer APIs for both generating the server HTML and hydrating it in the browser. Additionally, there are popular frameworks such as NextJS and Nuxt which simplify the developer experience of server-side rendering.

In the context of microfrontends, server-side rendering refers to assembling the HTML from multiple, separate microfrontends. Each microfrontend controls a fragment of the HTML sent from web server to browser, and hydrate their fragment once initialized in the browser.

A primary purpose of server-side rendering is improved performance. Server rendered pages often display their content to users faster than their static counterparts, since the user is presented with the content before javascript resources have been initialized. Other reasons for SSR include improved search engine optimization (SEO).

Server rendered applications are generally harder to build and maintain, since the code has to work on both client and server. Additionally, SSR often complicates the infrastructure needed to run your application, since many SPA + SSR solutions require NodeJS, which is not required in production for client-only SPAs.

The isomorphic-microfrontends example shows React server-rendered microfrontends. You can view the live demo of the code at https://isomorphic.microfrontends.app.

The ultimate goal of server-side rendering is to generate an HTTP response that the browser will display to the user while javascript is hydrating. Most microfrontend server-side rendering implementations, including single-spa’s recommended approach, do this with the following steps:

  1. Layout - Identify which microfrontends to render for the incoming HTTP request, and where within the HTML they will be placed. This is usually route based.
  2. Fetch - Begin rendering the HTML for each microfrontend to a stream.
  3. Headers - Retrieve HTTP response headers from each microfrontend. Merge them together and send the result as the HTTP response headers to the browser.
  4. Body - Send the HTTP response body to the browser, which is an HTML document consisting of static and dynamic parts. This involves waiting for each microfrontend’s stream to end before proceeding to the next portion of HTML.
  5. Hydrate - Within the browser, download all javascript needed and then hydrate the HTML.

To define an HTML template that lays out your page, first choose a “microfrontend layout middleware”:

  1. single-spa-layout: The official layout engine for single-spa.
  2. Tailor: A popular, battle tested layout engine that predates single-spa-layout and is not officially affiliated with single-spa.
  3. TailorX: An actively maintained fork of Tailor that is used by Namecheap in their single-spa website. The single-spa core team collaborated with the creators of TailorX when authoring single-spa-layout, taking some inspiration from it.

We generally recommend single-spa-layout, although choosing one of the other options might make sense for your situation, since single-spa-layout is newer and has been used less than Tailor/TailorX.

With single-spa-layout, you define a single template that handles all routes. Full documentation.

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  6. <meta http-equiv="X-UA-Compatible" content="ie=edge" />
  7. <title>Isomorphic Microfrontends</title>
  8. <meta
  9. name="importmap-type"
  10. content="systemjs-importmap"
  11. server-cookie
  12. server-only
  13. />
  14. <script src="https://cdn.jsdelivr.net/npm/import-map-overrides@2.0.0/dist/import-map-overrides.js"></script>
  15. <script src="https://cdn.jsdelivr.net/npm/systemjs@6.6.1/dist/system.min.js"></script>
  16. <script src="https://cdn.jsdelivr.net/npm/systemjs@6.6.1/dist/extras/amd.min.js"></script>
  17. <script src="https://cdn.jsdelivr.net/npm/systemjs@6.6.1/dist/extras/named-exports.min.js"></script>
  18. </head>
  19. <body>
  20. <template id="single-spa-layout">
  21. <single-spa-router>
  22. <nav>
  23. <application name="@org-name/navbar"></application>
  24. </nav>
  25. <main>
  26. <route path="settings">
  27. <application name="@org-name/settings"></application>
  28. </route>
  29. <route path="home">
  30. <application name="@org-name/home"></application>
  31. </route>
  32. </main>
  33. </single-spa-router>
  34. </template>
  35. <fragment name="importmap"></fragment>
  36. <script>
  37. System.import('@org-name/root-config');
  38. </script>
  39. <import-map-overrides-full
  40. show-when-local-storage="devtools"
  41. dev-libs
  42. ></import-map-overrides-full>
  43. </body>
  44. </html>

Your microfrontend layout middleware (see Layout section) determines which microfrontends match the HTTP request’s route. The middleware then fetches the HTTP response headers and HTML content for each microfrontend.

When using single-spa-layout, fetching each microfrontend is handled by the renderApplication function that is provided to renderServerResponseBody.

The method of fetching the headers and HTML content can vary, since single-spa-layout allows for any arbitrary, custom method of fetching. However, in practice, there are two popular approaches, which are described below. We generally recommend dynamic module loading as the primary method, since it requires less infrastructure to set up and has arguably (slightly) better performance. However, HTTP requests have some advantages, too, and it’s also possible for different microfrontends to be implemented with different fetch methods.

Module loading refers to loading javascript code using import and import(). Using module loading, the implementation of fetching the headers and content for each microfrontend is done purely within a single web server and operating system process:

  1. import('@org-name/navbar/server.js').then(navbar => {
  2. const headers = navbar.getResponseHeaders(props);
  3. const htmlStream = navbar.serverRender(props);
  4. });

In the context of single-spa-layout, this is done inside of the renderApplication function:

  1. import {
  2. constructServerLayout,
  3. sendLayoutHTTPResponse,
  4. } from 'single-spa-layout/server';
  5. import http from 'http';
  6. const serverLayout = constructServerLayout({
  7. filePath: 'server/views/index.html',
  8. });
  9. http
  10. .createServer((req, res) => {
  11. const { bodyStream } = sendLayoutHTTPResponse({
  12. res,
  13. serverLayout,
  14. urlPath: req.path,
  15. async renderApplication({ appName, propsPromise }) {
  16. const [app, props] = await Promise.all([
  17. import(`${props.name}/server.mjs`, propsPromise),
  18. ]);
  19. return app.serverRender(props);
  20. },
  21. async retrieveApplicationHeaders({ appName, propsPromise }) {
  22. const [app, props] = await Promise.all([
  23. import(`${props.name}/server.mjs`, propsPromise),
  24. ]);
  25. return app.getResponseHeaders(props);
  26. },
  27. async retrieveProp(propName) {
  28. return 'prop value';
  29. },
  30. assembleFinalHeaders(appHeaders) {
  31. return Object.assign(
  32. {},
  33. ...Object.values(allHeaders).map(a => a.appHeaders),
  34. );
  35. },
  36. renderFragment(name) {
  37. // not relevant to the docs here
  38. },
  39. });
  40. bodyStream.pipe(res);
  41. })
  42. .listen(9000);

To facilitate independent deployments of our microfrontends, such that the web server does not have to reboot/redeploy every time we update every microfrontend, we can use dynamic module loading. Dynamic module loading refers to loading a module from a dynamic location - often from somewhere on disk or over the network. By default, NodeJS will only load modules from relative URLs or from the node_modules directory, but dynamic module loading allows you to load modules from any arbitrary file path or URL.

A pattern to facilitate independent deployments via dynamic module loading is for each microfrontend’s deployment to upload one or more javascript files to a trusted CDN, and then use dynamic module loading to load a certain version of the code on the CDN. The web server polls for new versions of each microfrontend and downloads the newer versions as they are deployed.

To accomplish dynamic module loading, we can use NodeJS module loaders. Specifically, @node-loader/import-maps and @node-loader/http allow us to control where the module is located and how to download it over the network. The code below shows how a server-side import map facilitates dynamic module loading

Before deployment of navbar:

  1. {
  2. "imports": {
  3. "@org-name/navbar/": "https://cdn.example.com/navbar/v1/"
  4. }
  5. }

After deployment of navbar:

  1. {
  2. "imports": {
  3. "@org-name/navbar/": "https://cdn.example.com/navbar/v2/"
  4. }
  5. }

The import map itself is hosted on the CDN, so that deployments may occur without restarting the web server. An example of this setup is shown here.

It is also possible to implement the fetching of HTML content and HTTP headers from microfrontends using HTTP requests. In this setup, each microfrontend must run as a deployed web server. The root web server (responsible for responding to the browser) makes an HTTP call to each of the microfrontends’ web servers. Each microfrontend web server responds with an HTML page as response body, along with its HTTP response headers. The response body is streamed to the root web server so that it can send the bytes as soon as possible to the browser.

In the context of single-spa-layout, this is done with the renderApplication function:

  1. import {
  2. constructServerLayout,
  3. sendLayoutHTTPResponse,
  4. } from 'single-spa-layout/server';
  5. import http from 'http';
  6. import fetch from 'node-fetch';
  7. const serverLayout = constructServerLayout({
  8. filePath: 'server/views/index.html',
  9. });
  10. http
  11. .createServer((req, res) => {
  12. const fetchPromises = {};
  13. sendLayoutHTTPResponse(serverLayout, {
  14. res,
  15. serverLayout,
  16. urlPath: req.path,
  17. async renderApplication({ appName, propsPromise }) {
  18. const props = await propsPromise;
  19. const fetchPromise =
  20. fetchPromises[appName] ||
  21. (fetchPromises[appName] = fetchMicrofrontend(props));
  22. const response = await fetchPromise;
  23. // r.body is a Readable stream when you use node-fetch,
  24. // which is best for performance when using single-spa-layout
  25. return response.body;
  26. },
  27. async retrieveApplicationHeaders({ appName, propsPromise }) {
  28. const props = await propsPromise;
  29. const fetchPromise =
  30. fetchPromises[appName] ||
  31. (fetchPromises[appName] = fetchMicrofrontend(props));
  32. const response = await fetchPromise;
  33. return response.headers;
  34. },
  35. async retrieveProp(propName) {
  36. return 'prop value';
  37. },
  38. assembleFinalHeaders(allHeaders) {
  39. return Object.assign({}, ...Object.values(allHeaders));
  40. },
  41. renderFragment(name) {
  42. // not relevant to the docs here
  43. },
  44. });
  45. bodyStream.pipe(res);
  46. })
  47. .listen(9000);
  48. async function fetchMicrofrontend(props) {
  49. fetch(`http://${props.name}`, {
  50. headers: props,
  51. }).then(r => {
  52. if (r.ok) {
  53. return r;
  54. } else {
  55. throw Error(
  56. `Received http response ${r.status} from microfrontend ${appName}`,
  57. );
  58. }
  59. });
  60. }

The HTTP response headers sent to the browser are a combination of default headers and the headers retrieved from each microfrontend. Your method of fetching microfrontends does not change how the final headers are merged and assembled for the browser.

Tailor and TailorX have built-in methods of merging headers. Single-spa-layout allows for custom merging via the assembleFinalHeaders option:

  1. import {
  2. constructServerLayout,
  3. sendLayoutHTTPResponse,
  4. } from 'single-spa-layout/server';
  5. import http from 'http';
  6. const serverLayout = constructServerLayout({
  7. filePath: 'server/views/index.html',
  8. });
  9. http
  10. .createServer((req, res) => {
  11. const { bodyStream } = sendLayoutHTTPResponse({
  12. res,
  13. serverLayout,
  14. urlPath: req.path,
  15. async renderApplication({ appName, propsPromise }) {
  16. const [app, props] = await Promise.all([
  17. import(`${props.name}/server.mjs`, propsPromise),
  18. ]);
  19. return app.serverRender(props);
  20. },
  21. async retrieveApplicationHeaders({ appName, propsPromise }) {
  22. const [app, props] = await Promise.all([
  23. import(`${props.name}/server.mjs`, propsPromise),
  24. ]);
  25. return app.getResponseHeaders(props);
  26. },
  27. async retrieveProp(propName) {
  28. return 'prop value';
  29. },
  30. assembleFinalHeaders(allHeaders) {
  31. // appHeaders contains all the application names, props, and headers for
  32. return Object.assign(
  33. {},
  34. ...Object.values(allHeaders).map(a => a.appHeaders),
  35. );
  36. },
  37. renderFragment(name) {
  38. // not relevant to the docs here
  39. },
  40. });
  41. bodyStream.pipe(res);
  42. })
  43. .listen(9000);

The HTTP Response body sent from the web server to the browser must be streamed, byte by byte, in order to maximize performance. NodeJS Readable streams make this possible by acting as a buffer that sends each byte as received, instead of all bytes at once.

All microfrontend layout middlewares mentioned in this document stream the HTML response body to the browser. In the context of single-spa-layout, this is done by calling sendLayoutHTTPResponse

  1. import { sendLayoutHTTPResponse } from 'single-spa-layout/server';
  2. import http from 'http';
  3. const serverLayout = constructServerLayout({
  4. filePath: 'server/views/index.html',
  5. });
  6. http
  7. .createServer((req, res) => {
  8. sendLayoutHTTPResponse({
  9. res,
  10. // Add all other needed options here, too
  11. });
  12. })
  13. .listen(9000);

Hydration (or rehydration) refers to browser Javascript initializing and attaching event listeners to the HTML sent by the server. There are several variants, including progressive rehydration and partial rehydration.

Overview - 图1info

See also “Rendering on the Web” by Google.

In the context of microfrontends, hydration is done by the underlying UI framework of the microfrontend (React, Vue, Angular, etc). For example, in React, this is done by calling ReactDOM.hydrate(). The single-spa adapter libraries allow you to specify whether you are hydrating or mounting for the first time (see single-spa-react’s renderType option).

The role of single-spa-layout is to determine which microfrontends should hydrate which parts of the DOM. This is done automatically when you call constructLayoutEngine and singleSpa.start(). If using TailorX instead of single-spa-layout, the Isomorphic Layout Composer Project serves a similar purpose as constructLayoutEngine.

Overview - 图2Edit this page