Skip to content

Static rendering

Client-side apps all suffer the same performance pitfall of not being able to show any content until the JavaScript is downloaded, parsed and run. The user sees a blank white screen until this has all occurred, which is often followed by a loading indicator while data is fetched from the network. To improve perceived performance, sku renders all the static content (everything not requiring an API call) of your app at build time. We refer to this as static rendering.

Configuration

The sku config contains route specific options.

ts
export default {
  routes: ['/', '/details'],
  environments: ['development', 'production'],
  sites: ['australia', 'asia'],
  target: 'dist', // Optional, this is the default value
} satisfies SkuConfig;

The first listed route will be the default route opened by sku start. If you want to change this, set the initialPath option.

Running sku build with the above config will create the following output in your target directory.

├── development
│   ├── australia
│   │   ├── index.html
│   │   ├── details
│   │   |   ├── index.html
│   ├── asia
│   │   ├── index.html
│   │   ├── details
│   │   |   ├── index.html
├── production
│   ├── australia
│   │   ├── index.html
│   │   ├── details
│   │   |   ├── index.html
│   ├── asia
│   │   ├── index.html
│   │   ├── details
│   │   |   ├── index.html
├── [static-asset].{css,js,jpg,etc}

environments, sites & routes are all optional and will not show up in the build output if not specified.

NOTE: sku start will default to the first environment and site in your config if provided. You can use any site by passing the --environment argument e.g. sku start --environment production

Rendering

After configuring sku, the render entry needs to return the HTML required to create all the above files. The render entry is set via renderEntry (default is src/render.js).

Example render entry

tsx
import React from 'react';
import { renderToString } from 'react-dom/server';
import type { Render } from 'sku';

import App from './App';

export default {
  renderApp: ({ SkuProvider, environment, site, route }) =>
    renderToString(
      <SkuProvider>
        <App environment={environment} site={site} route={route} />
      </SkuProvider>,
    ),

  renderDocument: ({ app, bodyTags, headTags }) => `
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="UTF-8">
        <title>My Awesome Project</title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        ${headTags}
      </head>
      <body>
        <div id="app">${app}</div>
        ${bodyTags}
      </body>
    </html>
  `,
} satisfies Render;

renderApp

The renderApp function should return your application as an HTML string (generally using React.renderToString). It can also return other values (e.g. extracted meta information, CSS styles, etc). Anything returned will be available in renderDocument. You should not render your whole HTML document in renderApp, only return the HTML that is generated by React.

NOTE: Make sure to wrap your app with the SkuProvider. This is required for the app to work.

renderApp will be called once for each combination of settings in sku config. Specifically, environment, site & route.

The SkuProvider watches your render for dynamic imports. This allows sku to provide all the required script tags for this page to work client side.

EXPERIMENTAL: renderApp provides a renderToStringAsync function parameter which can be used instead of calling React.renderToString. See Supporting React Suspense

provideClientContext

provideClientContext is an optional function that runs after renderApp. It is used to pass context between the static render and the client code. This is useful for passing config values (e.g. API endpoints, feature switches) or state information (e.g. redux state). The object this function returns will be passed to the client entry.

The function receives environment, site & the result of renderApp.

tsx
import React from 'react';
import { renderToString } from 'react-dom/server';
import type { Render } from 'sku';

import App from './App';

export default {
  renderApp: ({ SkuProvider, environment, site, route }) => {
    const html = renderToString(
      <SkuProvider>
        <App environment={environment} site={site} route={route} />
      </SkuProvider>,
    );

    return {
      html,
      appLength: html.length, // <- arbitrary example
    };
  },

  provideClientContext: ({ site, environment, app }) => ({
    site,
    analyticsEnabled: environment === 'production',
    appLength: app.appLength,
  }),

  renderDocument: ({ app, bodyTags, headTags }) => `
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="UTF-8">
        <title>My Awesome Project</title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        ${headTags}
      </head>
      <body>
        <div id="app">${app}</div>
        ${bodyTags}
      </body>
    </html>
  `,
} satisfies Render;

Client entry

tsx
import React from 'react';
import { hydrateRoot } from 'react-dom/client';

import App from './App';

// The return type of `provideClientContext` in your render entry
type ClientContext = {
  site: string;
  analyticsEnabled: boolean;
  appLength: number;
};

export default ({ site, analyticsEnabled, appLength }: ClientContext) => {
  console.log('HTML source length', appLength);

  hydrateRoot(
    document.getElementById('app')!,
    <App site={site} analytics={analyticsEnabled} />,
  );
};

