コンテンツへスキップ
·14分で読めます

tRPC:煩雑さのないエンドツーエンドの型安全性

tRPCがAPIコントラクト問題をどう解消するか、Next.js App Routerとの統合、認証ミドルウェア、ファイルアップロード、サブスクリプションまで。実践的なtRPCセットアップをゼロから解説します。

シェア:X / TwitterLinkedIn

おなじみの光景です。APIレスポンスのフィールド名を変更します。バックエンドの型を更新します。デプロイします。そして、Dashboard.tsxの247行目にあるfetchコールの更新を誰かが忘れたために、本番環境でフロントエンドが壊れます。フィールドはundefinedになり、コンポーネントは空白で表示され、午前2時にエラートラッキングが火を噴きます。

これがAPIコントラクト問題です。技術の問題ではありません。連携の問題です。Swaggerドキュメントをどれだけ整備しても、GraphQLスキーマをどれだけ定義しても、フロントエンドとバックエンドの型がサイレントに乖離し得るという事実は変わりません。

tRPCは乖離を許さないことでこの問題を解決します。スキーマファイルはありません。コード生成ステップもありません。別途管理すべきコントラクトもありません。サーバー上でTypeScript関数を書けば、クライアントはコンパイル時にその正確な入出力の型を知ることができます。フィールド名を変更すれば、修正するまでフロントエンドはコンパイルできません。

これがtRPCの約束です。実際にどう動くのか、どこが優れているのか、そして絶対に使うべきでない場面はどこかをお見せしましょう。

問題の本質#

今日、ほとんどのチームがどのようにAPIを構築しているか見てみましょう。

REST + OpenAPI:エンドポイントを書きます。場合によってはSwaggerアノテーションを追加します。場合によってはOpenAPI仕様からクライアントSDKを生成します。しかし、仕様は別のアーティファクトです。古くなる可能性があります。生成ステップはCIパイプラインの中で壊れたり忘れられたりするもう一つの要素です。そして生成された型は往々にして醜い——paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"]のような深くネストされた怪物になりがちです。

GraphQL:型安全性は向上しますが、膨大な儀式が必要です。SDLでスキーマを書きます。リゾルバを書きます。スキーマから型を生成します。クライアントでクエリを書きます。クエリから型を生成します。少なくとも2つのコード生成ステップ、1つのスキーマファイル、全員が実行を忘れないようにしなければならないビルドステップが必要です。フロントエンドとバックエンドの両方を管理するチームにとって、これはもっとシンプルな解決策がある問題に対して過大なインフラです。

手動fetchコール:最も一般的なアプローチであり、最も危険なアプローチです。fetch("/api/users")を書き、結果をUser[]にキャストし、あとは祈るだけです。コンパイル時の安全性はゼロです。型アサーションはTypeScriptに対する嘘です。

typescript
// すべてのフロントエンド開発者がついたことのある嘘
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // 🙏 これが正しいことを祈る

tRPCはまったく異なるアプローチを取ります。APIを別のフォーマットで記述して型を生成する代わりに、サーバー上でプレーンなTypeScript関数を書き、その型をクライアントに直接インポートします。生成ステップなし。スキーマファイルなし。乖離なし。

コアコンセプト#

セットアップに入る前に、メンタルモデルを理解しましょう。

ルーター#

tRPCルーターはプロシージャをグループ化したコレクションです。MVCのコントローラーのようなものですが、型推論が組み込まれたプレーンなオブジェクトです。

typescript
import { initTRPC } from "@trpc/server";
 
const t = initTRPC.create();
 
export const appRouter = t.router({
  user: t.router({
    list: t.procedure.query(/* ... */),
    byId: t.procedure.input(/* ... */).query(/* ... */),
    create: t.procedure.input(/* ... */).mutation(/* ... */),
  }),
  post: t.router({
    list: t.procedure.query(/* ... */),
    publish: t.procedure.input(/* ... */).mutation(/* ... */),
  }),
});
 
export type AppRouter = typeof appRouter;

このAppRouter型のエクスポートがすべてのマジックです。クライアントはこの型——ランタイムコードではなく型だけ——をインポートし、すべてのプロシージャに対して完全なオートコンプリートと型チェックを得られます。

プロシージャ#

プロシージャは単一のエンドポイントです。3つの種類があります:

  • Query:読み取り操作。HTTPのGETセマンティクスに対応。TanStack Queryでキャッシュされます。
  • Mutation:書き込み操作。HTTP POSTに対応。キャッシュされません。
  • Subscription:リアルタイムストリーム。WebSocketを使用します。

コンテキスト#

コンテキストはすべてのプロシージャで利用可能なリクエストスコープのデータです。データベース接続、認証済みユーザー、リクエストヘッダー——Expressのreqオブジェクトに入れるようなものすべてがここに含まれます。

ミドルウェア#

ミドルウェアはコンテキストを変換したり、アクセスを制御したりします。最も一般的なパターンは、有効なセッションをチェックしてctx.userを追加する認証ミドルウェアです。

型推論チェーン#

これが重要なメンタルモデルです。次のようにプロシージャを定義すると:

