概要
Cloudflare + TanStack Startを用いて、サーバーレスなフルスタックWebアプリケーションを構築しました。本記事はその実装記録です。
TanStack Startを利用することで、React Server Components(RSC)を前提とせず、SSR・loader・Server Functionを組み合わせた比較的シンプルな構成で、フロントエンドからサーバー処理までを一貫して実装・デプロイできました。
サービス概要
※ 本記事は一般向けサービスのローンチ前に執筆しています。
ざっくり説明すると、NovelAIを制作ワークフローに統合した、キャラクターIP運用向けのCMSです。
現在は私自身が運用するキャラクターIPを対象に、NovelAIを利用したコンテンツ制作から公開、有料コンテンツの宣伝までを一気通貫で行える仕組みを構築することを目的としています。
将来的には、この制作フローを同人作家や同人声優などのクリエイターにも提供し、同様のワークフローを利用できるようにすることを想定しています。
ちなみに、このプロジェクトを構築し始めた直後に、FANZAから成人向けAI作品の生成から公開までをワンストップで行う「FANZAスタジオ」が発表されました。方向性の近いサービスがほぼ同時期に出てきたので、個人的にはかなり驚きました。
参考:ITmedia NEWS「FANZA、成人向けAI創作・公開サービス『FANZAスタジオ』発表」
https://www.itmedia.co.jp/news/article/2608/21/2000000691/
技術選定
使用技術は以下です。
- TanStack Start
- React / TypeScript
- Drizzle ORM
- Cloudflare Workers
- Cloudflare D1
今回の技術選定では、継続的な機能追加に耐えられる拡張性を持たせつつ、小規模なアプリケーションに対して過剰に複雑な構成にしないことを重視しました。
TanStack Startは、React / TypeScriptでフロントエンドからサーバー処理まで一貫して実装でき、loaderにルートデータ取得やcreateServerFnによるServer Functionを利用できます。RSCを前提とせず、
React → Server Function → Database
という単純なRPC境界を保てる点を評価しました。
Next.jsも候補でしたが、今回の規模ではRSCによる細粒度なServer / Client境界や豊富なレンダリング機能までは必要とせず、より明示的で単純な構成を取れるTanStack Startを選択しました。
加えて、loaderとcreateServerFnでデータ取得とサーバー処理の責務を分ける設計が明快で、個人的に扱いやすいと感じたことも採用理由の一つです。
実行環境にはCloudflare Workersを採用し、D1などのCloudflare Developer Platformと組み合わせることで、アプリケーションとインフラの双方をシンプルに保ちながら拡張できる構成としています。
なお、本記事執筆時点でTanStack StartはRelease Candidate(RC)です。正式なv1リリース前であることを踏まえ、バージョンを固定し、アップデート時の検証コストを許容できる小規模アプリケーションであることを前提に採用しています。
Vercel / AWS Amplifyとの比較
デプロイ先としては、VercelやAWS Amplifyも候補になります。
VercelはTanStack Startの公式パートナーで、Nitroを介してデプロイできます。Git連携やPreview Deployを含めた開発体験は非常に優れており、単純にTanStack Startを公開するだけであれば有力な選択肢です。
AWS AmplifyもSSRアプリケーションをホスティングでき、AWSの各サービスと組み合わせることで大規模な構成まで拡張できます。一方で、TanStack Start専用の統合ではなく、今回のような小規模なアプリケーションではAWSのサービス構成やIAMを含めて管理対象が増えやすいと感じました。
今回は、TanStack StartをCloudflare Vite Pluginから直接Workers上で実行できることに加え、D1・R2・KVなどのリソースをBindingという共通の仕組みで追加できる点を評価してCloudflareを採用しました。
特に、
TanStack Start → Workers → D1 / R2
という小さな構成から始め、必要になったCloudflareサービスだけを後から追加できる点が、今回の「拡張性は確保しつつ、初期構成を複雑にしない」という方針によく合っていました。
全体構成
全体構成は以下です。
TanStack Startのアプリケーション自体をCloudflare Workers上で動作させ、Server FunctionからD1へアクセスします。
フロントエンドとAPIを別々のアプリケーションとして構築するのではなく、TanStack Startを一つのデプロイ単位として扱います。
また、D1へのアクセスをServer Functionより内側に閉じ込めることで以下のような比較的単純なデータフローにしました。
loaderはルート単位のデータ取得、createServerFnはサーバーでのみ実行したい処理との境界として利用します。
TanStack StartをCloudflare Workersで動かす
まずTanStack Startのプロジェクトを用意します。
新規プロジェクトであればTanStack CLIからCloudflare向けの最小構成を作成できます。
npx @tanstack/cli@latest create sample-app --blank --deployment cloudflare -y
既存のTanStack StartプロジェクトへCloudflare対応を追加する場合は、CloudflareのVite PluginとWranglerを追加します。
npm install -D @cloudflare/vite-plugin wrangler
vite.config.tsへCloudflare Pluginを追加します。
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { cloudflare } from '@cloudflare/vite-plugin'
import viteReact from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
cloudflare({
viteEnvironment: {
name: 'ssr',
},
}),
tanstackStart(),
viteReact(),
],
})
次にwrangler.jsoncを作成します。
Wranglerは、Cloudflare Workersをローカルで実行・設定・デプロイするための公式CLIです。
Workers本体だけでなく、D1やR2、KVなどのCloudflareリソースとのBindingや、環境変数、互換性設定などもwrangler.jsoncから管理できます。
主に以下のような用途で利用します。
- TanStack StartをCloudflare Workersへデプロイする
- D1(DB)をWorkersへBindingする
- 開発環境・本番環境の設定を分ける
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "sample-app",
"compatibility_date": "2026-09-01",
"compatibility_flags": ["nodejs_compat"],
"main": "@tanstack/react-start/server-entry"
}
Cloudflareへログインします。
npx wrangler login
この状態で、
npm run dev
によるローカル実行と、
npx wrangler deploy
によるWorkersへのデプロイが可能になります。
ここで一度、D1などの外部リソースを追加する前にTanStack Start単体でWorkers上にデプロイできることを確認しておきます。
個人的には、最初からDBや認証を組み込むより、
TanStack Start
↓
Cloudflare Workers
までを一度成立させてから、D1などのBindingを追加していく方が問題を切り分けやすいと感じました。
Cloudflare D1を追加する
続いてデータベースとしてCloudflare D1を追加します。
D1はCloudflare WorkersからBinding経由で直接利用できるSQLite互換のServerless Databaseです。
まずデータベースを作成します。
npx wrangler d1 create sample-db
作成するとdatabase_idが発行されるため、wrangler.jsoncへD1 Bindingを追加します。
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "sample-app",
"compatibility_date": "2026-09-01",
"compatibility_flags": ["nodejs_compat"],
"main": "@tanstack/react-start/server-entry",
"d1_databases": [
{
"binding": "DB",
"database_name": "sample-db",
"database_id": "<YOUR_DATABASE_ID>", // <- これ
"migrations_dir": "drizzle",
"migrations_pattern": "drizzle/*/migration.sql"
}
]
}
bindingに指定したDBがWorkers上から利用する名前になります。
型定義もWranglerから生成できます。
npx wrangler types
CloudflareのBindingは通常の環境変数のように文字列として渡されるのではなく、Workers Runtimeからオブジェクトとして利用できます。
TanStack StartのServer Functionからは次のように参照できます。
import { env } from 'cloudflare:workers'
env.DB
この仕組みにより、D1だけでなくR2やKV、Queuesなども同じBindingの仕組みで追加できます。
Drizzle ORMを導入する
業務でアプリケーションコードの中に直接SQLを書き続けるつらさは身に染みているので、個人開発では可能な限りORMに逃げることにしています。少なくとも休日までSQL文字列とにらめっこしたくはありません。
もちろんD1を直接SQLで操作することもできますが、今回はスキーマ管理と型安全なクエリのためDrizzle ORMを利用します。
npm install drizzle-orm
npm install -D drizzle-kit
例えば、確認用に以下のテーブルを作成します。
// src/db/schema.ts
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const items = sqliteTable('items', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
})
続いてdrizzle.config.tsを作成します。
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
})
スキーマからMigrationを生成します。
npx drizzle-kit generate
Drizzleによって、以下のようなMigrationファイルが生成されます。
drizzle/
└─ xxxx_xxxxx/
└─ migration.sql
先ほどwrangler.jsoncで指定した、
"migrations_dir": "drizzle",
"migrations_pattern": "drizzle/*/migration.sql"
は、このDrizzleのMigration構造をWranglerから認識させるための設定です。
ローカルD1へMigrationを適用します。
npx wrangler d1 migrations apply sample-db --local
本番のD1へ適用する場合は、
npx wrangler d1 migrations apply sample-db --remote
とします。
これによって、
schema.ts
↓
drizzle-kit generate
↓
migration.sql
↓
Wrangler
↓
D1
という形で、TypeScript側のスキーマを起点としてDBの変更を管理できます。
Server FunctionからD1へアクセスする
ここまでで以下が接続できました。
- TanStack Start
- Cloudflare Workers
- Cloudflare D1
- Drizzle ORM
実際にServer FunctionからD1へアクセスしてみます。
import { createServerFn } from '@tanstack/react-start'
import { env } from 'cloudflare:workers'
import { drizzle } from 'drizzle-orm/d1'
import { items } from '@/db/schema'
export const getItems = createServerFn({
method: 'GET',
}).handler(async () => {
const db = drizzle(env.DB)
return db.select().from(items)
})
ここで重要なのは、React側からD1を直接扱っていないことです。
createServerFnで定義した処理は、React側から通常のTypeScript関数のように呼び出せます。
例えば以下です。
function ItemList() {
const handleClick = async () => {
const items = await getItems() // <- これ
console.log(items)
}
return (
<button onClick={handleClick}>
アイテムを取得
</button>
)
}
ただし、その処理自体はブラウザ上で実行されるわけではなく、Cloudflare Workers上で実行されます。
一見すると通常のTypeScript関数のように見えますが、createServerFn()で定義した関数はクライアントとサーバー間のRPC境界として扱われます。
ビルド時にはクライアント側の実装がRPC呼び出し用のスタブに置き換えられ、実際の処理はWorkers上のServer Functionとして実行されます。
そのため、D1へのアクセスや環境変数の参照など、サーバー側でのみ行いたい処理をServer Functionの中に閉じ込めることができます。
一見するとただ非同期関数を読んでいるだけですが、ビルド・デプロイ後は以下のように実行され、RPC境界をまたぎます。
Browser
↓
RPC
↓
createServerFn
↓
Cloudflare Workers
↓
Drizzle
↓
D1
呼び出し側では通常のTypeScriptの非同期関数のように扱える、このRPC境界の薄さは、TanStack Startを採用して特に気に入っている部分です。
loaderからServer Functionを呼び出す
一覧ページのように、画面表示時に必要なデータであればRouteのloaderから先ほどのServer Functionを呼び出せます。
import { createFileRoute } from '@tanstack/react-router'
import { getItems } from '@/server/items'
export const Route = createFileRoute('/items')({
loader: () => getItems(),
component: ItemsPage,
})
function ItemsPage() {
const items = Route.useLoaderData()
return (
<main>
<h1>Items</h1>
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</main>
)
}
今回の実装では、基本的に次のように責務を分けています。
DBアクセスや認証などのサーバー側処理はServer Functionへ寄せ、loaderは「このRouteを表示するために何が必要か」を記述する場所として扱っています。
例えば一覧画面であれば、
loader: () => getItems()
と書くだけで、初回アクセス時には以下のように解決されます。
Route
↓
loader
↓
Server Function
↓
D1
↓
SSR
↓
HTML
また、loaderはisomorphicに実行されます。つまり、初回リクエスト時にはSSRの一部としてサーバー側で実行され、クライアントナビゲーション時にはブラウザ側で実行されます。
これにより、初回表示ではSSRによるSEOや初期表示のメリットを得つつ、その後の画面遷移ではSPAのようなユーザー体験を維持できます。
Next.jsのServer Componentとは異なり、コンポーネント単位でServer / Clientを切り替えるのではなく、
UI
↓
loader / Server Function
↓
Server
という境界を基本にできるため、今回の規模ではこちらの方が実行箇所を追いやすいと感じました。
ここまでの構成
ここまで実装すると、全体は以下のようになります。
フロントエンド、SSR、Server Function、DBアクセスまでを一つのプロジェクト・一つのデプロイ単位として管理できます。
この時点では構成要素自体はかなり少ないですが、必要になればCloudflareのBindingを追加することで、
- D1 → Database
- R2 → Object Storage
- KV → Key-Value Store
- Queues → 非同期処理
といった形で段階的に機能を増やせます。
最初からそれらをすべて導入するのではなく、必要になったものだけを追加できる点も、今回の「拡張性は持たせるが、初期構成は複雑にしない」という方針と相性が良いと感じています。
まとめ
TanStack Start + Cloudflare Workers + D1を用いることで、フロントエンドからSSR、Server Function、DBアクセスまでを一つのプロジェクト・一つのデプロイ単位としてまとめることができました。
特に、loaderによるルート単位のデータ取得と、createServerFnによるRPC境界の分離が明快で、React / TypeScriptを中心に比較的シンプルなフルスタック構成を維持できる点は使いやすく感じました。
Cloudflare側も、Workersを中心にD1やR2などをBindingとして追加できるため、初期構成を小さく保ちながら、必要に応じて機能を拡張しやすいです。
一方で、本記事執筆時点ではTanStack StartはRCであり、周辺ライブラリを含めて仕様変更や情報の陳腐化には注意が必要です。
それでも、小規模なWebアプリケーションで「Reactを使って素早く開発したい」「SSRは欲しいがRSCの複雑さまでは必要ない」「インフラ運用もできるだけ薄くしたい」といった要件には、かなり相性の良い構成だと感じました。