Skip to content

Commit c3399b8

Browse files
Add SPA shell with bootstrap data example (#28434)
* Add SPA shell with bootstrap data example Co-authored-by: irvinebroque <irvinebroque@users.noreply.github.com> * [Workers] Add external-origin variant to SPA shell example * [Workers] Address review feedback and align SPA shell example with Workers best practices * [Workers] Add cross-links to SPA shell example from related pages Link to the new SPA shell with bootstrap data example from the five pages where readers are most likely to benefit from discovering it: SPA routing, Static Assets binding, HTMLRewriter API reference, Static Assets overview, and security headers example. --------- Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com> Co-authored-by: irvinebroque <irvinebroque@users.noreply.github.com>
1 parent 36b1e5e commit c3399b8

File tree

6 files changed

+383
-59
lines changed

6 files changed

+383
-59
lines changed

src/content/docs/workers/examples/security-headers.mdx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
---
2-
32
summary: Set common security headers (X-XSS-Protection, X-Frame-Options,
43
X-Content-Type-Options, Permissions-Policy, Referrer-Policy,
54
Strict-Transport-Security, Content-Security-Policy).
@@ -28,6 +27,8 @@ This creates a repository in your GitHub account and deploys the application to
2827

2928
import { TabItem, Tabs } from "~/components";
3029

30+
To inject CSP nonces into inline `<script>` tags using HTMLRewriter, refer to this [CSP nonce example](/workers/examples/spa-shell/#add-csp-nonces).
31+
3132
<Tabs syncKey="workersExamples"> <TabItem label="JavaScript" icon="seti:javascript">
3233

3334
```js
@@ -336,15 +337,15 @@ async fn fetch(req: Request, _env: Env, _ctx: Context) -> Result<Response> {
336337
</TabItem> <TabItem label="Hono" icon="seti:typescript">
337338

338339
```ts
339-
import { Hono } from 'hono';
340-
import { secureHeaders } from 'hono/secure-headers';
340+
import { Hono } from "hono";
341+
import { secureHeaders } from "hono/secure-headers";
341342

342343
const app = new Hono();
343344
app.use(secureHeaders());
344345

345346
// Handle all other requests by passing through to origin
346-
app.all('*', async (c) => {
347-
return fetch(c.req.raw);
347+
app.all("*", async (c) => {
348+
return fetch(c.req.raw);
348349
});
349350

350351
export default app;
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
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

Comments
 (0)