typescript
t.procedure
  .input(z.object({ id: z.string() }))
  .query(({ input }) => {
    // input は { id: string } として型付けされます
    return db.user.findUnique({ where: { id: input.id } });
  });

戻り値の型はクライアントまで流れます。db.user.findUniqueUser | nullを返すなら、クライアントのuseQueryフックのdataUser | nullとして型付けされます。手動の型定義なし。キャストなし。エンドツーエンドで推論されます。

Next.js App Routerでのセットアップ#

ゼロから構築しましょう。Next.js 14以降のApp Routerプロジェクトがあることを前提とします。

依存関係のインストール#

bash
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod

ステップ1:サーバーでtRPCを初期化#

tRPCインスタンスを作成し、コンテキスト型を定義します。

typescript
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import superjson from "superjson";
import { ZodError } from "zod";
 
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
  const session = await getSession(opts.req);
 
  return {
    session,
    db: prisma,
    req: opts.req,
  };
}
 
const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});
 
export const createCallerFactory = t.createCallerFactory;
export const router = t.router;
export const publicProcedure = t.procedure;

いくつか注目すべきポイントがあります:

  • superjsonトランスフォーマー:tRPCはデフォルトでJSONとしてデータをシリアライズするため、Dateオブジェクト、MapSet、その他の非JSON型は失われます。superjsonはそれらを保持します。
  • エラーフォーマッター:Zodのバリデーションエラーをレスポンスに添付し、クライアントがフィールドレベルのエラーを表示できるようにします。
  • createTRPCContext:この関数はすべてのリクエストで実行されます。セッションの解析、データベース接続のセットアップ、コンテキストオブジェクトの構築を行う場所です。

ステップ2:ルーターの定義#

typescript
// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
 
export const userRouter = router({
  me: protectedProcedure.query(async ({ ctx }) => {
    const user = await ctx.db.user.findUnique({
      where: { id: ctx.user.id },
      select: {
        id: true,
        name: true,
        email: true,
        image: true,
        createdAt: true,
      },
    });
 
    if (!user) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "User not found",
      });
    }
 
    return user;
  }),
 
  updateProfile: protectedProcedure
    .input(
      z.object({
        name: z.string().min(1).max(100),
        bio: z.string().max(500).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const updated = await ctx.db.user.update({
        where: { id: ctx.user.id },
        data: {
          name: input.name,
          bio: input.bio,
        },
      });
 
      return updated;
    }),
 
  byId: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ ctx, input }) => {
      const user = await ctx.db.user.findUnique({
        where: { id: input.id },
        select: {
          id: true,
          name: true,
          image: true,
          bio: true,
        },
      });
 
      if (!user) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "User not found",
        });
      }
 
      return user;
    }),
});
typescript
// src/server/routers/_app.ts
import { router } from "../trpc";
import { userRouter } from "./user";
import { postRouter } from "./post";
import { notificationRouter } from "./notification";
 
export const appRouter = router({
  user: userRouter,
  post: postRouter,
  notification: notificationRouter,
});
 
export type AppRouter = typeof appRouter;

ステップ3:Next.jsのRoute Handlerで公開#

App Routerでは、tRPCは標準のRoute Handlerとして動作します。カスタムサーバーも、特別なNext.jsプラグインも必要ありません。

typescript
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createTRPCContext } from "@/server/trpc";
 
const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: "/api/trpc",
    req,
    router: appRouter,
    createContext: createTRPCContext,
    onError:
      process.env.NODE_ENV === "development"
        ? ({ path, error }) => {
            console.error(
              `❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
            );
          }
        : undefined,
  });
 
export { handler as GET, handler as POST };

これだけです。GETとPOSTの両方が処理されます。クエリはGET(URLエンコードされた入力)を通り、ミューテーションはPOSTを通ります。

ステップ4:クライアントのセットアップ#

typescript
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
 
export const trpc = createTRPCReact<AppRouter>();

注目:AppRouterとしてのみインポートしています。サーバーコードがクライアントバンドルに漏れることはありません。

ステップ5:プロバイダーのセットアップ#

typescript
// src/components/providers/TRPCProvider.tsx
"use client";
 
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import superjson from "superjson";
 
function getBaseUrl() {
  if (typeof window !== "undefined") return "";
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
  return `http://localhost:${process.env.PORT ?? 3000}`;
}
 
export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 5 * 60 * 1000,
            retry: 1,
          },
        },
      })
  );
 
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        loggerLink({
          enabled: (op) =>
            process.env.NODE_ENV === "development" ||
            (op.direction === "down" && op.result instanceof Error),
        }),
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
      ],
    })
  );
 
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}
typescript
// src/app/layout.tsx(関連部分)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <TRPCProvider>{children}</TRPCProvider>
      </body>
    </html>
  );
}

ステップ6:コンポーネントで使用#

typescript
// src/app/dashboard/page.tsx
"use client";
 
import { trpc } from "@/lib/trpc";
 
export default function DashboardPage() {
  const { data: user, isLoading, error } = trpc.user.me.useQuery();
 
  if (isLoading) return <DashboardSkeleton />;
  if (error) return <ErrorDisplay message={error.message} />;
 
  return (
    <div>
      <h1>Welcome back, {user.name}</h1>
      <p>Member since {user.createdAt.toLocaleDateString()}</p>
    </div>
  );
}

