Skip to content
Published on

TanStack Start — RSCなしで本気のフルスタックReactを、Next.jsの代替を深掘りする 2026 (日本語)

Authors

プロローグ — 「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 年に登場。クライアントでサーバー状態を扱う — useEffectfetchuseState の地獄から React を救い出した — ライブラリ。キャッシュ・再要求・楽観的更新・ミューテーション・無限スクロール・Suspense 連携まですべてを標準化した。2026 年現在で週 350 万ダウンロード超、React のデータ取得の事実上の標準。

モデルを一行で:**クエリはキーで識別されるキャッシュエントリ。**同じキーは同じデータ、違うキーは違うデータ。staleTimegcTimerefetchOnWindowFocus といったツマミでキャッシュ挙動を調整する。

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 ActionLoader + 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 ActionTanStack Start Server Fn
定義場所'use server' 関数の中createServerFn の呼び出し
直列化React 内部フォーマットJSON(明示的)
フォームの段階的拡張自動(<form action={fn}>)手動(自分でハンドラを書く)
コンポーネント依存あり(RSC とペア)なし(独立した関数)
キャッシュ無効化revalidatePath/revalidateTagqueryClient.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.cacheunstable_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.jsAstro
データ集約ダッシュボード、SaaS バックオフィスTanStack StartReact Router v7
インタラクティブアプリ(エディタ・キャンバス)TanStack StartSolidStart
マーケ + ブログのハイブリッドNext.jsAstro
Vercel ロックインを避けたいTanStack StartSvelteKit
性能最優先、新技術を許容SolidStartSvelteKit
Remix コードベースありReact Router v7TanStack Start

9. どこで勝つか — 正直な強み

9.1 型推論が本当に端まで流れる

ルートツリー内の paramssearchloaderDatacontextbeforeLoad の戻り値がすべて自動推論。これほど深く実現したフレームワークは他にない。リファクタが怖くなくなる。

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」というトークを行った。中心メッセージを要約すると:

  1. RSC は興味深い技術だ。 コンテンツサイト・ニュース・EC では本当に光る。
  2. しかし React 開発者の大多数は SaaS・社内ツール・インタラクティブアプリを作っている。 その領域では RSC の利得は小さく、コストは大きい。
  3. 型安全・データ取得・検索パラメータ といった日々の問題を RSC は解決しない。
  4. 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 キャッシュ無効化

NextStart
revalidatePath('/users')queryClient.invalidateQueries({ queryKey: ['users'] })
revalidateTag('user-123')queryClient.invalidateQueries({ queryKey: ['user', '123'] })
unstable_cacheTanStack 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. 学習経路 — どこから始めるか

  1. まず TanStack Query。 Start なしで Next や Vite-React でも十分に学べる。データ取得のメンタルモデルを身につける。
  2. TanStack Router を単独で。 Vite + React の上でルーターだけ触る。型推論の魔法を体験する。
  3. 公式チュートリアルの「Build a SaaS in TanStack Start」。約 2 時間、フルスタックの全要素が一度ずつ出てくる。
  4. 小さなサイドプロジェクトを一つ Start で。既存の Next プロジェクトは移さず、新しいもので比較しながら学ぶ。
  5. 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