|
| 1 | +--- |
| 2 | +summary: 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. |
| 3 | +tags: |
| 4 | + - TypeScript |
| 5 | + - SPA |
| 6 | +pcx_content_type: example |
| 7 | +title: Single Page App (SPA) shell with bootstrap data |
| 8 | +sidebar: |
| 9 | + order: 1001 |
| 10 | +description: Use HTMLRewriter to inject prefetched bootstrap data into an SPA shell, eliminating client-side data fetching on initial load. Works with Workers Static Assets or an externally hosted SPA. |
| 11 | +reviewed: 2026-02-19 |
| 12 | +--- |
| 13 | + |
| 14 | +import { TypeScriptExample, WranglerConfig } from "~/components"; |
| 15 | + |
| 16 | +This example uses a Worker and [HTMLRewriter](/workers/runtime-apis/html-rewriter/) 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. |
| 17 | + |
| 18 | +Two variants are shown: |
| 19 | + |
| 20 | +1. **Static Assets** — The SPA is deployed using [Workers Static Assets](/workers/static-assets/) |
| 21 | +2. **External origin** — The SPA is hosted outside Cloudflare, and the Worker sits in front of it as a reverse proxy, improving performance |
| 22 | + |
| 23 | +Both variants use the same HTMLRewriter injection technique and the same client-side consumption pattern. Choose the one that matches your deployment. |
| 24 | + |
| 25 | +This pattern works with any SPA framework — React, Vue, Svelte, or others. For framework-specific deployment guides, refer to [Web applications](/workers/framework-guides/web-apps/). |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +## Option 1: Single Page App (SPA) built entirely on Workers |
| 30 | + |
| 31 | +Use this variant when your SPA build output is deployed as part of your Worker using [Static Assets](/workers/static-assets/). |
| 32 | + |
| 33 | +### Configure static assets |
| 34 | + |
| 35 | +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. |
| 36 | + |
| 37 | +<WranglerConfig> |
| 38 | + |
| 39 | +```jsonc |
| 40 | +{ |
| 41 | + "name": "my-spa", |
| 42 | + "main": "src/worker.ts", |
| 43 | + "compatibility_date": "$today", |
| 44 | + "compatibility_flags": ["nodejs_compat"], |
| 45 | + "assets": { |
| 46 | + "directory": "./dist", |
| 47 | + "binding": "ASSETS", |
| 48 | + "not_found_handling": "single-page-application", |
| 49 | + "run_worker_first": ["/*", "!/assets/*"], |
| 50 | + }, |
| 51 | +} |
| 52 | +``` |
| 53 | + |
| 54 | +</WranglerConfig> |
| 55 | + |
| 56 | +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). |
| 57 | + |
| 58 | +### Inject bootstrap data with HTMLRewriter |
| 59 | + |
| 60 | +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. |
| 61 | + |
| 62 | +If the API call fails, the shell still loads and the SPA falls back to client-side data fetching. |
| 63 | + |
| 64 | +<TypeScriptExample> |
| 65 | + |
| 66 | +```ts |
| 67 | +// Env is generated by `wrangler types` — run it whenever you change your config. |
| 68 | +// Do not manually define Env — it drifts from your actual bindings. |
| 69 | + |
| 70 | +export default { |
| 71 | + async fetch(request: Request, env: Env): Promise<Response> { |
| 72 | + const url = new URL(request.url); |
| 73 | + |
| 74 | + // Serve root-level static files (favicon.ico, robots.txt) directly. |
| 75 | + // Hashed assets under /assets/* skip the Worker entirely via run_worker_first. |
| 76 | + if (url.pathname.match(/\.\w+$/) && !url.pathname.endsWith(".html")) { |
| 77 | + return env.ASSETS.fetch(request); |
| 78 | + } |
| 79 | + |
| 80 | + // Start fetching bootstrap data immediately — do not await yet. |
| 81 | + const dataPromise = fetchBootstrapData(env, url.pathname, request.headers); |
| 82 | + |
| 83 | + // Fetch the SPA shell from static assets (co-located, sub-millisecond). |
| 84 | + const shell = await env.ASSETS.fetch( |
| 85 | + new Request(new URL("/index.html", request.url)), |
| 86 | + ); |
| 87 | + |
| 88 | + // Use HTMLRewriter to stream the shell and inject data into <body>. |
| 89 | + return new HTMLRewriter() |
| 90 | + .on("body", { |
| 91 | + async element(el) { |
| 92 | + const data = await dataPromise; |
| 93 | + if (data) { |
| 94 | + el.prepend( |
| 95 | + `<script>window.__BOOTSTRAP_DATA__=${JSON.stringify(data)}</script>`, |
| 96 | + { html: true }, |
| 97 | + ); |
| 98 | + } |
| 99 | + }, |
| 100 | + }) |
| 101 | + .transform(shell); |
| 102 | + }, |
| 103 | +} satisfies ExportedHandler<Env>; |
| 104 | + |
| 105 | +async function fetchBootstrapData( |
| 106 | + env: Env, |
| 107 | + pathname: string, |
| 108 | + headers: Headers, |
| 109 | +): Promise<unknown | null> { |
| 110 | + try { |
| 111 | + const res = await fetch(`${env.API_BASE_URL}/api/bootstrap`, { |
| 112 | + headers: { |
| 113 | + Cookie: headers.get("Cookie") || "", |
| 114 | + "X-Request-Path": pathname, |
| 115 | + }, |
| 116 | + }); |
| 117 | + if (!res.ok) return null; |
| 118 | + return await res.json(); |
| 119 | + } catch { |
| 120 | + // If the API is down, the shell still loads and the SPA |
| 121 | + // falls back to client-side data fetching. |
| 122 | + return null; |
| 123 | + } |
| 124 | +} |
| 125 | +``` |
| 126 | + |
| 127 | +</TypeScriptExample> |
| 128 | + |
| 129 | +--- |
| 130 | + |
| 131 | +## Option 2: SPA hosted on an external origin |
| 132 | + |
| 133 | +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. |
| 134 | + |
| 135 | +### Configure the Worker |
| 136 | + |
| 137 | +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](/workers/configuration/routing/custom-domains/) or a [Route](/workers/configuration/routing/routes/). |
| 138 | + |
| 139 | +<WranglerConfig> |
| 140 | + |
| 141 | +```jsonc |
| 142 | +{ |
| 143 | + "name": "my-spa-proxy", |
| 144 | + "main": "src/worker.ts", |
| 145 | + "compatibility_date": "$today", |
| 146 | + "compatibility_flags": ["nodejs_compat"], |
| 147 | + "vars": { |
| 148 | + "SPA_ORIGIN": "https://my-spa.example-hosting.com", |
| 149 | + "API_BASE_URL": "https://api.example.com", |
| 150 | + }, |
| 151 | +} |
| 152 | +``` |
| 153 | + |
| 154 | +</WranglerConfig> |
| 155 | + |
| 156 | +### Inject bootstrap data with HTMLRewriter |
| 157 | + |
| 158 | +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. |
| 159 | + |
| 160 | +<TypeScriptExample> |
| 161 | + |
| 162 | +```ts |
| 163 | +// Env is generated by `wrangler types` — run it whenever you change your config. |
| 164 | +// Do not manually define Env — it drifts from your actual bindings. |
| 165 | + |
| 166 | +export default { |
| 167 | + async fetch(request: Request, env: Env): Promise<Response> { |
| 168 | + const url = new URL(request.url); |
| 169 | + |
| 170 | + // Pass static asset requests through to the external origin unmodified. |
| 171 | + if (url.pathname.match(/\.\w+$/) && !url.pathname.endsWith(".html")) { |
| 172 | + return fetch(new Request(`${env.SPA_ORIGIN}${url.pathname}`, request)); |
| 173 | + } |
| 174 | + |
| 175 | + // Start fetching bootstrap data immediately — do not await yet. |
| 176 | + const dataPromise = fetchBootstrapData(env, url.pathname, request.headers); |
| 177 | + |
| 178 | + // Fetch the SPA shell from the external origin. |
| 179 | + // SPA routers serve index.html for all routes. |
| 180 | + const shell = await fetch(`${env.SPA_ORIGIN}/index.html`); |
| 181 | + |
| 182 | + if (!shell.ok) { |
| 183 | + return new Response("Origin returned an error", { status: 502 }); |
| 184 | + } |
| 185 | + |
| 186 | + // Use HTMLRewriter to stream the shell and inject data into <body>. |
| 187 | + return new HTMLRewriter() |
| 188 | + .on("body", { |
| 189 | + async element(el) { |
| 190 | + const data = await dataPromise; |
| 191 | + if (data) { |
| 192 | + el.prepend( |
| 193 | + `<script>window.__BOOTSTRAP_DATA__=${JSON.stringify(data)}</script>`, |
| 194 | + { html: true }, |
| 195 | + ); |
| 196 | + } |
| 197 | + }, |
| 198 | + }) |
| 199 | + .transform(shell); |
| 200 | + }, |
| 201 | +} satisfies ExportedHandler<Env>; |
| 202 | + |
| 203 | +async function fetchBootstrapData( |
| 204 | + env: Env, |
| 205 | + pathname: string, |
| 206 | + headers: Headers, |
| 207 | +): Promise<unknown | null> { |
| 208 | + try { |
| 209 | + const res = await fetch(`${env.API_BASE_URL}/api/bootstrap`, { |
| 210 | + headers: { |
| 211 | + Cookie: headers.get("Cookie") || "", |
| 212 | + "X-Request-Path": pathname, |
| 213 | + }, |
| 214 | + }); |
| 215 | + if (!res.ok) return null; |
| 216 | + return await res.json(); |
| 217 | + } catch { |
| 218 | + // If the API is down, the shell still loads and the SPA |
| 219 | + // falls back to client-side data fetching. |
| 220 | + return null; |
| 221 | + } |
| 222 | +} |
| 223 | +``` |
| 224 | + |
| 225 | +</TypeScriptExample> |
| 226 | + |
| 227 | +## Consume prefetched data in your SPA |
| 228 | + |
| 229 | +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. |
| 230 | + |
| 231 | +```tsx title="src/App.tsx" |
| 232 | +// React example — works the same way in Vue, Svelte, or any other framework. |
| 233 | +import { useEffect, useState } from "react"; |
| 234 | + |
| 235 | +function App() { |
| 236 | + const [data, setData] = useState(window.__BOOTSTRAP_DATA__ || null); |
| 237 | + const [loading, setLoading] = useState(!data); |
| 238 | + |
| 239 | + useEffect(() => { |
| 240 | + if (data) return; // Already have prefetched data — skip the API call. |
| 241 | + |
| 242 | + fetch("/api/bootstrap") |
| 243 | + .then((res) => res.json()) |
| 244 | + .then((result) => { |
| 245 | + setData(result); |
| 246 | + setLoading(false); |
| 247 | + }); |
| 248 | + }, []); |
| 249 | + |
| 250 | + if (loading) return <LoadingSpinner />; |
| 251 | + return <Dashboard data={data} />; |
| 252 | +} |
| 253 | +``` |
| 254 | + |
| 255 | +Add a type declaration so TypeScript recognizes the global property: |
| 256 | + |
| 257 | +```ts title="global.d.ts" |
| 258 | +declare global { |
| 259 | + interface Window { |
| 260 | + __BOOTSTRAP_DATA__?: unknown; |
| 261 | + } |
| 262 | +} |
| 263 | +``` |
| 264 | + |
| 265 | +## Additional injection techniques |
| 266 | + |
| 267 | +You can chain multiple HTMLRewriter handlers to inject more than bootstrap data. |
| 268 | + |
| 269 | +### Set meta tags |
| 270 | + |
| 271 | +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. |
| 272 | + |
| 273 | +```ts |
| 274 | +new HTMLRewriter() |
| 275 | + .on("head", { |
| 276 | + element(el) { |
| 277 | + el.append(`<meta property="og:title" content="${title}" />`, { |
| 278 | + html: true, |
| 279 | + }); |
| 280 | + }, |
| 281 | + }) |
| 282 | + .transform(shell); |
| 283 | +``` |
| 284 | + |
| 285 | +### Add CSP nonces |
| 286 | + |
| 287 | +Generate a nonce per request and inject it into both the Content-Security-Policy header and each inline `<script>` tag. |
| 288 | + |
| 289 | +```ts |
| 290 | +const nonce = crypto.randomUUID(); |
| 291 | + |
| 292 | +const response = new HTMLRewriter() |
| 293 | + .on("script", { |
| 294 | + element(el) { |
| 295 | + el.setAttribute("nonce", nonce); |
| 296 | + }, |
| 297 | + }) |
| 298 | + .transform(shell); |
| 299 | + |
| 300 | +response.headers.set( |
| 301 | + "Content-Security-Policy", |
| 302 | + `script-src 'nonce-${nonce}' 'strict-dynamic';`, |
| 303 | +); |
| 304 | + |
| 305 | +return response; |
| 306 | +``` |
| 307 | + |
| 308 | +### Inject user configuration |
| 309 | + |
| 310 | +Expose feature flags or environment-specific settings to the SPA without an extra API round-trip. |
| 311 | + |
| 312 | +```ts |
| 313 | +new HTMLRewriter() |
| 314 | + .on("body", { |
| 315 | + element(el) { |
| 316 | + el.prepend( |
| 317 | + `<script>window.__APP_CONFIG__=${JSON.stringify({ |
| 318 | + apiBase: env.API_BASE_URL, |
| 319 | + featureFlags: { darkMode: true }, |
| 320 | + })}</script>`, |
| 321 | + { html: true }, |
| 322 | + ); |
| 323 | + }, |
| 324 | + }) |
| 325 | + .transform(shell); |
| 326 | +``` |
| 327 | + |
| 328 | +## Related resources |
| 329 | + |
| 330 | +- [HTMLRewriter](/workers/runtime-apis/html-rewriter/) — Streaming HTML parser and transformer. |
| 331 | +- [Workers Static Assets](/workers/static-assets/) — Serve static files alongside your Worker. |
| 332 | +- [Static Assets routing](/workers/static-assets/routing/) — Configure `run_worker_first` and `not_found_handling`. |
| 333 | +- [Static Assets binding](/workers/static-assets/binding/) — Reference for the `ASSETS` binding and routing options. |
| 334 | +- [Custom Domains](/workers/configuration/routing/custom-domains/) — Attach a Worker to a domain as the origin. |
| 335 | +- [Routes](/workers/configuration/routing/routes/) — Run a Worker in front of an existing origin server. |
| 336 | +- [Workers Best Practices](/workers/best-practices/workers-best-practices/) — Code patterns and configuration guidance for Workers. |
0 commit comments