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.
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 theinitialPathoption.
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
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
SkuProviderwatches 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.
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
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 therenderAppfunctionheadTags- html tags to be placed in the head of the htmlbodyTags- 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:
- 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.
- Use renderToPipeableStream in your
renderAppfunction. You'll need to wait for the stream to end and return all the HTML at once. - Experimental: sku provides a
renderToStringAsyncfunction to yourrenderAppfunction 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
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.
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).
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.
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.
