TanStack Start × Cloudflare Pages — 詰まった全記録と解決策
このトピックを深掘りする
TanStack Start × Cloudflare
TanStack StartとCloudflare Pagesを組み合わせたフルスタック構成・Tailwind v4移行
全4本中 2 本目。
次に読む記事
Tailwind CSS v4移行で壊れたもの — @custom-variantとダークモードの実装記録
Tailwind CSS v3からv4への移行で踏んだ破壊的変更と、@custom-variantを使ったダークモード/ライトモードの実装パターン。
IndexNow × Cloudflare Pages — sitemap.xml を即時インデックス通知する運用
sitemap.xml をソースに IndexNow へ一括送信する実装と運用。Cloudflare Pages のデプロイ後に URL を確実に通知して、新規記事の発見を最短化する。
Next.jsを選ばなかった理由 — TanStack Startを選んだ判断基準
203個のインタラクティブコンポーネントを載せるポートフォリオで、Next.jsではなくTanStack Start + Cloudflare Pagesを選んだ技術選定の記録。
シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →
はじめに
このサイトは TanStack Start v1 + Cloudflare Pages + React 19 で動いている。2026年2月にリリースされたTanStack Start v1は、Viteベースのフルスタックフレームワークとしては非常に筋の良い設計だと思った。Cloudflare Pagesのエッジランタイムと組み合わせれば、グローバルに高速なSSRが手に入る。理論上は。
しかし現実は甘くない。「新しいフレームワーク × 新しいホスティング」の組み合わせは、文字通り地雷原だった。ドキュメントに書いてあることだけでは動かない。StackOverflowにも情報がない。GitHubのIssueを片っ端から読み、ソースコードを追い、試行錯誤を繰り返した。
この記事は、その過程で踏んだ5つの地雷と、それぞれの回避策を時系列で記録したものだ。同じ構成を選ぶ人が同じ穴に落ちないことを願って。
地雷1: Viteプラグインの順序
最初に踏んだ地雷がこれだ。Viteの設定ファイルを書いて bun run build を実行したら、こんなエラーが出た。
Error: server-entry not found意味不明だった。エントリーファイルは確かに存在する。パスも正しい。何度確認しても間違いはない。
3時間ほど格闘して、ようやく原因がわかった。Viteプラグインの登録順序だ。
間違い(動かない)
// vite.config.ts — NG
export default defineConfig({
plugins: [
tailwindcss(),
tsConfigPaths({ projects: ['./tsconfig.json'] }),
tanstackStart(), // ← cloudflareより先
cloudflare({ viteEnvironment: { name: 'ssr' } }),
react(),
],
})正解(動く)
// vite.config.ts — OK
import { cloudflare } from '@cloudflare/vite-plugin'
import tailwindcss from '@tailwindcss/vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import tsConfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
server: {
port: 3000,
},
plugins: [
tailwindcss(),
tsConfigPaths({ projects: ['./tsconfig.json'] }),
cloudflare({ viteEnvironment: { name: 'ssr' } }),
tanstackStart(),
react(),
],
})正しい順序は tailwindcss → tsConfigPaths → cloudflare → tanstackStart → react だ。
核心は cloudflare が tanstackStart より前に来ること。Cloudflare Viteプラグインは、SSR環境のエントリーポイントを生成する。TanStack Startプラグインは、そのエントリーポイントを参照してサーバーサイドのルーティングを構築する。順序が逆だと、TanStack Startが参照すべきエントリーポイントがまだ存在しない状態で初期化され、server-entry not found になる。
Viteのプラグインは配列の順番通りに実行される。これはViteの基本だが、「どのプラグインをどの順番で並べるか」はドキュメントのどこにも書いてなかった。TanStack StartのドキュメントにもCloudflareのドキュメントにも。
教訓: Viteプラグインの順序は「依存グラフ」で考える。下流のプラグインが上流のプラグインの出力を必要とするなら、上流を先に置く。
地雷2: ServerFnが使えない
TanStack Startの目玉機能のひとつが ServerFn(サーバー関数)だ。クライアントコンポーネントからサーバーサイドのロジックを直接呼び出せる、RPCスタイルのAPI。Next.jsのServer Actionsに相当する。
当然、これを使ってデータフェッチを実装しようとした。ローカルでは問題なく動く。bun run dev で快適に開発できた。
Cloudflare Pagesにデプロイした瞬間、すべてが壊れた。
Error: ServerFn handler not found in worker environment調べてみると、これはCloudflare PagesのSSR環境でServerFnが正しく動作しないという既知のバグだった(2026年2月時点)。TanStack StartのGitHub Issueに複数の報告が上がっていたが、明確な修正時期は示されていなかった。
回避策: 静的データ層への移行
ServerFnが使えないなら、サーバーサイドでデータを取得する仕組み自体を捨てるしかない。
全データを src/data/ ディレクトリに静的なTypeScript定数として配置する方針に切り替えた。
src/data/
├── profile.ts # プロフィール情報
├── works.ts # 作品データ
├── skills.ts # スキルセット
├── social.ts # SNSリンク
└── blog/
└── index.ts # ブログ記事一覧各ファイルはこんな形式になっている。
// src/data/works.ts
export type Work = {
slug: string
title: string
description: string
tags: string[]
// ...
}
export const works: Work[] = [
{
slug: 'fluid-simulation',
title: 'Fluid Simulation',
description: '...',
tags: ['Canvas', 'Physics'],
},
// ...
]災い転じて福となす
最初は「制約を受け入れた妥協案」だと思っていた。しかし実際に運用してみると、これが予想以上に良い設計だった。
CMS不要: コンテンツはTypeScriptファイルそのもの。CMSのAPIを叩く必要がない。ビルド時に型チェックが走るので、データの不整合がデプロイ前に検出される。
型安全:
Work型を変更すれば、その型を参照しているすべてのコンポーネントでコンパイルエラーが出る。データとUIの整合性が自動的に保証される。エッジ最適化: データがバンドルに含まれるため、Cloudflare Workersのランタイムで外部APIを叩く必要がない。Cold startが速く、レスポンスも速い。
オフライン開発: ネットワークなしで完全に動作する。飛行機の中でも開発できる。
ServerFnが使えるようになっても、この設計は変えないと思う。ポートフォリオサイトのように更新頻度が低いコンテンツには、静的データ層が最適解だ。
教訓: フレームワークの制約に直面したとき、「回避策」ではなく「別の設計パターン」として捉え直すと、より良い答えが見つかることがある。
地雷3: ダークモードのFOUC
このサイトはダークモードがデフォルトだ。ユーザーの好みは localStorage に保存し、次回訪問時に復元する。ここまではよくあるパターン。
問題は FOUC(Flash of Unstyled Content) だ。SSRでHTMLが生成される → ブラウザがHTMLをパース → CSSが適用される → JavaScriptがハイドレーション → テーマが適用される。この「CSSが適用される」と「テーマが適用される」の間に、一瞬だけ白い画面がフラッシュする。
ダークモードユーザーにとって、ページ遷移のたびに目に白い光が刺さるのは最悪の体験だ。
解決: head内のインラインスクリプト
JavaScriptのハイドレーションより前にテーマを適用する必要がある。つまり、<head> 内にインラインスクリプトを置く。
// src/routes/__root.tsx
export const Route = createRootRoute({
head: () => ({
scripts: [
{
children: \\`
(function() {
var t = localStorage.getItem('theme');
if (t === 'light' || (!t && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('light');
}
})();
\\`,
},
],
// ...
}),
})このスクリプトは以下のロジックで動く。
localStorageから保存済みのテーマを取得- テーマが
lightの場合、または未設定でOSがライトモードの場合、<html>にlightクラスを追加 - それ以外(ダークモード)は何もしない(デフォルトがダークなので)
suppressHydrationWarning
サーバー側では <html> に light クラスがない状態でHTMLが生成される。クライアント側ではインラインスクリプトにより light クラスが追加される可能性がある。この不一致はReactのハイドレーション警告を引き起こす。
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="ja" suppressHydrationWarning>
<head>
<HeadContent />
</head>
{/* ... */}
</html>
)
}suppressHydrationWarning を <html> タグに付けることで、この警告を抑制する。これはReactが公式に認めている使い方だ。テーマの切り替えのように「サーバーとクライアントで意図的に異なる出力を許容する」ケースがまさにこれに該当する。
Tailwind v4との組み合わせ
Tailwind v4では、ダークモードの制御に @custom-variant を使う。
/* src/styles/app.css */
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));dark: プレフィックスは .dark クラスの存在に依存し、light: プレフィックスは .light クラスの存在に依存する。デフォルトのスタイルをダークモード用に書き、light: で上書きする設計にすることで、クラスなし = ダークモードが成立する。
教訓: SSRとクライアントサイドの「隙間」でUIが壊れるパターンは多い。特にテーマやローカライゼーションなど、ユーザー固有の状態をSSRで扱うときは、インラインスクリプトによる早期適用が定石になる。
地雷4: wranglerにはフルビルドが必要
CI/CDパイプラインの最適化で踏んだ地雷だ。
GitHub Actionsでビルドとデプロイを分離したかった。一般的なパターンはこうだ。
- ビルドジョブ:
bun run build→ ビルド成果物をartifactとしてアップロード - デプロイジョブ: artifactをダウンロード →
wrangler pages deploy
これが動かなかった。
Error: Missing wrangler.json configuration原因
@cloudflare/vite-plugin は、ビルド時に wrangler.json を動的に生成する。この設定ファイルは、Viteプラグインのコンテキスト(エントリーポイント、環境変数、バインディングなど)に依存している。
ビルド成果物だけをartifactとして別ジョブに渡した場合、Viteプラグインのコンテキストが欠落する。wrangler.json はビルドディレクトリ内に存在するが、それが参照するファイルパスや設定値が、デプロイジョブの環境では解決できない。
解決: デプロイジョブでフルリビルド
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run build
- run: bunx wrangler pages deploy
env:
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}ビルドとデプロイを同じジョブ内で行う。artifact分離は諦める。
ビルド時間は数十秒程度なので、実用上の問題はない。しかし「ビルドとデプロイを分離する」というCI/CDのベストプラクティスが通用しない点は、知っておく必要がある。
教訓: Viteプラグインが動的に生成するファイルは、ビルドコンテキストに依存する。CI/CDでビルド成果物を分離する場合、プラグインが何を生成しているかを把握しておく必要がある。
地雷5: CSPとインラインスクリプトの共存
地雷3でFOUCを解決するためにインラインスクリプトを使った。地雷5は、そのインラインスクリプトがセキュリティヘッダーと衝突する話だ。
Cloudflare Pagesでは、public/_headers ファイルでレスポンスヘッダーを設定できる。CSP(Content Security Policy)を設定してXSS対策を施したい。
問題
CSPの script-src ディレクティブで 'self' のみを許可すると、FOUC防止のインラインスクリプトがブロックされる。かといってインラインスクリプトを外部ファイルに切り出すと、読み込みが遅れてFOUCが再発する。
さらに、Tailwind v4もインラインスタイルを使う場面がある。style-src でも 'unsafe-inline' が必要になる。
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; ...nonce方式の困難さ
本来なら、'unsafe-inline' の代わりにnonce(ワンタイムトークン)を使うべきだ。サーバーがリクエストごとにランダムなnonceを生成し、CSPヘッダーとインラインスクリプトの両方に埋め込む。
<!-- 理想的にはこうしたい -->
<script nonce="abc123">
(function() { /* FOUC prevention */ })();
</script>Content-Security-Policy: script-src 'nonce-abc123'しかしCloudflare Pagesの _headers ファイルは静的だ。リクエストごとに動的なnonceを生成してヘッダーに埋め込むことができない。Cloudflare Workersを使えば可能だが、Pages Functionsでレスポンスヘッダーを書き換える構成は、TanStack Startのルーティングと干渉するリスクがある。
トレードオフを受け入れる
最終的に、'unsafe-inline' を許容する判断をした。
# public/_headers
/*
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://m.media-amazon.com ...; font-src 'self'; connect-src 'self'; media-src 'self' blob:; frame-ancestors 'none'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()'unsafe-inline' はXSSのリスクを完全には排除できないが、他のヘッダー(X-Content-Type-Options、X-Frame-Options、frame-ancestors 'none')との多層防御で補完する。
ポートフォリオサイトという性質上、ユーザー入力を受け付ける箇所がほぼなく、XSSの攻撃面が極めて小さいことも判断材料になった。セキュリティはコンテキストで評価すべきであり、すべてのサイトに同じ基準を適用する必要はない。
教訓: セキュリティとUXはしばしばトレードオフになる。完璧を求めるよりも、リスクを正確に評価し、多層防御で補完する方が現実的だ。
現在のアーキテクチャ
5つの地雷を踏み越えた結果、以下のアーキテクチャに落ち着いた。
データ層
src/data/ に全コンテンツを静的TypeScript定数として配置。型定義がスキーマ、export const がレコード。CMSなし、API不要、ビルド時に型チェック。
レンダリング
Cloudflare PagesのエッジSSR。head() 関数でメタタグ、OGP、JSON-LDを生成。ルートごとにSEO情報を型安全に定義。
export const Route = createFileRoute('/blog/$slug')({
head: ({ params }) => {
const post = getBlogPost(params.slug)
return {
meta: [
{ title: post?.title },
{ name: 'description', content: post?.description },
{ property: 'og:title', content: post?.title },
// ...
],
}
},
})セキュリティ・キャッシュ
public/_headers で一元管理。静的アセットは immutable + 1年キャッシュ、コンテンツは適切なTTLを設定。
CI/CD
GitHub ActionsでBlacksmithランナーを使用。ビルドとデプロイは同一ジョブ内で実行。
まとめ:TanStack Start × Cloudflareの教訓
この構成で3週間ほど格闘した。振り返って言えることは3つ。
1. 新しいフレームワーク × 新しいホスティング = 地雷原
TanStack Start v1もCloudflare Viteプラグインも、単体では優れたツールだ。しかし組み合わせた瞬間、どちらのドキュメントにも書かれていないエッジケースが大量に発生する。エコシステムが成熟するまでは、このコストを受け入れる覚悟が必要だ。
2. ドキュメントに書いてないことが一番重要
Viteプラグインの順序、wranglerの動的生成、CSPとインラインスクリプトの衝突——いずれもドキュメントには載っていない。GitHubのIssue、Discord、ソースコードが本当のドキュメントになる。
3. 制約を受け入れると、シンプルな設計になる
ServerFnが使えない → 静的データ層。nonce生成が困難 → unsafe-inlineを許容。artifact分離ができない → 同一ジョブでビルド&デプロイ。制約を「問題」ではなく「設計の入力」として扱うと、結果的にシンプルで堅牢なアーキテクチャになる。
最後に、この記事自体がTypeScriptファイルとして src/data/blog/ に格納されている。ServerFnが使えないという制約から生まれた設計が、こうしてブログ記事のホスティングにも使われている。地雷が生んだアーキテクチャで、地雷の記録を配信する。なかなか味わい深い。
よくある質問
- Q. TanStack StartとCloudflare Pagesの組み合わせで最初に注意すべき点は何ですか?
- Viteプラグインの登録順序が最重要。cloudflareプラグインをtanstackStartより前に配置しないと「server-entry not found」エラーになる。
- Q. TanStack StartのServerFnはCloudflare Pagesで使えますか?
- 2026年2月時点ではSSR環境で正しく動作しない既知のバグがある。回避策として、全データをsrc/data/に静的TypeScript定数として配置する設計が有効。
- Q. SSRでダークモードのFOUC(白フラッシュ)を防ぐにはどうすればよいですか?
- head内にインラインスクリプトを配置し、ハイドレーション前にlocalStorageからテーマを読み取ってHTMLクラスを適用する。html要素にsuppressHydrationWarningを付与して警告を抑制する。