【TypeScript × tRPC】完全ガイド|エンドツーエンド型安全なAPI設計・Zod連携・Next.js統合・認証まで徹底解説

【TypeScript × tRPC】完全ガイド|エンドツーエンド型安全なAPI設計・Zod連携・Next.js統合・認証まで徹底解説 TypeScript

フルスタック開発でよくある悩みが「フロントとバックエンドで型定義を二重管理しなければならない」という問題です。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が向かないケース
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のコアとなるinitTRPCtオブジェクトを生成します。これがrouterproceduremiddlewareの作成元になります。

src/server/trpc.ts
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クライアント・ログインユーザー情報・セッションなどを格納します。リクエストごとに生成されます。

src/server/context.ts
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内のsessionnon-nullに絞り込まれます。

src/server/trpc.ts(protectedProcedureを追加)
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で入力バリデーションを行い、型安全な処理を記述します。

src/server/routers/user.ts
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で統合する

src/server/routers/_app.ts
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ハンドラー設定

src/app/api/trpc/[trpc]/route.ts
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の型だけをインポートし、実装は含まないことがポイントです。

src/trpc/client.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";

// AppRouter の型だけをインポート(バンドルサイズを増やさない)
// これによりクライアントでサーバー側の実装コードが含まれない
export const trpc = createTRPCReact<AppRouter>();
src/app/_trpc/TrpcProvider.tsx
"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>
  );
}
src/app/layout.tsx(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のフックが生成されます。引数と戻り値の型はすべて自動推論されます。

src/app/users/page.tsx(クライアントコンポーネント)
"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ラウンドトリップが発生せず、パフォーマンス上有利です。

src/app/users/[id]/page.tsx(Server Component)
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 予期しないサーバーエラー
TypeScript(クライアント側のエラーハンドリング)
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リクエストを発生させずに直接テストできます。

src/server/routers/__tests__/user.test.ts
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);
    });
  });
});
createCallerを使うテストのメリット

  • 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 完全ガイドもあわせてご覧ください。