いまの React フレームワークは実質 Next.js が一強だと思いつつ、RSC 周りでセキュリティの話題(脆弱性の報告など)が出たり、TanStack Start が話題になっていたりしたので、それぞれの特徴を掴むために、改めて並べて比べてみました。

理解のために同じ小さなアプリ(一覧/詳細/投稿)を両方で作り、「何がどう違うのか」を自分の言葉で説明できる状態を目標に整理したメモです。特に「GET(一覧/詳細)」と「POST(投稿)」で、処理をどこに書いてどう呼ぶか、という観点で見比べています。

Next.js はテンプレート通りに書けば動く一方で、自分は「どこで何が動いているのか」「(特に書き込み系で)どこに処理を置くのが安全なのか」の感覚が掴みづらいところがありました。なのでこのメモでは、GET/POST の“書き場所と呼び出し方”に絞って整理しています。

TanStack 版は TanStack Start + Vite + React + TypeScript 構成で、Contentful 呼び出しは createServerFn(サーバー関数)に寄せています。ここでは「書き場所 / 呼び出し方」の違いに絞り、挙動の細部は扱いません。

先に結論(要点)

  • GET(一覧/詳細): Next.js は「ページで await」。TanStack Start は「loaderRoute.useLoaderData()」。
  • POST(投稿): Next.js は Route Handler/Server Actions。TanStack Start は createServerFn

TanStack Start は何者か(自分の理解)

TanStack Router を中心にした React 用メタフレームワークで、RSC 前提ではなく、必要なぶんだけサーバー側の処理を組み込める印象です。

  • ルートごとに loader を書ける
  • サーバー関数(createServerFn)に処理を寄せられる

GET(一覧/詳細): 「取る場所」と「描く場所」

Next.js(App Router)

ページ関数の中でそのまま await fetchBlogs() できます。

contentful-nextjs-sample/app/page.tsx

// ブログ一覧ページ
import { ArticleCard } from '@/components/ArticleCard'
import { fetchBlogs } from '@/lib/contentful'

export const dynamic = 'force-dynamic'

export default async function HomePage() {
  try {
    const blogs = await fetchBlogs()
    // ...(空表示/一覧表示)
  } catch (error) {
    // ...(エラー表示)
  }
}

一方で、「これは常にサーバーで動くのか?」「キャッシュはどこで効くのか?」が、ファイル単体だと追いにくいと感じました。

TanStack Start

ルート定義に loader があり、そこでサーバー関数(createServerFn)を呼びます。描画側は Route.useLoaderData() で受け取ります。

contentful-tanstack-sample/src/routes/index.tsx

import { createFileRoute } from '@tanstack/react-router'
import { ArticleCard } from '@/components/ArticleCard'
import { listBlogs } from '@/lib/contentful.server'

export const Route = createFileRoute('/')({
  loader: async () => await listBlogs(),
  component: ArticleList
})

function ArticleList() {
  const data = Route.useLoaderData()
  // ...(空表示/一覧表示)
}

「どこで取って(loader)、どこで使うか(component)」が揃っているのが分かりやすかったです。
この分離があるので、GET のデータの流れは追いやすい印象でした。

ルーティング: 「フォルダ」か「ルート定義」か

  • Next.js: contentful-nextjs-sample/app/blog/[id]/page.tsx のようにフォルダがそのままルート
  • TanStack: contentful-tanstack-sample/src/routes/blog/$id.tsx のようにコードで宣言し、loader とセットで追える

contentful-tanstack-sample/src/routes/blog/$id.tsx

import { Link, createFileRoute } from '@tanstack/react-router'
import { marked } from 'marked'
import { getBlogById } from '@/lib/contentful.server'

export const Route = createFileRoute('/blog/$id')({
  loader: async ({ params }) => await getBlogById({ data: { id: params.id } }),
  component: ArticleDetail
})

環境変数と「秘密情報の置き場所」

Next.js

サーバー側で process.env を読めるので、CMA トークン(書き込み権限)はサーバー側に置けます。

TanStack Start