user.nameは完全に型付けされています。user.nmeとタイプミスすれば、TypeScriptがすぐにキャッチします。サーバーがnameの代わりにdisplayNameを返すように変更すれば、すべてのクライアント側の使用箇所にコンパイルエラーが表示されます。ランタイムでの想定外の事態はありません。

コンテキストとミドルウェア#

コンテキストとミドルウェアは、tRPCを「面白い型のトリック」から「プロダクション対応のフレームワーク」に変える要素です。

コンテキストの作成#

コンテキスト関数はすべてのリクエストで実行されます。実践的なバージョンを見てみましょう:

typescript
// src/server/trpc.ts
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
 
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
  const session = await getServerSession(authOptions);
 
  return {
    session,
    user: session?.user ?? null,
    db: prisma,
    headers: Object.fromEntries(opts.req.headers),
  };
}
 
type Context = Awaited<ReturnType<typeof createTRPCContext>>;

認証ミドルウェア#

最も一般的なミドルウェアパターンは、パブリックプロシージャとプロテクテッドプロシージャを分離することです:

typescript
// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in to perform this action",
    });
  }
 
  return next({
    ctx: {
      // コンテキストの型をオーバーライド — user はnullableではなくなります
      user: ctx.user,
    },
  });
});
 
export const protectedProcedure = t.procedure.use(isAuthed);

このミドルウェアが実行された後、protectedProcedure内のctx.userはnullでないことが保証されます。型システムがこれを強制します。TypeScriptが警告を出すため、パブリックプロシージャで誤ってctx.user.idにアクセスすることはできません。

ロールベースのミドルウェア#

より細かいアクセス制御のためにミドルウェアを組み合わせることができます:

typescript
const isAdmin = t.middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
 
  if (ctx.user.role !== "ADMIN") {
    throw new TRPCError({
      code: "FORBIDDEN",
      message: "Admin access required",
    });
  }
 
  return next({
    ctx: {
      user: ctx.user,
    },
  });
});
 
export const adminProcedure = t.procedure.use(isAdmin);

ロギングミドルウェア#

ミドルウェアは認証だけのものではありません。パフォーマンスロギングミドルウェアの例を見てみましょう:

typescript
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
 
  const result = await next();
 
  const duration = Date.now() - start;
 
  if (duration > 1000) {
    console.warn(`⚠️ Slow ${type} ${path}: ${duration}ms`);
  }
 
  return result;
});
 
// すべてのプロシージャに適用
export const publicProcedure = t.procedure.use(loggerMiddleware);

レート制限ミドルウェア#

typescript
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
 
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(20, "10 s"),
});
 
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
  const ip = ctx.headers["x-forwarded-for"] ?? "unknown";
  const { success, remaining } = await ratelimit.limit(ip);
 
  if (!success) {
    throw new TRPCError({
      code: "TOO_MANY_REQUESTS",
      message: `Rate limit exceeded. Try again later.`,
    });
  }
 
  return next();
});

Zodによる入力バリデーション#

tRPCは入力バリデーションにZodを使用します。これはオプションの装飾ではありません——クライアントとサーバーの両方で入力が安全であることを保証するメカニズムです。

基本的なバリデーション#

typescript
const postRouter = router({
  create: protectedProcedure
    .input(
      z.object({
        title: z.string().min(1, "Title is required").max(200, "Title too long"),
        content: z.string().min(10, "Content must be at least 10 characters"),
        categoryId: z.string().uuid("Invalid category ID"),
        tags: z.array(z.string()).max(5, "Maximum 5 tags").default([]),
        published: z.boolean().default(false),
      })
    )
    .mutation(async ({ ctx, input }) => {
      // input は完全に型付けされます:
      // {
      //   title: string;
      //   content: string;
      //   categoryId: string;
      //   tags: string[];
      //   published: boolean;
      // }
 
      return ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),
});

二重バリデーションのトリック#

微妙ですが重要な点があります:Zodバリデーションは両方のサイドで実行されます。クライアント側では、tRPCはリクエストを送信する前に入力をバリデーションします。入力が無効な場合、リクエストはブラウザから送信されません。サーバー側では、セキュリティ対策として同じスキーマで再度バリデーションされます。

つまり、クライアントサイドのバリデーションが無料で手に入ります:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState } from "react";
 
export function CreatePostForm() {
  const [title, setTitle] = useState("");
  const utils = trpc.useUtils();
 
  const createPost = trpc.post.create.useMutation({
    onSuccess: () => {
      utils.post.list.invalidate();
    },
    onError: (error) => {
      // Zodエラーは構造化された形で届きます
      if (error.data?.zodError) {
        const fieldErrors = error.data.zodError.fieldErrors;
        // fieldErrors.title?: string[]
        // fieldErrors.content?: string[]
        console.log(fieldErrors);
      }
    },
  });
 
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        createPost.mutate({
          title,
          content: "...",
          categoryId: "...",
        });
      }}
    >
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      {createPost.error?.data?.zodError?.fieldErrors.title && (
        <span className="text-red-500">
          {createPost.error.data.zodError.fieldErrors.title[0]}
        </span>
      )}
      <button type="submit" disabled={createPost.isPending}>
        {createPost.isPending ? "Creating..." : "Create Post"}
      </button>
    </form>
  );
}

