Skip to content
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions src/content/docs/workers/examples/spa-shell.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
---
summary: Serve an SPA shell from Workers Static Assets and use HTMLRewriter to inject bootstrap data, eliminating client-side data fetching on initial load.
tags:
- TypeScript
- SPA
pcx_content_type: example
title: SPA shell with bootstrap data
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
title: SPA shell with bootstrap data
title: Single Page App (SPA) shell with bootstrap data

sidebar:
order: 1001
description: Serve an SPA shell from Workers Static Assets and use HTMLRewriter to inject prefetched bootstrap data inline, eliminating client-side data fetching on initial page load.
reviewed: 2026-02-19
---

import { TypeScriptExample, WranglerConfig } from "~/components";

This example uses a Worker to serve a single-page application (SPA) shell from [Workers Static Assets](/workers/static-assets/) and inject prefetched API data with [HTMLRewriter](/workers/runtime-apis/html-rewriter/). The Worker fetches bootstrap data in parallel with the HTML shell and streams the result to the browser, so the SPA has everything it needs before its JavaScript runs.

This pattern works with any SPA framework — React, Vue, Svelte, or others.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Include links to the framework docs that are here:

https://developers.cloudflare.com/workers/framework-guides/web-apps


## Configure static assets

Set `not_found_handling` to `"single-page-application"` so that every route returns `index.html`. Use `run_worker_first` to route all requests through your Worker except hashed assets under `/assets/*`, which are served directly.

<WranglerConfig>

```jsonc
{
"name": "my-spa",
"main": "src/worker.ts",
"compatibility_date": "$today",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/*", "!/assets/*"],
},
}
```

</WranglerConfig>

For more details on these options, refer to [Static Assets routing](/workers/static-assets/routing/) and the [`run_worker_first` reference](/workers/static-assets/binding/#run_worker_first).

## Inject bootstrap data with HTMLRewriter

The Worker starts fetching API data immediately, then fetches the SPA shell from static assets. HTMLRewriter streams the `<head>` to the browser right away. When the `<body>` handler runs, it awaits the API response and prepends a `<script>` tag containing the serialized data.

If the API call fails, the shell still loads and the SPA falls back to client-side data fetching.

<TypeScriptExample>

```ts
interface Env {
ASSETS: Fetcher;
API_BASE_URL: string;
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

// Serve root-level static files (favicon.ico, robots.txt) directly.
// Hashed assets under /assets/* skip the Worker entirely via run_worker_first.
if (url.pathname.match(/\.\w+$/) && !url.pathname.endsWith(".html")) {
return env.ASSETS.fetch(request);
}

// Start fetching bootstrap data immediately — do not await yet.
const dataPromise = fetchBootstrapData(env, url.pathname, request.headers);

// Fetch the SPA shell from static assets (co-located, sub-millisecond).
const shell = await env.ASSETS.fetch(
new Request(new URL("/index.html", request.url)),
);

// Use HTMLRewriter to stream the shell and inject data into <body>.
return new HTMLRewriter()
.on("body", {
async element(el) {
const data = await dataPromise;
if (data) {
el.prepend(
`<script>window.__BOOTSTRAP_DATA__=${JSON.stringify(data)}</script>`,
{ html: true },
);
}
},
})
.transform(shell);
},
} satisfies ExportedHandler<Env>;

async function fetchBootstrapData(
env: Env,
pathname: string,
headers: Headers,
): Promise<unknown | null> {
try {
const res = await fetch(`${env.API_BASE_URL}/api/bootstrap`, {
headers: {
Cookie: headers.get("Cookie") || "",
"X-Request-Path": pathname,
},
});
if (!res.ok) return null;
return await res.json();
} catch {
// If the API is down, the shell still loads and the SPA
// falls back to client-side data fetching.
return null;
}
}
```

</TypeScriptExample>

## Consume prefetched data in your SPA

On the client, read `window.__BOOTSTRAP_DATA__` before making any API calls. If the data exists, use it directly. Otherwise, fall back to a normal fetch.

```tsx title="src/App.tsx"
// React example — works the same way in Vue, Svelte, or any other framework.
import { useEffect, useState } from "react";

function App() {
const [data, setData] = useState(window.__BOOTSTRAP_DATA__ || null);
const [loading, setLoading] = useState(!data);

useEffect(() => {
if (data) return; // Already have prefetched data — skip the API call.

fetch("/api/bootstrap")
.then((res) => res.json())
.then((result) => {
setData(result);
setLoading(false);
});
}, []);

if (loading) return <LoadingSpinner />;
return <Dashboard data={data} />;
}
```

Add a type declaration so TypeScript recognizes the global property:

```ts title="global.d.ts"
declare global {
interface Window {
__BOOTSTRAP_DATA__?: unknown;
}
}
```

## Additional injection techniques

You can chain multiple HTMLRewriter handlers to inject more than bootstrap data.

### Set meta tags

Inject Open Graph or other `<meta>` tags based on the request path. This gives social-media crawlers correct previews without a full server-side rendering framework.

```ts
new HTMLRewriter()
.on("head", {
element(el) {
el.append(`<meta property="og:title" content="${title}" />`, {
html: true,
});
},
})
.transform(shell);
```

### Add CSP nonces

Generate a nonce per request and inject it into both the Content-Security-Policy header and each inline `<script>` tag.

```ts
const nonce = crypto.randomUUID();

const response = new HTMLRewriter()
.on("script", {
element(el) {
el.setAttribute("nonce", nonce);
},
})
.transform(shell);

response.headers.set(
"Content-Security-Policy",
`script-src 'nonce-${nonce}' 'strict-dynamic';`,
);

return response;
```

### Inject user configuration

Expose feature flags or environment-specific settings to the SPA without an extra API round-trip.

```ts
new HTMLRewriter()
.on("body", {
element(el) {
el.prepend(
`<script>window.__APP_CONFIG__=${JSON.stringify({
apiBase: env.API_BASE_URL,
featureFlags: { darkMode: true },
})}</script>`,
{ html: true },
);
},
})
.transform(shell);
```

## When to use this pattern
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this section


This pattern is a good fit when:

- Your SPA serves the same HTML shell for every route.
- The initial page load requires data from an API (for example, user session, feature flags, or page-specific content).
- Your API origin adds latency that you want to hide behind the HTML stream.
- You want the SPA to remain functional even when the API is unreachable.

This pattern is not needed when:

- Your SPA does not need server-side data on initial load.
- You already use server-side rendering (SSR) or static site generation (SSG) to embed data in HTML.
- Your origin HTML is cached behind Cloudflare and already contains the required data.

## Related resources

- [HTMLRewriter](/workers/runtime-apis/html-rewriter/) — Streaming HTML parser and transformer.
- [Workers Static Assets](/workers/static-assets/) — Serve static files alongside your Worker.
- [Static Assets routing](/workers/static-assets/routing/) — Configure `run_worker_first` and `not_found_handling`.
- [Static Assets binding](/workers/static-assets/binding/) — Reference for the `ASSETS` binding and routing options.