フルスタック開発でよくある悩みが「フロントとバックエンドで型定義を二重管理しなければならない」という問題です。APIのレスポンス型が変わるたびに両方を修正し、型が一致しているかを確かめる作業は非常に手間がかかります。
tRPC(TypeScript Remote Procedure Call)はこの問題を根本から解決します。サーバーとクライアントのTypeScriptコードを同じリポジトリに置くことで、APIの型をスキーマ定義なしに自動でフロントエンドに共有できます。エンドポイントの入力・出力の型変更がすぐにクライアントのコンパイルエラーとして現れ、型の不一致を実行前に検出できます。
- tRPCのコア概念(Router・Procedure・Context)と型安全性の仕組み
- query/mutation/subscriptionの定義方法と型推論
- Zodを使った入力バリデーションとエラーメッセージのカスタマイズ
- Contextでデータベース接続・認証情報をProcedureに渡す方法
- Middlewareでアクセス制御(認証ガード)を実装する方法
- Next.js App Routerとの統合設定
- TanStack Queryとの組み合わせによるデータフェッチング
- tRPCのエラーハンドリング型
- テストの書き方
Zodの基礎についてはTypeScript × Zod 完全ガイドを、Next.jsとの型安全な開発についてはTypeScript × Next.js App Router 完全ガイドもあわせてご覧ください。
tRPCとは・なぜ型安全なのか
tRPCはTypeScriptのみで機能する、スキーマレスのRPC(Remote Procedure Call)フレームワークです。GraphQLやOpenAPI(Swagger)のようなスキーマ定義ファイルを別途用意する必要はありません。サーバーで定義したRouterの型を直接クライアントに渡すだけで型共有が完成します。
| 比較項目 | REST API + OpenAPI | GraphQL | tRPC |
|---|---|---|---|
| 型共有の方法 | スキーマから型を生成(codegen) | スキーマから型を生成(codegen) | Routerの型を直接インポート |
| 学習コスト | 低〜中 | 高い | 低い(TypeScriptだけ) |
| スキーマ定義 | 必要(OpenAPI YAML/JSON) | 必要(GraphQL SDL) | 不要 |
| モノレポ前提 | 不要 | 不要 | 基本的に必要 |
| 向いている用途 | 外部公開API・多言語クライアント | 複雑なデータグラフ・BFF | フルスタックTS・社内API |
tRPCはTypeScriptモノレポを前提とした設計です。外部パートナーや別チームが異なる言語でAPIを呼ぶ場合は型の恩恵を受けられないため、OpenAPIやGraphQLのほうが適しています。社内フルスタックTypeScriptプロジェクトに最も力を発揮します。
インストールとプロジェクト構成
tRPC v11をNext.js App Routerで使う場合の構成を解説します。
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod npm install --save-dev @types/node
src/
├── app/
│ ├── api/trpc/[trpc]/route.ts # tRPC HTTPハンドラー
│ └── _trpc/
│ └── TrpcProvider.tsx # クライアント側Provider
├── server/
│ ├── trpc.ts # tRPCインスタンス初期化
│ ├── context.ts # Contextの型定義
│ └── routers/
│ ├── _app.ts # ルートRouter(統合)
│ ├── user.ts # ユーザーRouter
│ └── post.ts # 投稿Router
└── trpc/
└── client.ts # クライアント設定
tRPCインスタンスの初期化
tRPCのコアとなるinitTRPCでtオブジェクトを生成します。これがrouter・procedure・middlewareの作成元になります。
import { initTRPC, TRPCError } from "@trpc/server";
import { ZodError } from "zod";
import type { Context } from "./context";
// Context型を渡してtRPCを初期化
const t = initTRPC.context<Context>().create({
// ZodのエラーをtRPCのエラーフォーマットに変換
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
// 各種ビルダーをエクスポート
export const router = t.router;
export const publicProcedure = t.procedure; // 認証不要
export const middleware = t.middleware;
export const mergeRouters = t.mergeRouters;
Contextの定義
Contextはすべてのプロシージャが共有できるオブジェクトです。DBクライアント・ログインユーザー情報・セッションなどを格納します。リクエストごとに生成されます。
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { getServerSession } from "next-auth";
import { prisma } from "./db";
// Context の型
export interface Context {
session: {
user: { id: string; name: string; email: string; role: "admin" | "user" };
} | null;
prisma: typeof prisma;
}
// リクエストごとに Context を生成する関数
export async function createContext(
opts: FetchCreateContextFnOptions
): Promise<Context> {
const session = await getServerSession();
return {
session,
prisma,
};
}
Middlewareで認証ガードを実装する
認証が必要なProcedureにはmiddlewareを組み合わせたprotectedProcedureを作ります。ミドルウェアを通すことで、Context内のsessionがnon-nullに絞り込まれます。
import { initTRPC, TRPCError } from "@trpc/server";
import type { Context } from "./context";
const t = initTRPC.context<Context>().create();
// 認証チェックのミドルウェア
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "ログインが必要です",
});
}
// ctx.session が non-null に絞り込まれて next に渡される
return next({
ctx: {
...ctx,
session: ctx.session, // ここで session は確定型
},
});
});
// 管理者チェックのミドルウェア
const isAdmin = t.middleware(({ ctx, next }) => {
if (ctx.session?.user.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "管理者権限が必要です",
});
}
return next({ ctx });
});
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthenticated);
export const adminProcedure = t.procedure.use(isAuthenticated).use(isAdmin);
Routerの定義
RouterはProcedureをまとめたものです。queryはGET相当、mutationはPOST/PUT/DELETE相当です。Zodで入力バリデーションを行い、型安全な処理を記述します。
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, publicProcedure, protectedProcedure } from "../trpc";
const CreateUserInput = z.object({
name: z.string().min(1, "名前は必須です").max(50),
email: z.string().email("有効なメールアドレスを入力してください"),
password: z.string().min(8, "パスワードは8文字以上が必要です"),
});
const UpdateUserInput = z.object({
name: z.string().min(1).max(50).optional(),
email: z.string().email().optional(),
});
export const userRouter = router({
// 公開エンドポイント: ユーザー一覧
list: publicProcedure
.input(z.object({
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(20),
search: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
// input の型: { page: number; limit: number; search?: string }
const { page, limit, search } = input;
const [users, total] = await Promise.all([
ctx.prisma.user.findMany({
skip: (page - 1) * limit,
take: limit,
where: search ? { name: { contains: search } } : undefined,
}),
ctx.prisma.user.count(),
]);
return { users, total, page, totalPages: Math.ceil(total / limit) };
}),
// 公開エンドポイント: ユーザー詳細
byId: publicProcedure
.input(z.object({ id: z.string().cuid() }))
.query(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({ where: { id: input.id } });
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "ユーザーが見つかりません" });
}
return user; // 戻り値の型は Prisma の User 型として推論される
}),
// 認証必須: ユーザー作成
create: protectedProcedure
.input(CreateUserInput)
.mutation(async ({ ctx, input }) => {
// ctx.session は non-null(protectedProcedure が保証)
const existing = await ctx.prisma.user.findUnique({
where: { email: input.email },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: "このメールアドレスは既に使用されています",
});
}
return ctx.prisma.user.create({ data: input });
}),
// 認証必須: 自分のプロフィール更新
updateMe: protectedProcedure
.input(UpdateUserInput)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.user.update({
where: { id: ctx.session.user.id },
data: input,
});
}),
// 認証必須: 自分のアカウント削除
deleteMe: protectedProcedure
.mutation(async ({ ctx }) => {
await ctx.prisma.user.delete({ where: { id: ctx.session.user.id } });
return { success: true };
}),
});
// Router の型をエクスポート(クライアント側で使う)
export type UserRouter = typeof userRouter;
ルートRouterで統合する
import { router } from "../trpc";
import { userRouter } from "./user";
import { postRouter } from "./post";
export const appRouter = router({
user: userRouter,
post: postRouter,
});
// AppRouter の型をエクスポート(クライアント側でインポートする)
export type AppRouter = typeof appRouter;
Next.js App RouterでのHTTPハンドラー設定
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createContext } from "@/server/context";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext,
onError:
process.env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(`tRPC error on ${path ?? "<no-path>"}:`, error);
}
: undefined,
});
export { handler as GET, handler as POST };
クライアント側の設定とTanStack Query連携
tRPC v11では@trpc/react-queryを使ってTanStack Queryと統合します。サーバー側のRouterの型だけをインポートし、実装は含まないことがポイントです。
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
// AppRouter の型だけをインポート(バンドルサイズを増やさない)
// これによりクライアントでサーバー側の実装コードが含まれない
export const trpc = createTRPCReact<AppRouter>();
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { useState } from "react";
import { trpc } from "@/trpc/client";
export function TrpcProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
// 認証ヘッダーを付与する場合
async headers() {
return {
authorization: `Bearer ${getAuthToken()}`,
};
},
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
import { TrpcProvider } from "./_trpc/TrpcProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TrpcProvider>{children}</TrpcProvider>
</body>
</html>
);
}
クライアントからAPIを呼び出す
設定が完了したら、trpc.user.list.useQuery()のようにネームスペースを辿るだけでTanStack Queryのフックが生成されます。引数と戻り値の型はすべて自動推論されます。
"use client";
import { trpc } from "@/trpc/client";
import { useState } from "react";
export default function UsersPage() {
const [search, setSearch] = useState("");
// trpc.user.list → userRouter の list procedure
// input の型: { page?: number; limit?: number; search?: string }
const { data, isLoading } = trpc.user.list.useQuery({
page: 1,
search: search || undefined,
});
// mutation の型も自動推論
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
// キャッシュを無効化(TanStack Query の invalidateQueries を使う)
utils.user.list.invalidate();
},
});
const utils = trpc.useUtils();
if (isLoading) return <p>読み込み中...</p>;
return (
<div>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
{data?.users.map((user) => (
// user の型は Prisma の User 型として推論される
<div key={user.id}>{user.name} — {user.email}</div>
))}
</div>
);
}
Server Componentからの呼び出し(サーバー直接呼び出し)
Next.js App RouterのServer Componentではfetchを使わず、Routerを直接呼び出すことができます。HTTPラウンドトリップが発生せず、パフォーマンス上有利です。
import { createCallerFactory, appRouter } from "@/server/routers/_app";
import { createContext } from "@/server/context";
const createCaller = createCallerFactory(appRouter);
export default async function UserDetailPage({ params }: { params: { id: string } }) {
const ctx = await createContext();
const caller = createCaller(ctx);
// 直接呼び出し: HTTPリクエストなし
const user = await caller.user.byId({ id: params.id });
// ^^ 型が効く
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
エラーハンドリング
tRPCのエラーはTRPCErrorクラスで表現します。codeにHTTPステータスに対応した文字列を指定します。
| TRPCError code | HTTPステータス | 用途 |
|---|---|---|
BAD_REQUEST |
400 | 入力値が不正(Zodエラーも400) |
UNAUTHORIZED |
401 | 未認証 |
FORBIDDEN |
403 | 権限なし |
NOT_FOUND |
404 | リソースが存在しない |
CONFLICT |
409 | 重複・競合 |
TOO_MANY_REQUESTS |
429 | レートリミット超過 |
INTERNAL_SERVER_ERROR |
500 | 予期しないサーバーエラー |
import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@/server/routers/_app";
const mutation = trpc.user.create.useMutation({
onError: (error) => {
// error は TRPCClientError<AppRouter> 型
if (error instanceof TRPCClientError) {
// Zodバリデーションエラーの場合
if (error.data?.zodError) {
const fieldErrors = error.data.zodError.fieldErrors;
// fieldErrors: { name?: string[]; email?: string[]; ... }
console.error("バリデーションエラー:", fieldErrors);
return;
}
// tRPCエラーコードで分岐
switch (error.data?.code) {
case "UNAUTHORIZED":
window.location.href = "/login";
break;
case "CONFLICT":
console.error("このメールアドレスは既に登録されています");
break;
default:
console.error("エラーが発生しました:", error.message);
}
}
},
});
エラーハンドリングのパターン全般についてはTypeScript エラーハンドリング完全ガイドを参照してください。
テスト
tRPCのProcedureは純粋な関数なので、createCallerを使ってHTTPリクエストを発生させずに直接テストできます。
import { describe, it, expect, beforeEach, vi } from "vitest";
import { createCallerFactory } from "@trpc/server";
import { appRouter } from "../_app";
import type { Context } from "../../context";
// テスト用のContextモック
function createMockContext(overrides: Partial<Context> = {}): Context {
return {
session: null,
prisma: {
user: {
findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn().mockResolvedValue(0),
},
} as any,
...overrides,
};
}
const createCaller = createCallerFactory(appRouter);
describe("userRouter", () => {
describe("list(認証不要)", () => {
it("ユーザー一覧を返す", async () => {
const mockUsers = [{ id: "1", name: "田中", email: "t@example.com" }];
const ctx = createMockContext({
prisma: {
user: {
findMany: vi.fn().mockResolvedValue(mockUsers),
count: vi.fn().mockResolvedValue(1),
},
} as any,
});
const caller = createCaller(ctx);
const result = await caller.user.list({ page: 1, limit: 10 });
expect(result.users).toEqual(mockUsers);
expect(result.total).toBe(1);
});
});
describe("create(認証必須)", () => {
it("未認証の場合 UNAUTHORIZED エラーをスローする", async () => {
const ctx = createMockContext({ session: null });
const caller = createCaller(ctx);
await expect(
caller.user.create({ name: "テスト", email: "test@example.com", password: "password123" })
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("認証済みの場合ユーザーを作成できる", async () => {
const newUser = { id: "99", name: "新規", email: "new@example.com" };
const ctx = createMockContext({
session: { user: { id: "admin", name: "管理者", email: "admin@example.com", role: "admin" } },
prisma: {
user: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(newUser),
},
} as any,
});
const caller = createCaller(ctx);
const result = await caller.user.create({
name: "新規",
email: "new@example.com",
password: "password123",
});
expect(result).toEqual(newUser);
});
});
});
- HTTPリクエストを発生させないため高速
- ContextをモックするだけでProcedureを直接呼べる
- 認証ガード(
protectedProcedure)のテストも簡単 - DBのPrismaクライアントを
vi.fn()でモックできる
まとめ
tRPCとTypeScriptを使う際のポイントをまとめます。
| 項目 | ポイント |
|---|---|
| 型共有の仕組み | AppRouterの型をクライアントにインポートするだけ。スキーマ定義・codegenが不要 |
| Procedure | query(読み取り)とmutation(書き込み)を使い分ける |
| Zodバリデーション | .input(z.object({...}))で入力を検証。エラーは自動で400に変換 |
| Context | DBクライアント・セッションをProcedureに渡す。リクエストごとに生成 |
| Middleware | 認証ガードはprotectedProcedureとして切り出す。型レベルでnon-nullを保証 |
| Server Component | createCallerでHTTPなしにRouterを直接呼び出せる |
| クライアント呼び出し | trpc.user.list.useQuery()のようにネームスペース経由。引数・戻り値型が自動推論 |
| エラー | TRPCErrorのcodeでHTTPステータスを制御。ZodエラーはzodErrorプロパティで取得 |
| テスト | createCallerでHTTPなしにProcedureを直接テスト |
tRPCはTypeScriptフルスタックプロジェクトにおいて、最も手軽にエンドツーエンド型安全を実現できる選択肢です。GraphQLのような専用クエリ言語を学ばずに、TypeScriptだけで型共有が完成します。
PrismaとtRPCを組み合わせたフルスタック開発についてはTypeScript × Prisma 完全ガイドを、データフェッチングのTanStack Queryとの連携についてはTypeScript × TanStack Query v5 完全ガイドもあわせてご覧ください。