renderDocument

renderDocument is called after renderApp. It gets passed all the same values as renderApp plus the following. It must return a full HTML document.

  • app - the value returned from the renderApp function
  • headTags - html tags to be placed in the head of the html
  • bodyTags - html tags to be placed at the end of the html body

Supporting React Suspense

React Suspense allows renders to complete asynchronously, waiting for modules or data to become available. When this occurs during static rendering, renderToString will throw an error, as it expects the render to complete immediately. To avoid this error you have a few options:

  1. Never suspend a component during an initial render. Either don't use React Suspense in the first place, or ensure suspended components aren't used until after hydration.
  2. Use renderToPipeableStream in your renderApp function. You'll need to wait for the stream to end and return all the HTML at once.
  3. Experimental: sku provides a renderToStringAsync function to your renderApp function that will perform option 2 for you.

Regardless of how you support it, please consider that Suspense is a new feature for React. Its APIs and use are rapidly evolving, and it is partially undocumented (see Note on Suspense-enabled data sources).

"Suspense-enabled data fetching without the use of an opinionated framework is not yet supported. The requirements for implementing a Suspense-enabled data source are unstable and undocumented. An official API for integrating data sources with Suspense will be released in a future version of React." - React Suspense Documentation

Example renderApp's renderToStringAsync parameter

tsx
import React from 'react';
import type { Render } from 'sku';

import App from './App';

export default {
  renderApp: async ({
    SkuProvider,
    environment,
    site,
    route,
    renderToStringAsync,
  }) => {
    const html = await renderToStringAsync(
      <SkuProvider>
        <App environment={environment} site={site} route={route} />
      </SkuProvider>,
    );

    return {
      html,
    };
  },
  // ...
} satisfies Render;

Common use cases

TypeScript

If your app uses TypeScript, sku provides the type definitions for the render entry.

tsx
import React from 'react';
import { renderToString } from 'react-dom/server';
import type { Render } from 'sku';
import App from './App';

interface RenderContext {
  html: string;
  otherThing: number;
}

export default {
  renderApp: () => ({
    html: renderToString(<App />),
    otherThing: 10,
  }),

  renderDocument: ({ app, headTags, bodyTags }) => `
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="UTF-8">
        <title>My Awesome Project</title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        ${headTags}
      </head>
      <body>
        <div id="app">${app.html}</div>
        ${bodyTags}
      </body>
    </html>
  `,
} satisfies Render<RenderContext>;

React Helmet

React Helmet requires extra work after React is finished rendering to extract static meta information. This is also a common pattern used by some other libraries (e.g. react-loadable, emotion).

tsx
import React from 'react';
import ReactDOM from 'react-dom/server';
import Helmet from 'react-helmet';
import type { Render } from 'sku';

import App from './App/App';

export default {
  renderApp: () => {
    const appHtml = ReactDOM.renderToString(<App />);
    const helmet = Helmet.renderStatic();

    const htmlAttributes = helmet.htmlAttributes.toString();
    const bodyAttributes = helmet.bodyAttributes.toString();
    const metaStrings = [
      helmet.title.toString(),
      helmet.meta.toString(),
      helmet.link.toString(),
    ];
    const metaHtml = metaStrings.filter(Boolean).join('\n    ');

    return {
      appHtml,
      metaHtml,
      htmlAttributes,
      bodyAttributes,
    };
  },
  renderDocument: ({ app, bodyTags, headTags }) => `
    <!DOCTYPE html>
    <html${app.htmlAttributes ? ` ${app.htmlAttributes}` : ''}>
      <head>
        ${app.metaHtml}
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        ${headTags}
      </head>
      <body${app.bodyAttributes ? ` ${app.bodyAttributes}` : ''}>
        <div id="app">${app.appHtml}</div>
        ${bodyTags}
      </body>
    </html>
  `,
} satisfies Render;

Dynamic routes

Apps using params in their path (eg. /job/[12345]) are supported as well. These params are represented in sku like so, /job/$id, where $ marks this part of the path as dynamic.

ts
export default {
  routes: ['/', '/job/$id'],
} satisfies SkuConfig;

When running sku start, requesting /job/123 will return the rendered HTML for /job/$id. Running sku build will output the following folder structure.

├── index.html
├── job
│   ├── $id
│   │   ├── index.html
├── [static-asset].{css,js,jpg,etc}

Warning

Although sku supports this behaviour, your web server must also be configured to serve the correct files by routing dynamic paths to the appropriate static file for that route. Due to this, some teams will choose to opt out of statically rendering these routes to reduce complexity.