複雑な入力パターン#

typescript
// 判別共用体
const searchInput = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("user"),
    query: z.string(),
    includeInactive: z.boolean().default(false),
  }),
  z.object({
    type: z.literal("post"),
    query: z.string(),
    category: z.string().optional(),
  }),
]);
 
// プロシージャ間で再利用するページネーション入力
const paginationInput = z.object({
  cursor: z.string().nullish(),
  limit: z.number().min(1).max(100).default(20),
});
 
const postRouter = router({
  infiniteList: publicProcedure
    .input(
      z.object({
        ...paginationInput.shape,
        category: z.string().optional(),
        sortBy: z.enum(["newest", "popular", "trending"]).default("newest"),
      })
    )
    .query(async ({ ctx, input }) => {
      const { cursor, limit, category, sortBy } = input;
 
      const posts = await ctx.db.post.findMany({
        take: limit + 1,
        cursor: cursor ? { id: cursor } : undefined,
        where: category ? { categoryId: category } : undefined,
        orderBy:
          sortBy === "newest"
            ? { createdAt: "desc" }
            : sortBy === "popular"
              ? { likes: "desc" }
              : { score: "desc" },
      });
 
      let nextCursor: string | undefined;
      if (posts.length > limit) {
        const nextItem = posts.pop();
        nextCursor = nextItem?.id;
      }
 
      return {
        posts,
        nextCursor,
      };
    }),
});

オプショナルな入力#

すべてのプロシージャが入力を必要とするわけではありません。クエリには不要な場合が多いです:

typescript
const statsRouter = router({
  // 入力不要
  overview: publicProcedure.query(async ({ ctx }) => {
    const [userCount, postCount, commentCount] = await Promise.all([
      ctx.db.user.count(),
      ctx.db.post.count(),
      ctx.db.comment.count(),
    ]);
 
    return { userCount, postCount, commentCount };
  }),
 
  // オプショナルなフィルター
  detailed: publicProcedure
    .input(
      z
        .object({
          from: z.date().optional(),
          to: z.date().optional(),
        })
        .optional()
    )
    .query(async ({ ctx, input }) => {
      const where = {
        ...(input?.from && { createdAt: { gte: input.from } }),
        ...(input?.to && { createdAt: { lte: input.to } }),
      };
 
      return ctx.db.post.groupBy({
        by: ["categoryId"],
        where,
        _count: true,
      });
    }),
});

エラーハンドリング#

tRPCのエラーハンドリングは構造化され、型安全であり、HTTPセマンティクスとクライアントサイドUIの両方にクリーンに統合されます。

サーバー側でのエラースロー#

typescript
import { TRPCError } from "@trpc/server";
 
const postRouter = router({
  delete: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const post = await ctx.db.post.findUnique({
        where: { id: input.id },
      });
 
      if (!post) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "Post not found",
        });
      }
 
      if (post.authorId !== ctx.user.id) {
        throw new TRPCError({
          code: "FORBIDDEN",
          message: "You can only delete your own posts",
        });
      }
 
      await ctx.db.post.delete({ where: { id: input.id } });
 
      return { success: true };
    }),
});

tRPCのエラーコードはHTTPステータスコードに対応します:

tRPCコードHTTPステータス使用場面
BAD_REQUEST400Zodバリデーション以外の不正な入力
UNAUTHORIZED401ログインしていない
FORBIDDEN403ログイン済みだが権限が不十分
NOT_FOUND404リソースが存在しない
CONFLICT409リソースの重複
TOO_MANY_REQUESTS429レート制限超過
INTERNAL_SERVER_ERROR500予期しないサーバーエラー

カスタムエラーフォーマット#

セットアップ時のエラーフォーマッターを覚えていますか?実際にどう動作するか見てみましょう:

typescript
const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
        // カスタムフィールドを追加
        timestamp: new Date().toISOString(),
        requestId: crypto.randomUUID(),
      },
    };
  },
});

クライアントサイドのエラーハンドリング#

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
 
export function DeletePostButton({ postId }: { postId: string }) {
  const utils = trpc.useUtils();
 
  const deletePost = trpc.post.delete.useMutation({
    onSuccess: () => {
      toast.success("Post deleted");
      utils.post.list.invalidate();
    },
    onError: (error) => {
      switch (error.data?.code) {
        case "FORBIDDEN":
          toast.error("You don't have permission to delete this post");
          break;
        case "NOT_FOUND":
          toast.error("This post no longer exists");
          utils.post.list.invalidate();
          break;
        default:
          toast.error("Something went wrong. Please try again.");
      }
    },
  });
 
  return (
    <button
      onClick={() => deletePost.mutate({ id: postId })}
      disabled={deletePost.isPending}
    >
      {deletePost.isPending ? "Deleting..." : "Delete"}
    </button>
  );
}

グローバルエラーハンドリング#

未処理のtRPCエラーをすべてキャッチするグローバルエラーハンドラーを設定できます:

