いまの 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 は「loader→Route.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(エントリー作成)まわりで「どこに処理を書き、どう呼ぶか」の書き方の差がいちばん分かりやすく出ました。