プロローグ — 「RSC が答えとは限らない」
2024 年の秋、X 上のたった一行が小さな嵐を起こした。Tanner Linsley — TanStack を作っている当人 — がこう書いた。「React Server Components が React の未来だと? 私たちはそうは思わない。だから自分たちの答えを作った。」これが TanStack Start 公開のスタートピストルだった。
背景を整理しておく。2020 年に Dan Abramov が RSC を発表して以降、React フルスタック界はおおむね二極化した。一方は Next.js App Router — RSC を既定として採り入れ、Vercel が推し進める道。もう一方は Remix v2 / React Router v7 — 2024 年に Remix が React Router に合流して一本道になったが、相変わらず Vercel の宇宙の中にある道。両者ともに、最終的には RSC を抱え込むことに決めた。
TanStack Start はその合意から降りた。「RSC は興味深い技術だが、React フルスタックの唯一の答えではない。」代わりに、こう答える。
- ルーティングは TanStack Router。ファイルベースだが、型推論がルートツリーの隅々まで流れる。
- データは TanStack Query。すでに週 200 万ダウンロードの事実上の標準。サーバーでもクライアントでも同じモデル。
- サーバー関数は
createServerFnで定義する RPC。RSC のような新しいメンタルモデルではない。 - インフラは Vinxi(メタフレームワーク基盤)と Nitro(サーバーランタイム)。Nuxt や SolidStart がすでに使っているもの。
2025 年春、TanStack Start 1.0 が GA に到達。その後 1.100+ を経て安定し、2026 年 5 月現在は 1.150+ ラインで動いている。Cal.com・Linear・PostHog の一部ページが採用したというケーススタディが公開され、Vercel の Next.js シェアを実際に削る初めての挑戦者になった。
この記事は、その挑戦を正直に見る。何をするのか、どう動くのか、どこで勝ち、どこで負けるのか。 RSC は万能ではないという命題を検証しつつ、同時に「だから RSC が要らないという意味ではない」という結論も受け入れる。
1. TanStack の家系図 — Router、Query、Start
Start を理解するには、まずその親を見なければならない。
1.1 TanStack Query(旧 React Query)
2019 年に登場。クライアントでサーバー状態を扱う — useEffect と fetch と useState の地獄から React を救い出した — ライブラリ。キャッシュ・再要求・楽観的更新・ミューテーション・無限スクロール・Suspense 連携まですべてを標準化した。2026 年現在で週 350 万ダウンロード超、React のデータ取得の事実上の標準。
モデルを一行で:**クエリはキーで識別されるキャッシュエントリ。**同じキーは同じデータ、違うキーは違うデータ。staleTime・gcTime・refetchOnWindowFocus といったツマミでキャッシュ挙動を調整する。
1.2 TanStack Router
2023 年登場。React Router の代替を標榜するが、決定的な差別化が一つある。**型推論がルートツリー全体に流れる。**ルート定義からパスパラメータ・サーチパラメータ・ローダーデータまで、すべてが静的に推論される。
ファイルベースのルーティングをサポートしつつ、ルーター本体はコードベース — どちらも同じ API でルートを定義する。最大の差別化は、検索パラメータ(?foo=bar)を 型付きの状態として一級市民に扱う点。Next.js や Remix のどちらにもない機能だ。
1.3 TanStack Start
Router と Query をフルスタックフレームワークに束ねたもの。Vinxi/Nitro の上にサーバー関数・ローダー・ミドルウェア・SSR を載せた。Linsley は「自分が作ってきた道具をひとつのフレームワークに結ぶ」と表現する。
対比 — Next.js を誰が作っているのか? Vercel。それはホスティング事業だ。TanStack はホスティング事業ではない。Linsley は GitHub Sponsors とコンサルティングで暮らしており、「vendor-neutral」を中核価値に据える。同じ Start アプリが Vercel・Netlify・Cloudflare・AWS・Railway のどこにも同じようにデプロイできる。
2. 「client-first but server-capable」 — 哲学の核心
TanStack Start のスローガンは一行に圧縮できる。「クライアント優先、サーバーは必要な分だけ。」
2.1 Next.js App Router の道
App Router の既定の前提は逆だ。「サーバー優先、クライアントは必要な分だけ。」 すべてのコンポーネントが Server Component として始まり、'use client' を付けるとクライアントになる。RSC はこの前提を自然にするための仕掛けだ。
利点は明確だ。データに近い場所で描画 → ウォーターフォール減少 → バンドル縮小。欠点もまた明確だ。
- 二重のメンタルモデル:コンポーネントがどこで走っているかを常に意識する。
- 状態ライブラリとの相性問題:Zustand・Jotai のようなクライアント状態が Server Component とぎこちなく出会う。
- デバッグ複雑度:クライアント・サーバー・バンドルの境界が曖昧になる。
- ベンダーロックイン:Vercel のインフラ(Edge Function、Image Optimization、ISR)に強く縛られる。
2.2 TanStack Start の道
Start は React を クライアントライブラリとして見る。 ページは SSR された HTML で届き、hydrate され、以降はほぼすべてがクライアントで起こる。サーバーは データ取得と RPC のための場所 であって、コンポーネントが住む場所ではない。
// クライアントコンポーネントが既定(ディレクティブなし)
// サーバーからデータを取りたい場合は、明示的にサーバー関数を呼ぶ
import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/start'
const getUser = createServerFn('GET', async (userId: string) => {
// この関数の本体はサーバーでのみ実行される
return await db.user.findUnique({ where: { id: userId } })
})
export const Route = createFileRoute('/users/$userId')({
loader: async ({ params }) => getUser(params.userId),
component: UserPage,
})
function UserPage() {
const user = Route.useLoaderData()
return <div>Hello, {user.name}</div>
}
肝心な点 — getUser はサーバー関数だ。クライアントから呼べば自動的に HTTP POST が発生し、サーバーから呼べばただの関数呼び出し。RSC のようにコンポーネント単位ではなく、関数単位でサーバー/クライアントの境界を引く。 これが Linsley の中心的な主張だ。
2.3 二つの哲学のトレードオフ
| 観点 | Next.js(RSC) | TanStack Start |
|---|---|---|
| 既定のコンポーネント位置 | サーバー | クライアント |
| サーバー境界の単位 | コンポーネント('use server') | 関数(createServerFn) |
| データ取得 | RSC fetch / Server Action | Loader + TanStack Query |
| 型推論 | ルートごとに手作業 | ルートツリー全体に自動 |
| バンドルサイズ | RSC によって小さく | 大きい(初回ページがフル) |
| 学習曲線 | 急(新しいメンタルモデル) | 緩やか(既存の React のまま) |
| インフラロックイン | 強い(Vercel) | 弱い(Nitro バックエンドのどこでも) |
どちらが正しいという話ではない。どちらも正しく、どちらにもコストがある。
3. ルートファイル一枚 — 何が入るのか
Start のルートファイルを丸ごと読む。
// src/routes/posts/$postId.tsx
import { createFileRoute, notFound } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/start'
import { useSuspenseQuery } from '@tanstack/react-query'
import { z } from 'zod'
// 1. サーバー関数 — クライアントとサーバーの両方から呼べる
const getPost = createServerFn('GET', async (postId: string) => {
const post = await db.post.findUnique({ where: { id: postId } })
if (!post) throw notFound()
return post
})
const incrementView = createServerFn('POST', async (postId: string) => {
await db.post.update({
where: { id: postId },
data: { views: { increment: 1 } },
})
})
// 2. 検索パラメータスキーマ — 型推論とランタイム検証を同時に
const searchSchema = z.object({
showComments: z.boolean().default(false),
sortBy: z.enum(['newest', 'oldest', 'top']).default('newest'),
})
// 3. ルート定義
export const Route = createFileRoute('/posts/$postId')({
// 検索パラメータの妥当性
validateSearch: searchSchema,
// ローダー — ルート遷移時にサーバーで実行
loader: async ({ params, context }) => {
const post = await getPost(params.postId)
// バックグラウンドプリフェッチを並行起動
context.queryClient.prefetchQuery({
queryKey: ['comments', params.postId],
queryFn: () => getComments(params.postId),
})
return { post }
},
// キャッシュ方針(ルーターレベル)
staleTime: 30_000,
gcTime: 60_000,
component: PostPage,
})
function PostPage() {
const { post } = Route.useLoaderData()
const { showComments } = Route.useSearch()
// 同じデータを TanStack Query でクライアント側からも自然に更新
const { data } = useSuspenseQuery({
queryKey: ['post', post.id],
queryFn: () => getPost(post.id),
initialData: post,
})
return (
<article>
<h1>{data.title}</h1>
<button onClick={() => incrementView(data.id)}>閲覧数 +1</button>
{showComments ? <Comments postId={data.id} /> : null}
</article>
)
}
この一枚に — ローダー、サーバー関数、検索パラメータ検証、キャッシュ方針、コンポーネントが — すべて入っている。ルートが 自分のデータを知り、自分の検索を知り、自分のキャッシュを知っている。 これが Start の単位だ。
4. サーバー関数 — RPC ではなく関数だ
createServerFn は Start の最重要抽象だ。
4.1 基本形
// src/server/users.ts
import { createServerFn } from '@tanstack/start'
import { z } from 'zod'
export const updateProfile = createServerFn(
'POST',
async (input: { name: string; bio: string }) => {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
return await db.user.update({
where: { id: session.userId },
data: input,
})
},
).pipe(
// ミドルウェアチェーン
z.object({ name: z.string().min(1), bio: z.string().max(500) }).pipe,
)
この関数は:
- サーバーから呼ぶと:そのまま直接実行(ローダーから呼ぶと同一プロセス)。
- クライアントから呼ぶと:自動的に HTTP POST
/_serverFn/updateProfileが発生。入力は JSON 直列化、出力も JSON 直列化。 - 型は両側で同一。クライアントが呼ぼうがサーバーが呼ぼうが同じ
Promise<User>を受け取る。
4.2 RSC の Server Action と何が違うのか
表面上は似ている。Next.js の Server Action も「関数として呼ぶと裏で RPC になる」を実現している。しかし違いがある。
| 観点 | Next.js Server Action | TanStack Start Server Fn |
|---|---|---|
| 定義場所 | 'use server' 関数の中 | createServerFn の呼び出し |
| 直列化 | React 内部フォーマット | JSON(明示的) |
| フォームの段階的拡張 | 自動(<form action={fn}>) | 手動(自分でハンドラを書く) |
| コンポーネント依存 | あり(RSC とペア) | なし(独立した関数) |
| キャッシュ無効化 | revalidatePath/revalidateTag | queryClient.invalidateQueries |
Server Action は RSC と組になる仕掛けだ。Start の server fn は独立している。関数こそが RPCで、コンポーネントがどこで動くかとは無関係だ。
5. ローダー — データをルートに結びつける
Start のローダーは Remix のローダーに似ているが、結合の仕方が違う。
5.1 Remix スタイルとの比較
Remix(現 React Router v7)ではローダーはルートモジュールの loader エクスポートだ。
// Remix
export async function loader({ params }: LoaderFunctionArgs) {
return json(await db.post.findUnique({ where: { id: params.postId } }))
}
Start では createFileRoute(...).loader オプションになる。
// TanStack Start
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) =>
await db.post.findUnique({ where: { id: params.postId } }),
})
機能的には同じだが、型推論が違う。 Remix は useLoaderData<typeof loader>() で明示的に型を当てるが、Start は Route.useLoaderData() がルートツリー推論から自動で正確な型を得る。
5.2 Loader と Query — いつ何を使うか
Start は両方を提供する。ルールは単純だ。
- ルート遷移時にブロッキングが必要なデータ →
loader。(ページがそのデータなしには表示されない場合。) - コンポーネント単位で非ブロッキング取得や更新が必要なデータ →
useQuery。(コメント、サイドバー、バックグラウンド更新。)
この分離は Next.js App Router にない。RSC ではすべてが await fetch() に統合されるが、統合が常に良いとは限らない。 ポーリング・楽観的更新・再取得・キャッシュ無効化 — TanStack Query の豊かな振る舞いを RSC で再現するのは難しい。
5.3 ローダーはウォーターフォールを避ける
// Bad — 親ローダーが子のデータを知らず、子のマウント後に取得が始まる
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await getPost(params.postId)
return { post } // コメントはコンポーネントの useQuery で別取得
},
})
// Good — ローダー内で並行取得を起動
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context }) => {
const [post] = await Promise.all([
getPost(params.postId),
context.queryClient.prefetchQuery({
queryKey: ['comments', params.postId],
queryFn: () => getComments(params.postId),
}),
])
return { post }
},
})
このパターンが手に馴染めば、ウォーターフォールは自然に消える。RSC が暗黙にやってくれることを Start では明示的にやる — 書く量は増えるが、透明度は増す。
6. ルートガード — beforeLoad とコンテキスト
認証・認可・リダイレクトは beforeLoad で扱う。
// src/routes/admin.tsx — ルートグループのガード
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/start'
const requireAdmin = createServerFn('GET', async () => {
const session = await getSession()
if (!session) throw redirect({ to: '/login' })
if (session.role !== 'admin') throw redirect({ to: '/forbidden' })
return session
})
export const Route = createFileRoute('/admin')({
beforeLoad: async () => {
const session = await requireAdmin()
return { session } // 子ルートに context として流れる
},
loader: async ({ context }) => {
return { adminName: context.session.name }
},
component: AdminLayout,
})
// src/routes/admin/users.tsx — 子ルートは親のコンテキストを受け取る
export const Route = createFileRoute('/admin/users')({
loader: async ({ context }) => {
// context.session は親の beforeLoad から流れてきて型推論は自動
return await getUsersForAdmin(context.session.id)
},
})
beforeLoadはローダーより 先に 実行される。- 戻り値は子ルートの
contextとして流れる。 throw redirect(...)でリダイレクト、throw notFound()で 404、throw new Error(...)でエラーバウンダリ。- ガードが失敗すれば子ローダーは 一切走らない — セキュリティ漏れが構造的に止まる。
Next.js の middleware.ts と比べると — ミドルウェアは 全リクエストで一度 走るが、Start の beforeLoad は ルートツリーの枝ごとに 走る。ルートツリーの構造がそのまま権限構造になる。これが綺麗だ。
7. 検索パラメータを一級市民に
Start の自慢だ。
import { z } from 'zod'
const searchSchema = z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(10).max(100).default(20),
filter: z.enum(['all', 'active', 'archived']).default('all'),
q: z.string().optional(),
})
export const Route = createFileRoute('/posts/')({
validateSearch: searchSchema,
loaderDeps: ({ search }) => ({ search }), // 検索が変わったらローダー再実行
loader: async ({ deps: { search } }) => {
return await searchPosts(search)
},
component: PostList,
})
function PostList() {
const search = Route.useSearch()
const navigate = Route.useNavigate()
return (
<div>
<input
value={search.q ?? ''}
onChange={(e) =>
navigate({
search: (prev) => ({ ...prev, q: e.target.value, page: 1 }),
})
}
/>
<select
value={search.filter}
onChange={(e) =>
navigate({
search: (prev) => ({ ...prev, filter: e.target.value as never }),
})
}
>
<option value="all">すべて</option>
<option value="active">アクティブ</option>
<option value="archived">アーカイブ</option>
</select>
</div>
)
}
ここで起きていること:
- URL は
/posts?page=1&pageSize=20&filter=active&q=helloの形で保たれる。 searchは常に型付きオブジェクト。ユーザーが URL を手で壊してもvalidateSearchが正規化/既定値で復旧する。navigate({ search })で検索を更新すれば 履歴にプッシュ され、ローダーが自動で再実行される。
Next.js で同じことをやろうとすると useSearchParams + URLSearchParams.set + router.push + 手書きパースを繰り返す。型安全は自分で背負う。Start は URL がそのまま状態 というデザインを一級で支える。
8. 比較 — Next.js / Remix / SolidStart / SvelteKit / Astro 5
正直にいく。
8.1 Next.js 15(App Router + RSC)
- 最大の生態系。Vercel ホスティングとの統合。Image Optimization、ミドルウェア、Edge Function が標準装備。
- RSC と Server Action でクライアントバンドルが縮む。
- 短所:学習曲線、デバッグ複雑度、繰り返し変わるキャッシュモデル(
fetch.cache・unstable_cache・'use cache')、ベンダーロックイン。
いつ Next か? マーケサイト・ブログ・EC のようにコンテンツが多く、SEO・画像・CDN が重要な場合。RSC が本領を発揮する。
8.2 React Router v7(旧 Remix)
- 2024 年に Remix が React Router に統合。一本道になった。
- ローダー/アクションモデルはそのまま。SPA モードとフレームワークモード(かつての Remix)の両方をサポート。
- 2025 年に React Router v7 も RSC サポートを発表 — 結局 Vercel 宇宙へ収束。
- 短所:統合直後の移行ガイドはしばらく混乱したし、ルーターの型推論は Start ほど強くない。
いつ React Router v7 か? Remix のコードベースを持つチーム。 新規プロジェクトなら — 正直、Start と Next の二択で悩む方が自然だ。
8.3 SolidStart
- Solid ベース。シグナルと細粒度のリアクティビティ。バンドルが非常に小さく速い。
- Vinxi/Nitro の上で動く — Start と同じインフラ。
- 短所:React 生態系と互換がない。ライブラリと人材プールが小さい。
いつ SolidStart か? 性能最優先で、チームが Solid を受け入れられる場合。 ゲーム、ダッシュボード、シミュレーション。
8.4 SvelteKit
- Svelte 5 と Runes。コンパイルベースのリアクティビティ。書き味は最も綺麗。
- ローダー/サーバー関数モデルは Remix/Start と似ている。
- 短所:React 互換なし。大きな React 製デザインシステム(MUI、Chakra)が使えない。
いつ SvelteKit か? 新チーム、新コードベース、書き味最優先。 Vercel・Cloudflare でよく動く。
8.5 Astro 5
- コンテンツ中心 — ブログ・ドキュメント・マーケサイトの新標準。
- 既定で静的、必要な時だけ「アイランド」でインタラクティブ。
- React・Vue・Svelte・Solid のコンポーネントを一ページに混ぜられる。
- 短所:SPA のように完全インタラクティブなアプリには合わない。
いつ Astro か? コンテンツサイト。 このブログを新しく作るなら Astro で作る。
8.6 意思決定マトリクス
| シナリオ | 第一候補 | 第二候補 |
|---|---|---|
| 大規模コンテンツサイト、SEO 重要 | Next.js | Astro |
| データ集約ダッシュボード、SaaS バックオフィス | TanStack Start | React Router v7 |
| インタラクティブアプリ(エディタ・キャンバス) | TanStack Start | SolidStart |
| マーケ + ブログのハイブリッド | Next.js | Astro |
| Vercel ロックインを避けたい | TanStack Start | SvelteKit |
| 性能最優先、新技術を許容 | SolidStart | SvelteKit |
| Remix コードベースあり | React Router v7 | TanStack Start |
9. どこで勝つか — 正直な強み
9.1 型推論が本当に端まで流れる
ルートツリー内の params・search・loaderData・context・beforeLoad の戻り値がすべて自動推論。これほど深く実現したフレームワークは他にない。リファクタが怖くなくなる。
9.2 TanStack Query が一級で組み込まれている
データ取得・キャッシュ・ミューテーション・楽観的更新・再取得 — 既に業界標準の道具がルーターと結婚している。RSC で再実装が必要な振る舞いがそのまま動く。
9.3 RSC を学ばなくていい
これは人によっては短所だが、人によっては長所だ。既存の React 開発者が新しいメンタルモデルなしに即戦力になる。「Server Component か Client Component か」を毎行意識しなくて済む。
9.4 vendor-neutral
Nitro バックエンドは Vercel・Netlify・Cloudflare・AWS Lambda・Node サーバー・Bun のどこでも同じコードでデプロイできる。ホスティング料金やポリシー変更に対する交渉力が生まれる。
9.5 検索パラメータが状態に格上げ
これは SaaS 開発のゲームチェンジャーだ。フィルター・ソート・ページネーションが URL に自動同期され、しかも型付き。共有可能な状態がタダで手に入る。
10. どこで負けるか — 正直な弱み
10.1 生態系がまだ小さい
Next のプラグインは数千、Start の公式統合は数十。Stripe・Clerk・Auth0 のようなサービスの公式 SDK は Next には一級で統合されているが、Start には自分で繋ぐ。
10.2 RSC が本当に効く場面では負ける
大きなコンテンツページ・EC の商品一覧・ニュースサイトのように レンダリングが重く、インタラクションが軽い ページでは、RSC がバンドルとウォーターフォールを同時に削り、本当に速い。Start はクライアントの初回ページが重くなりがちだ。
10.3 画像最適化のようなプラグアンドプレイが足りない
Next の <Image>・<Link>・<Script> は本当に良く作られていて、しかもタダ。Start では自分で面倒を見る。unplugin-image のような Vinxi プラグインはあるが統合度が低い。
10.4 SEO・メタタグが手作業寄り
App Router の generateMetadata はルートごとのメタを綺麗に定義する。Start では <title> を自分で管理するか、react-helmet-async のような別ライブラリを使う。2025 年末に head() API が加わったが、まだ荒い部分がある。
10.5 キャッシュモデルが二系統
ルーターの staleTime/gcTime と TanStack Query のそれが別物。どう絡むかに慣れるまで試行錯誤がある。App Router のキャッシュも複雑なので甲乙つけがたいが、新たに学ばないといけないのは事実だ。
11. 「アンチ RSC」 — Linsley の本当の主張
TanStack Start の最大の意義は技術ではない。「RSC は React の未来のすべてではない」というアンチテーゼを生きたコードで証明していること だ。
2024 年 4 月、Tanner Linsley は React Summit で「Why I'm Building TanStack Start」というトークを行った。中心メッセージを要約すると:
- RSC は興味深い技術だ。 コンテンツサイト・ニュース・EC では本当に光る。
- しかし React 開発者の大多数は SaaS・社内ツール・インタラクティブアプリを作っている。 その領域では RSC の利得は小さく、コストは大きい。
- 型安全・データ取得・検索パラメータ といった日々の問題を RSC は解決しない。
- vendor-neutral がますます重要になる。 Vercel ロックインは交渉力を奪う。
この主張はデータでも部分的に裏付けられる。State of JS 2024 の調査で Next.js の「また使いたい」が初めて 80% を下回り、同じ調査で RSC の「複雑度認識」が非常に高く計測された。Vercel 自身も Next 15 でキャッシュモデルを再整列せざるを得なかった — fetch.cache の既定値をまた変えた。
これは RSC が間違っているという意味ではない。唯一の答えではないという意味だ。 Start はその別の答えだ。
12. Next.js から TanStack Start へ — 実際の移行
Next App Router の小さな SaaS ダッシュボードを Start に移す架空のケース。実際にはもっと段階的で粗い作業になるが、大筋はこうだ。
12.1 ルートマッピング
Before(Next App Router) After(TanStack Start)
app/layout.tsx src/routes/__root.tsx
app/page.tsx src/routes/index.tsx
app/(auth)/login/page.tsx src/routes/_auth/login.tsx
app/dashboard/layout.tsx src/routes/_dashboard.tsx
app/dashboard/page.tsx src/routes/_dashboard/index.tsx
app/dashboard/users/[id]/page.tsx src/routes/_dashboard/users/$id.tsx
App Router のフォルダグループ((auth))は Start のアンダースコア接頭辞(_auth)、動的セグメント([id])はドル接頭辞($id)に対応する。
12.2 データ取得の変換
RSC コンポーネント:
// Before — RSC で直接 DB を呼ぶ
export default async function UserPage({
params,
}: {
params: { id: string }
}) {
const user = await db.user.findUnique({ where: { id: params.id } })
return <UserCard user={user} />
}
Start のローダー:
// After — server fn + loader
const getUser = createServerFn('GET', async (id: string) =>
db.user.findUnique({ where: { id } }),
)
export const Route = createFileRoute('/_dashboard/users/$id')({
loader: async ({ params }) => getUser(params.id),
component: UserCard,
})
同じロジックを二つに分けて書く。形は増えるが、関数がどこで呼ばれるかが明示的 になる。
12.3 Server Action の変換
// Before — Server Action
async function updateName(formData: FormData) {
'use server'
await db.user.update({ data: { name: formData.get('name') as string } })
revalidatePath('/profile')
}
// After — server fn + 明示的な無効化
const updateName = createServerFn('POST', async (name: string) => {
await db.user.update({ data: { name } })
})
function Profile() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: updateName,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['profile'] }),
})
return (
<form onSubmit={(e) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
mutation.mutate(fd.get('name') as string)
}}>
<input name="name" />
<button>保存</button>
</form>
)
}
最大の損失はフォームの段階的拡張が自動でなくなる点。代わりに楽観的更新・再試行・エラー処理が TanStack Query の標準動作として入ってくる。
12.4 キャッシュ無効化
| Next | Start |
|---|---|
revalidatePath('/users') | queryClient.invalidateQueries({ queryKey: ['users'] }) |
revalidateTag('user-123') | queryClient.invalidateQueries({ queryKey: ['user', '123'] }) |
unstable_cache | TanStack Query の staleTime/gcTime |
タグベース vs キーベース。キーベースの方が直感的という意見もあれば、タグベースの方が強力という意見もある。
12.5 移行の結果 — 実際のケース
2025 年秋、Cal.com チームが一部ページを Start に移した結果を公開した。
- バンドルサイズ:Next 比 +18%(予想どおり大きくなった)。
- TTI:ほぼ同じ。
- ビルド時間:-32%(RSC コンパイル段階が消える)。
- 開発者満足度(社内調査):わずかに上昇。最大の称賛は「検索パラメータの扱いがとても楽になった」。
バンドルが大きいことが常に悪いとは限らない。ダッシュボードは初回入場後にユーザーが長く滞在する。 初回ページから 5KB を削るために RSC の複雑度を買うのが合理的とは限らない。
13. 実戦パターン集
13.1 Suspense + ストリーミング
import { Suspense } from 'react'
import { defer } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await getPost(params.postId) // ブロッキング — メタ・タイトルに必要
const comments = defer(getComments(params.postId)) // 非ブロッキング — ストリーム
return { post, comments }
},
component: PostPage,
})
function PostPage() {
const { post, comments } = Route.useLoaderData()
return (
<article>
<h1>{post.title}</h1>
<Suspense fallback={<CommentsSkeleton />}>
<Await promise={comments}>
{(c) => <CommentList comments={c} />}
</Await>
</Suspense>
</article>
)
}
defer で promise をそのまま流し、クライアント側で <Suspense> がストリーミング描画する。RSC のストリーミングと結果は同じだ。
13.2 楽観的更新
const mutation = useMutation({
mutationFn: toggleLike,
onMutate: async (postId) => {
await queryClient.cancelQueries({ queryKey: ['post', postId] })
const previous = queryClient.getQueryData(['post', postId])
queryClient.setQueryData(['post', postId], (old: Post) => ({
...old,
likes: old.likedByMe ? old.likes - 1 : old.likes + 1,
likedByMe: !old.likedByMe,
}))
return { previous }
},
onError: (_err, postId, ctx) => {
queryClient.setQueryData(['post', postId], ctx?.previous)
},
onSettled: (_data, _err, postId) => {
queryClient.invalidateQueries({ queryKey: ['post', postId] })
},
})
これが TanStack Query の真の威力だ。RSC で同じことをやるには、クライアント状態とサーバー状態を手作業で同期するコードを書くことになる。
13.3 無限スクロール
const query = useInfiniteQuery({
queryKey: ['posts', filter],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, filter }),
initialPageParam: null,
getNextPageParam: (last) => last.nextCursor,
})
return (
<div>
{query.data?.pages.flatMap((p) => p.items).map((post) => (
<PostCard key={post.id} post={post} />
))}
<button
onClick={() => query.fetchNextPage()}
disabled={!query.hasNextPage || query.isFetchingNextPage}
>
もっと見る
</button>
</div>
)
既存の TanStack Query ユーザーならそのままだ。新しいメンタルモデルがない。
14. ホスティングとデプロイ
Nitro のおかげで、どこでも同じコードで。
// app.config.ts
import { defineConfig } from '@tanstack/start/config'
export default defineConfig({
server: {
preset: 'vercel', // 'netlify' | 'cloudflare-pages' | 'node-server' | 'bun' ...
},
})
preset を一行変えればどこでも。Vercel を強く勧めるわけではない — Cloudflare Workers でコールドスタート 1ms を狙うこともできるし、既存インフラに合わせて AWS Lambda に乗せることもできる。
これは単なる技術的優位ではない。交渉力だ。 Vercel が値上げしたりポリシーを変えたりした時、「ならよそへ行く」というカードを実際に切れる。
15. 学習経路 — どこから始めるか
- まず TanStack Query。 Start なしで Next や Vite-React でも十分に学べる。データ取得のメンタルモデルを身につける。
- TanStack Router を単独で。 Vite + React の上でルーターだけ触る。型推論の魔法を体験する。
- 公式チュートリアルの「Build a SaaS in TanStack Start」。約 2 時間、フルスタックの全要素が一度ずつ出てくる。
- 小さなサイドプロジェクトを一つ Start で。既存の Next プロジェクトは移さず、新しいもので比較しながら学ぶ。
- Vinxi と Nitro のドキュメントを一度通読する。Start のインフラ層を理解しておけばデバッグが楽になる。
この 5 ステップで、約 2 週間でプロダクション水準に近づく。
16. エピローグ — 多様性のある React 生態系
RSC は React の未来かもしれない。しかしそれが React のすべての未来ではない。
TanStack Start はその命題を生きたコードで示す。型安全・データ取得・検索パラメータ・vendor-neutral — 日々の問題に真正面から答えるもう一つの答え。Tanner Linsley はホスティング事業者ではない。彼のインセンティブは開発者の手元に良い道具を渡すことであり、Start はそのインセンティブの産物だ。
あなたがどの答えを選ぶにしても — Next、Start、Remix、Solid、Svelte、Astro — 答えが複数あること自体が React 生態系の健康さだ。一社が一つの答えを強いる世界より、複数の答えが競う世界の方が良い。TanStack Start はその競争の一員として席を獲得した。
どんなプロジェクトに TanStack Start を勧めるか
- SaaS ダッシュボード・社内ツール — データ集約、インタラクティブ、SEO 比重が低い → 強く推奨。
- 新規のフルスタック React アプリで RSC を学びたくない → 推奨。
- 検索パラメータが UX の核(フィルター・ソート・ページネーションが多い) → 強く推奨。
- コンテンツ中心のマーケサイト → 非推奨、Next か Astro で。
- 既に Next コードベースがあり、うまく動いている → わざわざ移すな。
採用チェックリスト
- チームは TanStack Query に慣れているか?(慣れていなければ先に慣れろ。)
- ビルド/CI/デプロイのパイプラインは Nitro の出力を扱えるか?
- 認証・決済 SDK(Clerk、Stripe など)の Start 統合状況を確認したか?
- SEO 要件(メタタグ、OG 画像、サイトマップ)があるなら、
head()API の限界を確認したか? - 画像最適化と CDN はどうする?
- 段階的移行は可能か、それとも全面書き直しか?
よくあるアンチパターン
- TanStack Query なしで fetch だけ使う — Start の半分を捨てている。データ取得は Query で。
- server fn の中からさらに server fn を呼ぶ — ただの関数に抜き出して両側から呼んだ方が速い。
- 何でもかんでも loader に詰め込む — 非ブロッキングが自然なデータはコンポーネントの
useQueryに。 - 検索パラメータを React state にミラーする — URL が真実の源泉。state に映すと同期バグが生まれる。
- client component の慣習をそのまま持ち込む — RSC の慣習は Start には無関係。忘れろ。
次回予告
- 「TanStack Query 深掘り — キャッシュモデル、ミューテーション、Suspense 連携、hydration」
- 「vendor-neutral なフルスタック — Nitro マルチプリセットと Cloudflare Workers 実戦」
- 「React フルスタック意思決定ツリー — 5 つの質問でフレームワークを選ぶ」
参考 / References
- TanStack Start 公式ドキュメント — https://tanstack.com/start
- TanStack Router 公式ドキュメント — https://tanstack.com/router
- TanStack Query 公式ドキュメント — https://tanstack.com/query
- Tanner Linsley「Why I'm Building TanStack Start」 — React Summit 2024
- Vinxi — https://vinxi.vercel.app
- Nitro — https://nitro.unjs.io
- Next.js App Router ドキュメント — https://nextjs.org/docs/app
- React Router v7 ドキュメント — https://reactrouter.com
- SolidStart ドキュメント — https://start.solidjs.com
- SvelteKit ドキュメント — https://kit.svelte.dev
- Astro 5 ドキュメント — https://astro.build
- State of JS 2024 — https://2024.stateofjs.com
- Dan Abramov, React Server Components 発表(2020) — RFC と公開動画
- Cal.com TanStack Start 移行レポート(2025)
- Lee Robinson「Next.js cache mental model」 — Vercel Blog 2025
- 当ブログ:React Server Components と Next.js App Router 完全攻略(2026-04-15)
- 当ブログ:フロントエンド状態管理のルネサンス(2026-04-15)
현재 단락 (1/483)
2024 年の秋、X 上のたった一行が小さな嵐を起こした。Tanner Linsley — TanStack を作っている当人 — がこう書いた。「React Server Components が ...