この例では、Worker と HTMLRewriter を使い、事前取得した API データをシングルページアプリケーション(SPA)シェルに注入します。Worker は HTML シェルと並行してブートストラップデータを取得し、結果をブラウザーへストリーミングします。そのため、JavaScript が動く前に SPA が必要なデータをそろえられます。
次の 2 つのバリエーションを示します。
- Static Assets — SPA を Workers Static Assets でデプロイします
- 外部オリジン — SPA を Cloudflare の外でホストし、Worker をリバースプロキシとして前面に置いてパフォーマンスを向上します
どちらのバリエーションも、同じ HTMLRewriter による注入手法と、同じクライアント側の利用パターンを使います。デプロイ形態に合うほうを選んでください。
このパターンは、React、Vue、Svelte など、どの SPA フレームワークでも使えます。フレームワーク固有のデプロイガイドは Web アプリケーション を参照してください。
SPA のビルド出力を Static Assets として Worker の一部にデプロイする場合は、このバリエーションを使います。
すべてのルートが index.html を返すように、not_found_handling を "single-page-application" に設定します。run_worker_first を使い、/assets/* 配下のハッシュ付きアセット以外のリクエストをすべて Worker 経由にします。ハッシュ付きアセットは直接配信します。
{
"name": "my-spa",
"main": "src/worker.ts",
// Set this to today's date
"compatibility_date": "2026-09-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 date
compatibility_date = "2026-09-20"
compatibility_flags = [ "nodejs_compat" ]
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "single-page-application"
run_worker_first = [ "/*", "!/assets/*" ]これらのオプションの詳細は、Static Assets のルーティング と run_worker_first リファレンス を参照してください。
Worker は API データの取得をすぐに開始し、続けて静的アセットから SPA シェルを取得します。HTMLRewriter は <head> をすぐブラウザーへストリーミングします。<body> ハンドラーが動くときに API レスポンスを待ち、シリアライズしたデータを含む <script> タグを先頭に挿入します。
API 呼び出しが失敗してもシェルは読み込まれ、SPA はクライアント側のデータ取得にフォールバックします。
// 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;
}
}HTML、CSS、JavaScript を Cloudflare の外にデプロイする場合は、このバリエーションを使います。Worker は外部オリジンから SPA シェルを取得し、HTMLRewriter でブートストラップデータを注入して、加工したレスポンスをブラウザーへストリーミングします。
SPA が Workers Static Assets にないため、assets ブロックは不要です。代わりに、外部オリジンの URL を環境変数として保存します。Worker は カスタムドメイン または Route でドメインに紐づけます。
{
"name": "my-spa-proxy",
"main": "src/worker.ts",
// Set this to today's date
"compatibility_date": "2026-09-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 date
compatibility_date = "2026-09-20"
compatibility_flags = [ "nodejs_compat" ]
[vars]
SPA_ORIGIN = "https://my-spa.example-hosting.com"
API_BASE_URL = "https://api.example.com"Worker は SPA シェルと API データを並行して取得します。SPA オリジンが応答すると、HTMLRewriter は HTML をストリーミングしながら、ブートストラップデータを <body> に注入します。静的アセット(CSS、JS、画像)は変更せず、外部オリジンへそのまま渡します。
// 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;
}
}クライアントでは、API を呼び出す前に window.__BOOTSTRAP_DATA__ を読みます。データがあればそのまま使い、なければ通常の 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} />;
}TypeScript がグローバルプロパティを認識するように、型宣言を追加します。
declare global {
interface Window {
__BOOTSTRAP_DATA__?: unknown;
}
}HTMLRewriter ハンドラーを複数つなぎ、ブートストラップデータ以外も注入できます。
リクエストパスに応じて Open Graph などの <meta> タグを注入します。フルのサーバーサイドレンダリングフレームワークなしでも、ソーシャルメディアのクローラーに正しいプレビューを渡せます。
new HTMLRewriter()
.on("head", {
element(el) {
el.append(`<meta property="og:title" content="${title}" />`, {
html: true,
});
},
})
.transform(shell);リクエストごとに nonce を生成し、Content-Security-Policy ヘッダーと各インライン <script> タグの両方に注入します。
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;機能フラグや環境固有の設定を、追加の API ラウンドトリップなしで SPA に渡せます。
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 — ストリーミング HTML パーサーとトランスフォーマーです。
- Workers Static Assets — Worker と一緒に静的ファイルを配信します。
- Static Assets のルーティング —
run_worker_firstとnot_found_handlingを設定します。 - Static Assets バインディング —
ASSETSバインディングとルーティングオプションのリファレンスです。 - カスタムドメイン — Worker をドメインのオリジンとして紐づけます。
- Routes — 既存のオリジンサーバーの前面で Worker を動かします。
- Workers のベストプラクティス — Workers 向けのコードパターンと設定の案内です。