サーバー関数側で process.env を読めるので、CDA/CMA トークンはサーバーに置いたままにできます。

UI コンポーネントの差

UI コンポーネント自体は同じにしていて、主な差はリンクの実装(next/link vs @tanstack/react-router の “)くらいでした。

POST(エントリー作成): どこで処理するか

Next.js: クライアント → Route Handler → Contentful CMA

投稿フォーム(クライアント)は /api/blog に POST して、サーバー側(Route Handler)が Contentful CMA を呼びます。

contentful-nextjs-sample/app/new/page.tsx

// client
const res = await fetch('/api/blog', { method: 'POST', body: JSON.stringify({ title, body }) })
const data = await res.json()
router.push(`/blog/${data.id}`)
router.refresh()

contentful-nextjs-sample/app/api/blog/route.ts

// CMA で記事を作成・公開する API ルート
import { revalidatePath } from 'next/cache'
import { NextResponse } from 'next/server'
import { createAndPublishBlog } from '@/lib/contentful'

export async function POST(request: Request) {
  try {
    const { title, body } = await request.json()
    // ...(入力チェック)
    const blog = await createAndPublishBlog({ title: title.trim(), body: typeof body === 'string' ? body : '' })

    revalidatePath('/')
    revalidatePath(`/blog/${blog.id}`)

    return NextResponse.json(blog)
  } catch (error) {
    // ...(エラー処理)
  }
}
  • 更新反映: revalidatePath('/')revalidatePath(`/blog/${blog.id}`)

TanStack Start: クライアント → サーバー関数 → Contentful CMA

/new では、フォーム送信時に createAndPublishBlog サーバー関数を呼びます。Contentful(CMA)を呼ぶのはサーバー関数側だけです。

contentful-tanstack-sample/src/routes/new.tsx

createAndPublishBlog({ data: { title, body } })
  .then((blog) => {
    navigate({ to: `/blog/${blog.id}` })
  })
  // ...(エラー処理/ローディング)

contentful-tanstack-sample/src/lib/contentful.server.ts

export const createAndPublishBlog = createServerFn({ method: 'POST' })
  .inputValidator((input: { title: string; body: string }) => input)
  .handler(async ({ data }) => {
    ensureManagementEnv()
    const { spaceId, environmentId, cmaToken, defaultLocale } = getConfig()
    // ...(CMAで作成→publish)
    // return mapEntryToBlog(published, defaultLocale)
  })

更新は「作成成功 → 詳細へ遷移」という形にしていて、必要なら一覧側の loader が再実行されるタイミングで最新化されます。

どちらもトークンはサーバー側に置きます。違いは「どこに書いて、どう呼ぶか」です。

  • Next.js: Route Handler / Server Actions に処理を書いて process.env を読む
  • TanStack Start: createServerFn に処理を書いて process.env を読み、クライアントからは関数を呼ぶ

TanStack Start でサーバー処理を同居できる点

TanStack Start は サーバー処理をプロジェクト内に置けるため、別で API サーバーを立てずに POST をサーバー側へ寄せやすいようです。

テンプレートに乗ったときの追いやすさ

Next.js(RSC/App Router)はテンプレート通りに書けば形になりやすい反面、“暗黙の前提”を外側(知識)から補完する必要がありました。特にキャッシュや更新(revalidate)の感覚は、なかなか把握しづらい感覚がありました。
TanStack Start は loader / createServerFn など、追うための足場がコードとして残りやすい印象です。

まず触ってみるときの目線

自分は「GET の書き味」よりも、POST をどこに置くか(トークンや更新の扱い)を最初に確認すると判断しやすいと感じました。

  • 管理操作が無い(読み取り中心): Router だけでもいけそう
  • 書き込みがある(CMA/決済/管理): Start(サーバー関数)か、別の BFF/API を用意する話になりそう

まとめ

RSC の「便利さ」は強力ですが、自分にはまだ“何が勝手にやってくれているか”が追いにくい瞬間がありました。

今回の範囲だと、POST(エントリー作成)まわりで「どこに処理を書き、どう呼ぶか」の書き方の差がいちばん分かりやすく出ました。