Skip to content
Cloudflare Docs

Single Page App (SPA) shell with bootstrap data

Last reviewed: 1 day ago

Use HTMLRewriter to inject bootstrap data into an SPA shell — whether the shell is served from Workers Static Assets or fetched from an external origin.

This example uses a Worker and HTMLRewriter to inject prefetched API data into a single-page application (SPA) shell. 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.

Two variants are shown:

  1. Static Assets — The SPA is deployed using Workers Static Assets
  2. External origin — The SPA is hosted outside Cloudflare, and the Worker sits in front of it as a reverse proxy, improving performance

Both variants use the same HTMLRewriter injection technique and the same client-side consumption pattern. Choose the one that matches your deployment.

This pattern works with any SPA framework — React, Vue, Svelte, or others. For framework-specific deployment guides, refer to Web applications.


Option 1: Single Page App (SPA) built entirely on Workers

Use this variant when your SPA build output is deployed as part of your Worker using Static Assets.

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.

{
"name": "my-spa",
"main": "src/worker.ts",
// Set this to today's date
"compatibility_date": "2026-02-20",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/*", "!/assets/*"],
},
}

For more details on these options, refer to Static Assets routing and the run_worker_first reference.

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.

JavaScript
// Env is generated by `wrangler types` — run it whenever you change your config.
// Do not manually define Env — it drifts from your actual bindings.
export default {
async fetch(request, env) {
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);
},
};
async function fetchBootstrapData(env, pathname, headers) {
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;
}
}

Option 2: SPA hosted on an external origin

Use this variant when your HTML, CSS, and JavaScript are deployed outside Cloudflare. The Worker fetches the SPA shell from the external origin, uses HTMLRewriter to inject bootstrap data, and streams the modified response to the browser.

Configure the Worker

Because the SPA is not in Workers Static Assets, you do not need an assets block. Instead, store the external origin URL as an environment variable. Attach the Worker to your domain with a Custom Domain or a Route.

{
"name": "my-spa-proxy",
"main": "src/worker.ts",
// Set this to today's date
"compatibility_date": "2026-02-20",
"compatibility_flags": ["nodejs_compat"],
"vars": {
"SPA_ORIGIN": "https://my-spa.example-hosting.com",
"API_BASE_URL": "https://api.example.com",
},
}

Inject bootstrap data with HTMLRewriter

The Worker fetches both the SPA shell and API data in parallel. When the SPA origin responds, HTMLRewriter streams the HTML while injecting bootstrap data into <body>. Static assets (CSS, JS, images) are passed through to the external origin without modification.

JavaScript
// Env is generated by `wrangler types` — run it whenever you change your config.
// Do not manually define Env — it drifts from your actual bindings.
export default {
async fetch(request, env) {
const url = new URL(request.url);
// Pass static asset requests through to the external origin unmodified.
if (url.pathname.match(/\.\w+$/) && !url.pathname.endsWith(".html")) {
return fetch(new Request(`${env.SPA_ORIGIN}${url.pathname}`, request));
}
// Start fetching bootstrap data immediately — do not await yet.
const dataPromise = fetchBootstrapData(env, url.pathname, request.headers);
// Fetch the SPA shell from the external origin.
// SPA routers serve index.html for all routes.
const shell = await fetch(`${env.SPA_ORIGIN}/index.html`);
if (!shell.ok) {
return new Response("Origin returned an error", { status: 502 });
}
// 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);
},
};
async function fetchBootstrapData(env, pathname, headers) {
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;
}
}

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.

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:

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.

TypeScript
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.

TypeScript
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.

TypeScript
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);