Single Page App (SPA) shell with bootstrap data
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:
- Static Assets — The SPA is deployed using Workers Static Assets
- 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.
Use this variant when your SPA build output is deployed as part of your Worker using 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/*"], },}name = "my-spa"main = "src/worker.ts"# Set this to today's datecompatibility_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.
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.
// 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; }}// 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: 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; }}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.
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", },}name = "my-spa-proxy"main = "src/worker.ts"# Set this to today's datecompatibility_date = "2026-02-20"compatibility_flags = [ "nodejs_compat" ]
[vars]SPA_ORIGIN = "https://my-spa.example-hosting.com"API_BASE_URL = "https://api.example.com"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.
// 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; }}// 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: Request, env: Env): Promise<Response> { 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); },} 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; }}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.
// 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:
declare global { interface Window { __BOOTSTRAP_DATA__?: unknown; }}You can chain multiple HTMLRewriter handlers to inject more than bootstrap data.
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.
new HTMLRewriter() .on("head", { element(el) { el.append(`<meta property="og:title" content="${title}" />`, { html: true, }); }, }) .transform(shell);Generate a nonce per request and inject it into both the Content-Security-Policy header and each inline <script> tag.
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;Expose feature flags or environment-specific settings to the SPA without an extra API round-trip.
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);- HTMLRewriter — Streaming HTML parser and transformer.
- Workers Static Assets — Serve static files alongside your Worker.
- Static Assets routing — Configure
run_worker_firstandnot_found_handling. - Static Assets binding — Reference for the
ASSETSbinding and routing options. - Custom Domains — Attach a Worker to a domain as the origin.
- Routes — Run a Worker in front of an existing origin server.
- Workers Best Practices — Code patterns and configuration guidance for Workers.