現場の課題:壊れたAPIコントラクトがもたらす代償
複数通貨対応のチェックアウト機能改修を進めていたあるスプリントで、私たちのチームは典型的な本番障害に直面しました。バックエンドエンジニアがレスポンスフィールドの名前を userId から accountId へと変更したのです。ユニットテストはパスし、結合テストもすべてグリーン。CI/CDパイプラインもエラーを出すことなくデプロイを完了しました。
しかしその直後、Sentryが警告を上げ始めました。わずか20分足らずの間に、140件もの決済リクエストが TypeError: Cannot read properties of undefined で失敗したのです。クライアント側のコンポーネントは依然として response.userId を参照していました。
Next.jsによるフロントエンドも、Node.jsによるバックエンドも、どちらもTypeScriptで書かれていました。それにもかかわらず、ランタイムエラーのすり抜けを防ぐことはできませんでした。両端でTypeScriptを採用しているからといって、ネットワーク境界まで型安全であるとは限らないのです。
原因分析:フルスタックTypeScriptが境界で破綻する理由
多くの開発チームは、手作業でのスキーマ定義のコピーや、乖離の生じやすいコードジェネレーターを使ってクライアント・サーバー間のギャップを埋めています。典型的なワークフローは次のとおりです。
- バックエンドエンジニアがエンドポイントを実装し、レスポンスのDTOをエクスポートする。
- フロントエンドエンジニアがそれらのインターフェースを手動でコピーするか、PRマージ前に誰かが再生成し忘れたOpenAPI仕様から生成する。
- ネイティブの
fetch()もaxiosも、通信時のペイロードをコンパイル時に検証しない。TypeScriptはas UserProfileといったアサーションを盲目的に信用する。
この根拠のない安心感が、チームに痛手を与えます。レスポンスの型をキャストした瞬間、コンパイラによるセーフティネットは事実上失われます。たとえコードが同一のNext.jsリポジトリ内に同居していても、クライアントとサーバーは互いに相手の状態を知らないまま通信し続けることになります。
ソリューション比較:REST、GraphQL、そしてtRPC
モダンなアーキテクチャでは、フルスタック間の通信をどのように処理しているのでしょうか。代表的な3つのアプローチを比較してみましょう。
1. REST + OpenAPI Codegen
エンドポイントを実装し、600行にも及ぶYAMLファイルに仕様を記述した上で、CLIスクリプトを実行してフロントエンド用のSDKを生成します。
- メリット: 業界標準であり、言語を問わず利用可能。疎結合なアーキテクチャに適している。
- デメリット: メンテナンスが煩雑。サーバー側のフィールドを1つ変更しても、コード生成を再実行するまでフロントエンドの型は古いままになり、フィードバックループが遅い。
2. GraphQL + ApolloまたはRelay
厳格なスキーマ定義言語(SDL)とクライアント主導のクエリにより、オーバーフェッチ(過剰なデータ取得)を防止します。
- メリット: 柔軟なクエリ設計、組み込みのイントロスペクション機能、大規模なデータグラフやマルチプラットフォームクライアントで実証された実績。
- デメリット: ボイラープレートの多さ。スキーマ、リゾルバー、クライアントクエリ、そして
graphql-codegenのようなビルドツールのセットアップが必要。スタック全体がEnd-to-EndでTypeScriptなら、これは余計なオーバーヘッドになりかねない。
3. tRPC (TypeScript Remote Procedure Call)
スキーマ定義ファイルも中間のビルドステップも完全に不要。フロントエンドがバックエンドルーターから型を直接推論します。
- メリット: コード生成が一切不要。即座に効くオートコンプリート。バックエンドのルート定義が変わると、コンパイルエラーとして即座に検知。
- デメリット: クライアントとサーバー双方がTypeScriptに依存する。公開APIやサードパーティ向けAPIの提供には向かない。
実践ガイド:Next.js App RouterでのtRPC実装
Next.js上でtRPCとZodを組み合わせることで、堅牢なセーフティネットが即座に手に入ります。バックエンドのフィールドを変更すると、ステージング環境へプッシュする前に、手元のローカルビルドの段階で破綻を検知できます。
ステップ1:依存関係のインストール
Next.jsプロジェクトに必要なパッケージを追加します。
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
ステップ2:Contextとルーターの初期化
インスタンスを初期化し、再利用可能なプロシージャビルダーをエクスポートするためのエントリーポイント(例:src/server/trpc.ts)を作成します。
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
ステップ3:プロシージャと入力スキーマの定義
src/server/routers/app.ts にAPIルーターを構築します。Zodが入力のランタイムバリデーションを処理し、TypeScriptが戻り値の型を自動推論します。
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
export const appRouter = router({
getUser: publicProcedure
.input(
z.object({
id: z.string().uuid(),
})
)
.query(async ({ input }) => {
// データベース検索のモック処理
return {
id: input.id,
name: 'Alex Rivera',
role: 'lead-engineer',
activeProjects: 4,
};
}),
});
// 実装コードではなく、型定義のみをエクスポートする
export type AppRouter = typeof appRouter;
type AppRouter = typeof appRouter をエクスポートする点が最大のポイントです。Next.jsはサーバーサイドのコード、データベースロジック、シークレット情報をクライアントバンドルから完全に除外します。通信境界を越えて共有されるのは、軽量な型シグネチャだけです。
ステップ4:Route Handlerのマウント
Next.jsのApp Router環境で、src/app/api/trpc/[trpc]/route.ts にFetchアダプターをマウントします。
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/app';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
ステップ5:クライアントからのデータ取得
React用クライアントヘルパーを接続し、コンポーネント内でクエリを実行します。
'use client';
import { trpc } from '@/utils/trpc';
export function UserProfileCard({ userId }: { userId: string }) {
const { data, isLoading, error } = trpc.getUser.useQuery({ id: userId });
if (isLoading) return <p>プロフィールを読み込み中...</p>;
if (error) return <p>ユーザーの読み込みに失敗しました: {error.message}</p>;
if (!data) return null;
return (
<div className="card">
<h3>{data.name}</h3>
<p>役職: {data.role}</p>
<span>担当プロジェクト数: {data.activeProjects}</span>
</div>
);
}
バックエンド側で activeProjects を projectCount に変更してみてください。即座にVS Codeが data.activeProjects の下に赤色の波線を表示します。テストの実行や本番障害後のポストモーテムを待つまでもなく、ミリ秒単位でデグレを察知できます。
本番運用で役立つベストプラクティス
- 早い段階でルーターを分割する: ドメインごと(例:
authRouter、billingRouter)にルートを整理し、t.mergeRouters()を使って単一のルート(root)ルーターに統合しましょう。 - ミドルウェアを集約する: 各クエリに認証やレート制限のチェックを個別に散らすのではなく、再利用可能なミドルウェアをチェーンして一元管理します。
- Zodで入力をサニタイズする:
.transform()を活用し、URLクエリ文字列やISO日付文字列をハンドラーに渡す前にクリーンに型変換しておきましょう。

