← Blog
TanStack StartCloudflareViteDeploy

TanStack Start × Cloudflare Pages — 詰まった全記録と解決策

18 分で読める

このトピックを深掘りする

TanStack Start × Cloudflare

TanStack StartとCloudflare Pagesを組み合わせたフルスタック構成・Tailwind v4移行

全4本中 2 本目。

このトピックの記事一覧

シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →

はじめに

このサイトは 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 だ。

核心は cloudflaretanstackStart よりに来ること。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'],
  },
  // ...
]

災い転じて福となす

最初は「制約を受け入れた妥協案」だと思っていた。しかし実際に運用してみると、これが予想以上に良い設計だった。

  1. CMS不要: コンテンツはTypeScriptファイルそのもの。CMSのAPIを叩く必要がない。ビルド時に型チェックが走るので、データの不整合がデプロイ前に検出される。

  2. 型安全: Work 型を変更すれば、その型を参照しているすべてのコンポーネントでコンパイルエラーが出る。データとUIの整合性が自動的に保証される。

  3. エッジ最適化: データがバンドルに含まれるため、Cloudflare Workersのランタイムで外部APIを叩く必要がない。Cold startが速く、レスポンスも速い。

  4. オフライン開発: ネットワークなしで完全に動作する。飛行機の中でも開発できる。

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');
            }
          })();
        \\`,
      },
    ],
    // ...
  }),
})

このスクリプトは以下のロジックで動く。

  1. localStorage から保存済みのテーマを取得
  2. テーマが light の場合、または未設定でOSがライトモードの場合、<html>light クラスを追加
  3. それ以外(ダークモード)は何もしない(デフォルトがダークなので)

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でビルドとデプロイを分離したかった。一般的なパターンはこうだ。

  1. ビルドジョブ: bun run build → ビルド成果物をartifactとしてアップロード
  2. デプロイジョブ: 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-OptionsX-Frame-Optionsframe-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を付与して警告を抑制する。