-
Notifications
You must be signed in to change notification settings - Fork 12.3k
Add SPA shell with bootstrap data example #28434
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
2f06fe7
Add SPA shell with bootstrap data example
ask-bonk[bot] eef93cf
[Workers] Add external-origin variant to SPA shell example
ask-bonk[bot] a9efdc8
[Workers] Address review feedback and align SPA shell example with Wo…
ask-bonk[bot] 645c983
[Workers] Add cross-links to SPA shell example from related pages
ask-bonk[bot] d038bb1
Apply suggestion from @irvinebroque
irvinebroque File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| 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. | ||
|
||
|
|
||
| ## 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 | ||
|
||
|
|
||
| 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. | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.