typescript
// TRPCProvider内で
const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        mutations: {
          onError: (error) => {
            // 未処理のミューテーションエラーに対するグローバルフォールバック
            if (error instanceof TRPCClientError) {
              if (error.data?.code === "UNAUTHORIZED") {
                // ログインページにリダイレクト
                window.location.href = "/login";
                return;
              }
 
              toast.error(error.message);
            }
          },
        },
      },
    })
);

ミューテーションと楽観的更新#

ミューテーションは、tRPCがTanStack Queryと本当にうまく統合される部分です。実践的なパターンを見てみましょう:楽観的更新を伴ういいねボタンです。

基本的なミューテーション#

typescript
// サーバー
const postRouter = router({
  toggleLike: protectedProcedure
    .input(z.object({ postId: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const existing = await ctx.db.like.findUnique({
        where: {
          userId_postId: {
            userId: ctx.user.id,
            postId: input.postId,
          },
        },
      });
 
      if (existing) {
        await ctx.db.like.delete({
          where: { id: existing.id },
        });
        return { liked: false };
      }
 
      await ctx.db.like.create({
        data: {
          userId: ctx.user.id,
          postId: input.postId,
        },
      });
 
      return { liked: true };
    }),
});

楽観的更新#

ユーザーが「いいね」をクリックします。サーバーのレスポンスを200ms待ってからUIを更新したくはありません。楽観的更新がこれを解決します:UIを即座に更新し、サーバーが拒否した場合にロールバックします。

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
 
export function LikeButton({ postId, initialLiked, initialCount }: {
  postId: string;
  initialLiked: boolean;
  initialCount: number;
}) {
  const utils = trpc.useUtils();
 
  const toggleLike = trpc.post.toggleLike.useMutation({
    onMutate: async ({ postId }) => {
      // 発行中のリフェッチをキャンセルし、楽観的更新を上書きしないようにする
      await utils.post.byId.cancel({ id: postId });
 
      // 以前の値のスナップショットを取得
      const previousPost = utils.post.byId.getData({ id: postId });
 
      // キャッシュを楽観的に更新
      utils.post.byId.setData({ id: postId }, (old) => {
        if (!old) return old;
        return {
          ...old,
          liked: !old.liked,
          likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
        };
      });
 
      // ロールバック用にスナップショットを返す
      return { previousPost };
    },
 
    onError: (_error, { postId }, context) => {
      // エラー時に以前の値にロールバック
      if (context?.previousPost) {
        utils.post.byId.setData({ id: postId }, context.previousPost);
      }
    },
 
    onSettled: (_data, _error, { postId }) => {
      // 常にエラーまたは成功後にリフェッチしてサーバー状態を確認
      utils.post.byId.invalidate({ id: postId });
    },
  });
 
  return (
    <button
      onClick={() => toggleLike.mutate({ postId })}
      className={initialLiked ? "text-red-500" : "text-gray-400"}
    >
      ♥ {initialCount}
    </button>
  );
}

パターンは常に同じです:

  1. onMutate:クエリをキャンセルし、現在のデータのスナップショットを取り、楽観的更新を適用し、スナップショットを返す。
  2. onError:スナップショットを使ってロールバックする。
  3. onSettled:成功かエラーかに関わらず、クエリを無効化してサーバーから再取得する。

この3ステップのパターンにより、UIは常にレスポンシブで、最終的にサーバーと整合性が取れた状態になります。

関連クエリの無効化#

ミューテーション後、関連データをリフレッシュする必要があることがよくあります。tRPCのuseUtils()はこれをエルゴノミックにします:

typescript
const createComment = trpc.comment.create.useMutation({
  onSuccess: (_data, variables) => {
    // その投稿のコメントリストを無効化
    utils.comment.listByPost.invalidate({ postId: variables.postId });
 
    // 投稿自体を無効化(コメント数が変わったため)
    utils.post.byId.invalidate({ id: variables.postId });
 
    // すべての投稿リストを無効化(リストビューのコメント数)
    utils.post.list.invalidate();
  },
});

バッチ処理とサブスクリプション#

HTTPバッチ処理#

デフォルトで、httpBatchLinkを使用したtRPCは、複数の同時リクエストを単一のHTTPコールにまとめます。コンポーネントがレンダリングされ、3つのクエリが発火した場合:

typescript
function Dashboard() {
  const user = trpc.user.me.useQuery();
  const posts = trpc.post.list.useQuery({ limit: 10 });
  const stats = trpc.stats.overview.useQuery();
 
  // ...
}

これら3つのクエリは自動的に単一のHTTPリクエストにバッチ処理されます:GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...

サーバーは3つすべてを処理し、3つの結果を単一のレスポンスで返し、TanStack Queryが各フックに結果を分配します。設定は不要です。

必要に応じて、特定のコールのバッチ処理を無効化できます:

typescript
// プロバイダー内で、splitLinkを使って特定のプロシージャを別のルートに送る
import { splitLink, httpBatchLink, httpLink } from "@trpc/client";
 
const [trpcClient] = useState(() =>
  trpc.createClient({
    links: [
      splitLink({
        condition: (op) => op.path === "post.infiniteList",
        true: httpLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
        false: httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
          maxURLLength: 2048,
        }),
      }),
    ],
  })
);

WebSocketサブスクリプション#

