Skip to content

非公式本サイトは非公式の日本語ドキュメントであり、Cloudflare 公式サイトではありません。最新情報はdevelopers.cloudflare.comをご確認ください。

ブートストラップデータ付きのシングルページアプリ(SPA)シェル

HTMLRewriter を使い、Workers Static Assets から配信する SPA シェルにも、外部オリジンから取得する SPA シェルにも、ブートストラップデータを注入します。

最終更新 Markdown で表示Agent セットアップ

この例では、Worker と HTMLRewriter を使い、事前取得した API データをシングルページアプリケーション(SPA)シェルに注入します。Worker は HTML シェルと並行してブートストラップデータを取得し、結果をブラウザーへストリーミングします。そのため、JavaScript が動く前に SPA が必要なデータをそろえられます。

次の 2 つのバリエーションを示します。

  1. Static Assets — SPA を Workers Static Assets でデプロイします
  2. 外部オリジン — SPA を Cloudflare の外でホストし、Worker をリバースプロキシとして前面に置いてパフォーマンスを向上します

どちらのバリエーションも、同じ HTMLRewriter による注入手法と、同じクライアント側の利用パターンを使います。デプロイ形態に合うほうを選んでください。

このパターンは、React、Vue、Svelte など、どの SPA フレームワークでも使えます。フレームワーク固有のデプロイガイドは Web アプリケーション を参照してください。


オプション 1: Workers 上だけで構築するシングルページアプリ(SPA)

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 リファレンス を参照してください。

HTMLRewriter でブートストラップデータを注入する

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;
	}
}

オプション 2: 外部オリジンでホストする SPA

HTML、CSS、JavaScript を Cloudflare の外にデプロイする場合は、このバリエーションを使います。Worker は外部オリジンから SPA シェルを取得し、HTMLRewriter でブートストラップデータを注入して、加工したレスポンスをブラウザーへストリーミングします。

Worker を設定する

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"

HTMLRewriter でブートストラップデータを注入する

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;
	}
}

SPA で事前取得したデータを使う

クライアントでは、API を呼び出す前に window.__BOOTSTRAP_DATA__ を読みます。データがあればそのまま使い、なければ通常の fetch にフォールバックします。

src/App.tsxtsx
// 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 がグローバルプロパティを認識するように、型宣言を追加します。

global.d.tsts
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);

CSP nonce を追加する

リクエストごとに 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);

関連リソース

役に立ちましたか?