土台と上物を分ける:フレームワーク・ランタイム・言語の交換可能性を前提にした設計
Published on 2026年5月1日
設計はじめに
エラーハンドリング設計のリファクタを進めていたとき、ふと別の違和感が湧いてきた。
そもそも、なぜこの api-server は Next.js に乗っているんだろう。
中身を見ると、API は完全に Hono が捌いている。Next.js は app/api/[[...route]]/route.ts で handle(app) を呼ぶだけの薄い殻でしかなく、Server Components も Server Actions も、Next.js のフルスタック機能は何ひとつ使っていない。残っているのは「Vercel にデプロイしている慣習」と「Auth0 の SDK が @auth0/nextjs-auth0 であること」の副作用くらいだ。
もう一つ、最近ずっと感じている違和感がある。Next.js 自体が年々肥大化していて、フレームワークとしての風向きも変わってきている ことだ。「迷ったら Next.js」で済ませられた数年前と比べて、それを永続する土台として扱い続けていいのか、自信が持てなくなってきている。
この 2 つの違和感が重なって、いずれフレームワーク移行を考えなければならない局面が来る前に、移行可能性を残した状態でコードを書いておく ための戦略を一度言語化しておきたくなった。掘っていくと、結局のところ問いは一つに収束する。「土台」と「上物」をどこで分けるか、という問題である。
この記事は、フレームワーク・ランタイム・言語といった層を「いつでも交換できる前提」で扱うために、自分が今持っている設計指針を言語化したメモだ。
前提:Next.js を「永続する土台」と扱いづらくなってきた
数年前までは「迷ったら Next.js」が大体の正解だった。React で SSR / SSG / API Routes / image 最適化 / file-based routing を全部面倒見てくれる、デファクトスタンダードのフレームワークだった。
ただ、ここ 1〜2 年の風向きを見ていると、その前提を素朴に置き続けるのは少し危うい気がしている。明確な「終わり」があるわけではないが、いくつかの兆候が同時に重なってきている。
1. フレームワーク自体の肥大化と複雑化
App Router、Server Components、Server Actions、Partial Prerendering、unstable_cache、"use cache"、tag-based revalidation——独自概念が毎年積み増され、挙動が不透明になっていく一方だ。
「fetch にオプションを渡しただけでなぜキャッシュされるのか」「revalidatePath がいつ反映されるのか」「Server Component と Client Component の境界で何が起きているのか」を 正確に説明できる人がチームにどれだけいるか を考えると、心許ない。
「動いているコード」と「理解しているコード」の乖離が広がるほど、デバッグ不能な不具合の温床になる。フレームワークの面倒見が良すぎて、内部の挙動がブラックボックス化していく構造を、自分は信用しきれない。
2. Vercel への過度な傾倒
Next.js は「Vercel が作っているフレームワーク」であって、両者の境界はもともと曖昧だった。最近はその傾向がさらに強まっている。
- Image / fonts / cache の最適化は Vercel デプロイ前提で最も効く
- 機能追加の方向性が Vercel の課金モデル(Edge / Functions / ISR)に強く影響を受けている
- フレームワーク非依存に書かないと「Vercel では動くが他では動かない」が起きやすい
「Next.js を選ぶ = Vercel を選ぶ」という構図が現実化してきていて、デプロイ先の自由度を確保したい場合の摩擦が大きい。フレームワーク選定が同時にホスティング選定になっていることへの居心地の悪さは、確実に増している。
3. React 標準との乖離
RSC やディレクティブ自体は React 公式仕様だ。しかし Next.js の RSC 実装には Next.js 固有の挙動(fetch 拡張、cache の自動有効化、revalidation tag、middleware の制約)が乗っていて、「React の標準を使っているつもりで、Next.js の独自仕様を使っている」 状態が日常的に起きる。
これが何を意味するかというと、「RSC を使い続けたい」と「Next.js から離れたい」は本来両立しやすいはずなのに、実際には Next.js 固有の挙動に深く依存したコードが量産されていて、抜け出せなくなるということだ。標準と独自拡張の境界が見えづらいフレームワーク は、長期で見るとロックインの温床になる。
4. 競合の成熟
数年前は「RSC を扱える実用フレームワークは Next.js だけ」だった。これが崩れてきている。
- Waku — Hono 互換、minimal RSC。フレームワークの薄さで Next.js とは逆方向
- TanStack Start — Server Functions 中心の思想。RSC は段階的に取り込み中
- React Router v7(旧 Remix) — Web 標準寄り、Vercel 非依存で動かしやすい
選択肢が増えたということは、「Next.js を続ける理由」を毎回意識的に置けるようになった ということでもある。何となく Next.js、ではなく、「この機能のためにこのフレームワークを選んでいる」と説明できる必要が出てきた。逆に言えば、説明できないなら選び直す余地があるということだ。
5. 抜けるコストが指数的に上がる構造
Next.js の API は、使えば使うほど深く絡みつく性質を持っている。next/image を 100 箇所で使っていると、剥離はもう局所修正では済まない。@auth0/nextjs-auth0 のような SDK が next/headers を内部で呼んでいれば、認証ロジック全体が Next.js 結合になる。
「いつでも抜けられる」と思っていたものが、実際は 抜ける判断が遅れるほど指数的に重くなる という構造になっている。だからこそ、依存が浅いうちに抜け道を整理しておく価値が上がる。判断を先送りにする限り、選択肢は静かに減り続ける。
繰り返すが、これは「Next.js が悪い」という話ではない。むしろ単独のフレームワークとしての完成度は今でも高い。ただ、永続する土台として扱える時代は終わりつつある、という観察だ。React と TypeScript ほどの安定性は期待しづらくなってきた、と言ってもいい。
だからこそ、この記事の出発点になっている問いはより切実になる。「土台」と「上物」をどこで分けるか。Next.js は土台の側に置けるのか、それとも上物として切り離せる構造に保つべきか。自分の答えは後者だ。
土台 = React + TypeScript、上物は交換可能
設計判断の軸として、自分はざっくり次のように層を分けている。
| 層 | 中身 | 安定度 | 交換コスト |
|---|---|---|---|
| TypeScript | 言語そのもの。型システムは後方互換で進化 | 高 | 大(言語移行) |
| React | コンポーネントモデル / hooks / RSC ディレクティブ仕様 | 高(破壊的変更は数年単位) | 大 |
| フレームワーク | Next.js / Hono / TanStack Start / Waku 等 | 低(思想ごと交代しうる) | 中〜大 |
| ランタイム | Node / Bun / Cloudflare Workers / Deno 等 | 中 | 小〜中(adapter) |
ここで自分が置いている前提は単純で、土台 2 つ(React と TypeScript)が安定する限り、フレームワークとランタイムは「差し替え」で済むようにしておく ということだ。
逆に言うと、「上物に乗っている API」を土台と勘違いしてコード全体に染み込ませると、フレームワークを変える瞬間に全部書き直しになる。いま守りたいのは、その混在を起こさないこと に尽きる。
観察:同じモノレポでも、Next.js が居る理由は違う
自分のリポジトリには Next.js が乗っているプロジェクトが 2 つある。同じ Next.js でも、居る理由がまるで違う。
api-server(Next.js は薄い殻)
- API は Hono で書いている
- Next.js との結合点は
app/api/[[...route]]/route.tsのhandle(app) from "hono/vercel"の一行だけ - ドメイン / Usecase / errorMap / repositories は完全にフレームワーク非依存
- 重い結合は
@auth0/nextjs-auth0が内部でnext/headersのcookies()を呼んでいる箇所くらい
つまり Next.js は 「Vercel にデプロイするための殻」「Auth0 SDK が要求する文脈」 として乗っているだけで、フルスタックフレームワークとしての価値はほとんど引き出せていない。
beatfolio(RSC を実運用)
- Next.js 15 App Router で、Server Components と Client Components の境界をちゃんと使っている
next/image/next/link/next/font等の Next.js 固有 API への依存もある- ここでは Next.js が居る理由がはっきりある
同じ Next.js でも、片方は殻、もう片方は本体 だ。これを一緒くたに扱うと、片方の都合でもう片方が振り回される。
Next.js 固有 API をどう扱うか
「土台 = React + TypeScript」を守るために、フレームワーク固有 API は薄いラッパで閉じる か、そもそも別の方法で実現できないか先に検討する のをルールにしている。
警戒対象(Next.js 固有)
| API | 性質 |
|---|---|
next/image / next/link / next/font / next/script |
React コンポーネントに見えるが Next.js 専用 |
next/headers(cookies() / headers()) |
Next.js 専用 |
next/cache(revalidatePath / unstable_cache 等) |
Next.js 専用 |
fetch の { next: { revalidate, tags } } 拡張 |
Next.js 専用 |
@auth0/nextjs-auth0 |
内部で next/headers を呼ぶため間接結合 |
これらは便利だからこそ、気を抜くと至る所に染み出す。だから新規実装で使うときは、1 箇所に集約して grep で居場所が分かる状態 に保つ。ドメイン層・Usecase 層・errorMap には絶対に持ち込まない。
React 標準(こちらは OK)
一方で勘違いしやすいが、"use client" / "use server" のディレクティブは React 公式仕様 であって Next.js 独自ではない。Server Components の async/await や Suspense 越しの streaming も React 標準だ。
公式リファレンス
つまり「RSC を使っている=Next.js から離れられない」ではなく、RSC 対応のバンドラとランタイムさえあれば、ディレクティブはそのまま持ち運べる。Waku のような minimal RSC フレームワークがあり得るのはこのためだ。
「これは React の標準か、Next.js の独自拡張か」を毎回区別するだけで、無自覚に上物へ依存しないコードが書ける。
api-server から Next.js を剥がすときの順序
「いつかやる」を絵に描かないために、剥離マップを段階で持っておく。軽い順に並べるとこうなる。
| ステップ | 変更内容 | 規模 |
|---|---|---|
| ① エントリ差し替え | route.ts の handle(app) を serve(app) from "@hono/node-server"(または Bun.serve / Workers の fetch)に置換 |
数行 |
| ② Next scaffold 削除 | next.config.ts, app/layout.tsx, app/page.tsx を削除。package.json から next / react / react-dom を撤去 |
数ファイル |
| ③ Middleware 置換 | src/middleware.ts + middlewares/basicAuth/ を Hono middleware (createMiddleware) に書き換え |
1 ファイル |
| ④ Auth0 SDK 差し替え | @auth0/nextjs-auth0 を撤去し、Authentication API 直叩き(cookie は hono/cookie、JWT 検証は jose 等)へ |
大 |
体感が一番劇的に変わるのは ①〜③ の段階だ。依存ツリーが縮み、cold start が速くなり、ビルド時間が短くなる。本丸の ④ は別の PR で慎重に切る。
進める順序は、Auth0 直参照を排除する前哨として IAuthSessionProvider のような interface を先に切るのがよい。実装の差し替えではなく、参照点の差し替えで先にコストを払う という考え方だ。これは「テスト戦略」のときに書いた I/O 契約の話と同じで、契約面が先に固まれば、内部実装は自由に交換できる。
ランタイムとフレームワーク候補の眺め方
api-server を Next.js から外した先の選択肢は、ランタイム軸とフレームワーク軸で別々に考える。
ランタイム軸
| 候補 | 利点 | 注意点 |
|---|---|---|
| Node.js on Vercel Functions | デプロイ先据え置き、移行容易 | Vercel の制約(タイムアウト等)は変わらず |
| Cloudflare Workers | エッジ実行、cold start 最速 | Node API 不可(Buffer 等)、DB ドライバ要確認 |
| Bun on Fly.io / Render | 起動最速、Node 互換 | デプロイ先変更が要 |
| Node.js on Lambda / Cloud Run | 既存インフラ流用 | Vercel から離れる |
beatfolio は Next.js のままで Vercel、api-server だけ別ランタイム、という分割も成立する。API contract で繋がっているだけ なので、両者が一緒のランタイムに乗っている必然性は最初から無い。
フロント側で RSC を維持する場合
beatfolio で RSC を使い続ける前提なら、Next.js を置き換える選択肢はかなり狭まる。
| 構成 | RSC 対応 | beatfolio 移行コスト |
|---|---|---|
| Next.js 据え置き | ◎ | 0 |
| Waku(Hono 互換、minimal RSC) | ◎ | 中 |
| TanStack Start | △(RSC は開発中、現状は Server Functions モデル) | 大 |
| 素の React + Hono | ✗(CSR 化) | コードは持ち運べるが SSR/Streaming が消える |
ここで一番大きい分岐は 「RSC を捨てるか維持するか」 で、これは api-server の判断とは独立に決まる。土台と上物を分けると、こういう判断が「片方ずつ」できるようになる。
言語横断移行を視野に入れる
ここから先はもう少し未来寄りの話で、「いずれ AI で別言語に移植する」も視野に入れたとき、このリポジトリは構造上それなりに親和性が高い状態になっていると考えている。
親和性が高い理由
| 要因 | 効くポイント |
|---|---|
| 関数単位の I/O テスト | 入出力で挙動を縛っているので、実装言語が変わっても assertion の意味は不変 |
toStrictEqual での厳密比較 |
「型と値の両方が一致」という言語非依存な契約 |
| DDD のレイヤ分割 | translation 単位が狭い(VO / Entity / Usecase / Repository 単位で移植可能) |
| 純粋関数前提 | 副作用混入が無く「テスト緑 = 動く」が成立しやすい |
| Repository interface パターン | infra 層だけ別言語で書き換えても上位は無変更 |
| 型駆動エラー設計 | discriminated union → Go の sealed interface / Rust の enum に素直に写る |
「テスト戦略」で書いた I/O 契約を守って内部実装を自由にする という方針は、結局のところ「言語を変える」という究極の内部実装変更にもそのまま効く。
ただし、テストでは捕まらない領域がある
ここを見落とすと「テストが緑なのに本番で違う動きをする」が起きる。
| 領域 | 理由 |
|---|---|
| 並行性モデル | Promise / event loop → goroutine / async runtime。race condition の出方が違う |
| null / undefined / optional | TS の T | null | undefined → Go zero value / Rust Option<T> のセマンティクス差 |
| Date / TimeZone / Locale | JS Date は癖が強く、他言語 datetime と挙動差が出る |
| JSON シリアライゼーション | フィールド省略、型コアース、数値精度(JS Number vs Go int64) |
| ライブラリ挙動差 | Drizzle → SQLBoiler/SeaORM、zod → validator/pydantic でエッジケースが違う |
| トランザクション境界 | txRunner の入れ子・分離レベル・ロールバック挙動は ORM ごとに異なる |
ここから導ける運用は単純で、エッジケースのテスト密度が、そのまま移行精度になる ということだ。正常系だけのテストでは、移行先で何が壊れるか分からない。必須/任意/境界値/異常系を必ずセットで書く、というルールが「将来の自由度」に直結する。
加えて、API contract を OpenAPI / TypeSpec のような言語非依存な spec で持つ という選択肢もある。フロント実装と移行先実装の双方の「外側の契約」になり、テストが捕まえられない部分の一部を補える。
いま守ること / いつでも変えられるもの
この記事の話を一枚に圧縮すると、こうなる。
いま守ること(invariant)
- ドメイン / Usecase / errorMap / repositories を framework-agnostic に保つ
- フレームワーク固有 API への依存は、薄いラッパで閉じる
- 関数単位 I/O テストの密度を保つ(エッジケースを必ずセットで書く)
- 外部 SDK は interface 経由で呼ぶ(直叩き禁止)
いつでも変えられるもの
- ホスティング / ランタイム(Vercel / Workers / Cloud Run / Lambda 等)
- API フレームワーク(Next.js → Hono standalone)
- 認証 SDK(
@auth0/nextjs-auth0→ 直叩き / Lucia 系 / arctic + oslo 等) - 言語実装(TypeScript → Go / Rust / Python — テスト密度に比例して安全度が決まる)
別軸の判断
- frontend (beatfolio) の RSC 維持 / 撤回は api-server の判断と切り離して論じる
まとめ
設計判断の核心は、結局のところこの一文に収束する。
土台(React + TypeScript)は固定し、上物(フレームワーク・ランタイム・SDK)はいつでも差し替えられる状態に保つ。
そのために、
- 上物への依存は 1 箇所に集約し、grep で居場所が分かるようにする
- I/O 契約(テスト)で内部実装の自由度を確保する
- 「これは React 標準か、Next.js 独自か」を毎回区別する
- 移行マップを段階で持ち、本丸(Auth0 SDK のような重い結合)を独立した PR にする
「テスト戦略」「エラーハンドリング設計」「なぜ設計にこだわるのか」で書いてきた話は、視点を変えれば全部この問いに繋がっている。
どれだけ未来の自分(あるいは別の言語実装)に対して、いまのコードを引き渡せる状態にしておけるか。
設計に投資するのは、その引き渡しコストを下げ続けるためだ。フレームワークもランタイムも言語も、いずれ何かしらの理由で交換が必要になる。そのとき慌てずに済むかどうかは、いま、土台と上物の境界をどれだけ意識して書いているか で決まる。