リアルタイム機能のために、tRPCはWebSocket上のサブスクリプションをサポートしています。これには別途WebSocketサーバーが必要です(Next.jsはRoute HandlerでネイティブにWebSocketをサポートしていません)。

typescript
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
 
// インメモリのイベントエミッター(本番環境ではRedis pub/subを使用)
import { EventEmitter } from "events";
 
const eventEmitter = new EventEmitter();
 
export const notificationRouter = router({
  onNew: protectedProcedure.subscription(({ ctx }) => {
    return observable<Notification>((emit) => {
      const handler = (notification: Notification) => {
        if (notification.userId === ctx.user.id) {
          emit.next(notification);
        }
      };
 
      eventEmitter.on("notification", handler);
 
      return () => {
        eventEmitter.off("notification", handler);
      };
    });
  }),
 
  // 通知をトリガーするミューテーション
  markAsRead: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const notification = await ctx.db.notification.update({
        where: { id: input.id, userId: ctx.user.id },
        data: { readAt: new Date() },
      });
 
      return notification;
    }),
});

クライアント側:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState } from "react";
 
export function NotificationBell() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
 
  trpc.notification.onNew.useSubscription(undefined, {
    onData: (notification) => {
      setNotifications((prev) => [notification, ...prev]);
      toast.info(notification.message);
    },
    onError: (error) => {
      console.error("Subscription error:", error);
    },
  });
 
  return (
    <div>
      <span className="badge">{notifications.length}</span>
      {/* 通知リストUI */}
    </div>
  );
}

WebSocketトランスポートには専用のサーバープロセスが必要です。wsライブラリを使った最小構成を示します:

typescript
// ws-server.ts(別プロセス)
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { appRouter } from "./server/routers/_app";
import { createTRPCContext } from "./server/trpc";
 
const wss = new WebSocketServer({ port: 3001 });
 
const handler = applyWSSHandler({
  wss,
  router: appRouter,
  createContext: createTRPCContext,
});
 
wss.on("connection", (ws) => {
  console.log(`Connection opened (${wss.clients.size} total)`);
  ws.once("close", () => {
    console.log(`Connection closed (${wss.clients.size} total)`);
  });
});
 
process.on("SIGTERM", () => {
  handler.broadcastReconnectNotification();
  wss.close();
});
 
console.log("WebSocket server listening on ws://localhost:3001");

クライアントにはサブスクリプション用のwsLinkが必要です:

typescript
import { wsLink, createWSClient } from "@trpc/client";
 
const wsClient = createWSClient({
  url: "ws://localhost:3001",
});
 
// splitLinkを使ってサブスクリプションをWebSocket経由でルーティング
const [trpcClient] = useState(() =>
  trpc.createClient({
    links: [
      splitLink({
        condition: (op) => op.type === "subscription",
        true: wsLink({ client: wsClient, transformer: superjson }),
        false: httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
      }),
    ],
  })
);

型安全なファイルアップロード#

tRPCはファイルアップロードをネイティブにはサポートしていません。JSON-RPCプロトコルであるため、バイナリデータは守備範囲外です。ただし、tRPCと署名付きURLを組み合わせることで、型安全なアップロードフローを構築できます。

パターンは以下の通りです:

  1. クライアントがtRPCに署名付きアップロードURLを要求する。
  2. tRPCがリクエストを検証し、権限をチェックし、URLを生成する。
  3. クライアントが署名付きURLを使って直接S3にアップロードする。
  4. クライアントがtRPCにアップロード完了を通知する。
typescript
// src/server/routers/upload.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
 
const s3 = new S3Client({ region: process.env.AWS_REGION });
 
export const uploadRouter = router({
  getPresignedUrl: protectedProcedure
    .input(
      z.object({
        filename: z.string().min(1).max(255),
        contentType: z.string().regex(/^(image|application)\//),
        size: z.number().max(10 * 1024 * 1024, "File must be under 10MB"),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const key = `uploads/${ctx.user.id}/${crypto.randomUUID()}-${input.filename}`;
 
      const command = new PutObjectCommand({
        Bucket: process.env.S3_BUCKET!,
        Key: key,
        ContentType: input.contentType,
        ContentLength: input.size,
      });
 
      const presignedUrl = await getSignedUrl(s3, command, {
        expiresIn: 300, // 5分
      });
 
      // 保留中のアップロードをデータベースに保存
      const upload = await ctx.db.upload.create({
        data: {
          key,
          userId: ctx.user.id,
          filename: input.filename,
          contentType: input.contentType,
          size: input.size,
          status: "PENDING",
        },
      });
 
      return {
        uploadId: upload.id,
        presignedUrl,
        key,
      };
    }),
 
  confirmUpload: protectedProcedure
    .input(z.object({ uploadId: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const upload = await ctx.db.upload.findUnique({
        where: { id: input.uploadId, userId: ctx.user.id },
      });
 
      if (!upload) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }
 
      // ファイルが実際にS3に存在するか確認
      // (オプションだが推奨)
 
      const confirmed = await ctx.db.upload.update({
        where: { id: upload.id },
        data: { status: "CONFIRMED" },
      });
 
      return {
        url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${confirmed.key}`,
      };
    }),
});

クライアント側:

typescript
"use client";
 
import { trpc } from "@/lib/trpc";
import { useState, useCallback } from "react";
 
export function FileUpload() {
  const [uploading, setUploading] = useState(false);
 
  const getPresignedUrl = trpc.upload.getPresignedUrl.useMutation();
  const confirmUpload = trpc.upload.confirmUpload.useMutation();
 
  const handleFileChange = useCallback(
    async (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      if (!file) return;
 
      setUploading(true);
 
      try {
        // ステップ1:tRPCから署名付きURLを取得
        const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
          filename: file.name,
          contentType: file.type,
          size: file.size,
        });
 
        // ステップ2:S3に直接アップロード
        const uploadResponse = await fetch(presignedUrl, {
          method: "PUT",
          body: file,
          headers: {
            "Content-Type": file.type,
          },
        });
 
        if (!uploadResponse.ok) {
          throw new Error("Upload failed");
        }
 
        // ステップ3:tRPC経由でアップロードを確認
        const { url } = await confirmUpload.mutateAsync({ uploadId });
 
        toast.success(`File uploaded: ${url}`);
      } catch (error) {
        toast.error("Upload failed. Please try again.");
      } finally {
        setUploading(false);
      }
    },
    [getPresignedUrl, confirmUpload]
  );
 
  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={uploading}
        accept="image/*"
      />
      {uploading && <p>Uploading...</p>}
    </div>
  );
}

フロー全体が型安全です。署名付きURLのレスポンスの型、アップロードIDの型、確認レスポンスの型——すべてがサーバーの定義から推論されます。署名付きURLのレスポンスに新しいフィールドを追加すれば、クライアントはすぐにそれを知ることができます。

サーバーサイドコールとReact Server Components#

Next.js App Routerでは、Server Componentsでデータを取得したい場面がよくあります。tRPCはサーバーサイドコーラーを通じてこれをサポートしています:

typescript
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
 
// Server Componentでの使用
// src/app/posts/[id]/page.tsx
import { createTRPCContext } from "@/server/trpc";
import { createCaller } from "@/server/trpc";
 
export default async function PostPage({
  params,
}: {
  params: { id: string };
}) {
  const ctx = await createTRPCContext({
    req: new Request("http://localhost"),
    resHeaders: new Headers(),
  });
  const caller = createCaller(ctx);
 
  const post = await caller.post.byId({ id: params.id });
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
      {/* インタラクティブな部分にはClient Components */}
      <LikeButton postId={post.id} initialLiked={post.liked} />
      <CommentSection postId={post.id} />
    </article>
  );
}

これにより両方の良いところが手に入ります:完全な型安全性を備えたサーバーレンダリングの初期データと、ミューテーションやリアルタイム機能のためのクライアントサイドのインタラクティブ性です。

tRPCプロシージャのテスト#

プロシージャは単なる関数なので、テストは簡単です。HTTPサーバーを立ち上げる必要はありません。

typescript
// src/server/routers/user.test.ts
import { describe, it, expect, vi } from "vitest";
import { appRouter } from "./routers/_app";
import { createCaller } from "./trpc";
 
describe("user router", () => {
  it("returns the current user profile", async () => {
    const caller = createCaller({
      user: { id: "user-1", email: "test@example.com", role: "USER" },
      db: prismaMock,
      session: mockSession,
      headers: {},
    });
 
    prismaMock.user.findUnique.mockResolvedValue({
      id: "user-1",
      name: "Test User",
      email: "test@example.com",
      image: null,
      createdAt: new Date("2026-01-01"),
    });
 
    const result = await caller.user.me();
 
    expect(result).toEqual({
      id: "user-1",
      name: "Test User",
      email: "test@example.com",
      image: null,
      createdAt: new Date("2026-01-01"),
    });
  });
 
  it("throws UNAUTHORIZED for unauthenticated requests", async () => {
    const caller = createCaller({
      user: null,
      db: prismaMock,
      session: null,
      headers: {},
    });
 
    await expect(caller.user.me()).rejects.toThrow("UNAUTHORIZED");
  });
 
  it("validates input with Zod", async () => {
    const caller = createCaller({
      user: { id: "user-1", email: "test@example.com", role: "USER" },
      db: prismaMock,
      session: mockSession,
      headers: {},
    });
 
    await expect(
      caller.user.updateProfile({
        name: "", // 最小長1
      })
    ).rejects.toThrow();
  });
});

HTTPレイヤーのモックも、supertestも、ルートマッチングも不要です。関数を呼び出して結果をアサートするだけです。これはtRPCの過小評価されている利点の一つです:トランスポートレイヤーは実装の詳細に過ぎないため、テストが驚くほどシンプルになります。

tRPCを使うべきでない場面#

tRPCは万能の解決策ではありません。ここからは機能しなくなる場面を見ていきます:

パブリックAPI#

外部の開発者が利用するAPIを構築する場合、tRPCは不適切です。外部の利用者はあなたのTypeScriptの型にアクセスできません。ドキュメント化された安定したコントラクトが必要です——REST向けのOpenAPI/Swagger、またはGraphQLスキーマです。tRPCの型安全性は、クライアントとサーバーが同じTypeScriptコードベースを共有している場合にのみ機能します。

モバイルアプリ(TypeScriptを使わない場合)#

モバイルアプリがSwift、Kotlin、またはDartで書かれている場合、tRPCは何の価値も提供しません。型は言語の境界を越えられません。理論的にはtrpc-openapiを使ってtRPCルートからOpenAPI仕様を生成できますが、その時点で儀式を元に戻しているだけです。最初からRESTを使いましょう。

マイクロサービス#

tRPCは単一のTypeScriptコードベースを前提としています。バックエンドが異なる言語の複数のサービスに分割されている場合、tRPCはサービス間通信には役立ちません。gRPC、REST、またはメッセージキューを使いましょう。

フロントエンド/バックエンドが分離された大規模チーム#

フロントエンドとバックエンドが別々のリポジトリで別々のデプロイパイプラインを持つ場合、tRPCのコアとなる利点が失われます。型の共有にはモノレポまたは共有パッケージが必要です。AppRouter型をnpmパッケージとして公開_することはできます_が、そうするとREST + OpenAPIの方がより自然に扱えるバージョニングの問題が発生します。

RESTセマンティクスが必要な場合#

HTTPキャッシュヘッダー、コンテンツネゴシエーション、ETag、その他のREST固有の機能が必要な場合、tRPCのHTTPに対する抽象化があなたと戦うことになります。tRPCはHTTPをフィーチャーではなくトランスポートの詳細として扱います。

判断フレームワーク#

私の判断基準は以下の通りです:

シナリオ推奨
同一リポジトリのフルスタックTypeScriptアプリtRPC — 最大の恩恵、最小のオーバーヘッド
社内ツール/管理ダッシュボードtRPC — 開発速度が最優先
サードパーティ開発者向けパブリックAPIREST + OpenAPI — 利用者にはドキュメントが必要、型ではなく
モバイル + Webクライアント(非TSモバイル)RESTまたはGraphQL — 言語非依存のコントラクトが必要
リアルタイム重視(チャット、ゲーミング)tRPCサブスクリプションまたは生のWebSocket(複雑さ次第)
フロントエンド/バックエンドが別チームGraphQL — スキーマがチーム間のコントラクトになる

プロダクションからの実践的なヒント#

ドキュメントには載っていない、tRPCを本番環境で運用して学んだことをいくつか紹介します:

ルーターは小さく保ちましょう。 単一のルーターファイルは200行を超えないようにしましょう。ドメインごとに分割します:userRouterpostRouterbillingRouter。それぞれ独自のファイルに。

サーバーサイドコールにはcreateCallerFactoryを使いましょう。 Server Componentから自分のAPIを呼ぶ際にfetchに手を伸ばさないでください。コーラーファクトリは同じ型安全性をHTTPオーバーヘッドゼロで提供します。

バッチ処理の最適化に時間をかけすぎないでください。 デフォルトのhttpBatchLinkでほぼ常に十分です。わずかな改善のためにsplitLinkの設定に何日も費やすチームを見てきました。まずプロファイリングしましょう。

QueryClientにstaleTimeを設定しましょう。 デフォルトのstaleTimeが0ということは、フォーカスイベントのたびにリフェッチがトリガーされることを意味します。データの鮮度要件に応じて適切な値(30秒から5分)に設定しましょう。

初日からsuperjsonを使いましょう。 後から追加するのは、すべてのクライアントとサーバーを同時にマイグレーションすることを意味します。Dateのシリアライゼーションバグから救ってくれる1行の設定です。

エラーバウンダリーは味方です。 tRPCを多用するページセクションをReactのエラーバウンダリーでラップしましょう。単一のクエリの失敗がページ全体をダウンさせるべきではありません。

typescript
"use client";
 
import { ErrorBoundary } from "react-error-boundary";
 
function ErrorFallback({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div role="alert" className="p-4 bg-red-50 rounded-lg">
      <p className="font-medium text-red-800">Something went wrong</p>
      <pre className="text-sm text-red-600 mt-2">{error.message}</pre>
      <button
        onClick={resetErrorBoundary}
        className="mt-3 px-4 py-2 bg-red-600 text-white rounded"
      >
        Try again
      </button>
    </div>
  );
}
 
export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <UserStats />
      </ErrorBoundary>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <RecentPosts />
      </ErrorBoundary>
    </div>
  );
}

まとめ#

tRPCはRESTやGraphQLの置き換えではありません。特定の状況向けの異なるツールです:クライアントとサーバーの両方を管理していて、両方がTypeScriptで、「バックエンドを変更した」から「フロントエンドがそれを知っている」までの最短経路が欲しい場合です。

その状況では、他に並ぶものはありません。コード生成なし、スキーマファイルなし、乖離なし。TypeScriptが最も得意とすること——本番環境に届く前にミスをキャッチすること——をそのまま活かすだけです。

トレードオフは明確です:プロトコルレベルの相互運用性(非TypeScriptクライアントなし)を放棄する代わりに、他の方法では達成が困難な開発速度とコンパイル時の安全性を得られます。

ほとんどのフルスタックTypeScriptアプリケーションにとって、それは十分に価値のあるトレードオフです。

関連記事