tRPC 11完全実践ガイド〜型安全API・Next.js App Router・React Query連携【2026年版】〜

「tRPC で型安全な API を Next.js App Router に組み込みたい」「GraphQL や REST より楽に、サーバーとクライアントで型を共有したい」。そんな声に応える完全実践ガイドです。本記事は tRPC 11.x を前提に、最小ルーターから本番運用までを 40 個以上のコピペで動く TypeScript コードで解説します。Next.js App Router・React Query 連携・WebSocket Subscription・OpenAPI 連携・各種アダプタ(Express / Fastify / Hono / AWS Lambda)まで、現場でそのまま使える実装を網羅します。

この記事を最後まで読むと身につくこと

  • tRPC 11 の最小ルーター〜本番運用までの全コード
  • Zod による input バリデーションと型推論の完全活用
  • Next.js App Router(RSC + Client Components)への正しい統合方法
  • React Query 連携(useQuery / useMutation / Infinite Query / Optimistic Update)
  • Context / Middleware / protectedProcedure / Rate Limit / Logger
  • WebSocket Subscription / SSE / superjson transformer
  • Express / Fastify / Hono / AWS Lambda アダプタ全対応
  • tRPC vs GraphQL vs Server Actions の使い分け基準
  1. 1. tRPC の概要と選定基準
    1. 1.1 tRPC とは何か
    2. 1.2 tRPC 11 の3つの強み
    3. 1.3 REST / GraphQL / Server Actions との比較
  2. 2. インストールとプロジェクト初期化
    1. 2.1 必要なパッケージ
    2. 2.2 tsconfig.json の重要設定
    3. 2.3 ディレクトリ構成のおすすめ
  3. 3. tRPC の基本: initTRPC とルーター
    1. 3.1 initTRPC の初期化
    2. 3.2 最小のルーター(Hello World)
    3. 3.3 ルーターのネスト(サブルーター)
  4. 4. Procedure: query / mutation / subscription
    1. 4.1 query: 読み取り用 procedure
    2. 4.2 mutation: 書き込み用 procedure
    3. 4.3 subscription: ストリーミング購読
  5. 5. input バリデーション(Zod 連携)
    1. 5.1 input を Zod で型付ける
    2. 5.2 複数 input を chain でマージ
    3. 5.3 output バリデーション
  6. 6. Context と Middleware
    1. 6.1 createContext: リクエストごとの依存注入
    2. 6.2 middleware の作り方
    3. 6.3 protectedProcedure を使う
    4. 6.4 ロガー middleware
    5. 6.5 Rate Limit middleware
  7. 7. エラーハンドリング
    1. 7.1 TRPCError の投げ方
    2. 7.2 errorFormatter で Zod エラーを整形
    3. 7.3 onError でサーバー側ログを集約
  8. 8. Next.js App Router 統合
    1. 8.1 API Route(fetch アダプタ)
    2. 8.2 React Query Provider のセットアップ
    3. 8.3 ルート Layout で Provider をマウント
    4. 8.4 RSC から直接呼ぶ(server caller)
  9. 9. クライアント側: useQuery / useMutation
    1. 9.1 useQuery で読み取り
    2. 9.2 useMutation で書き込み
    3. 9.3 invalidate でキャッシュ更新
    4. 9.4 Infinite Queries(ページング)
    5. 9.5 Optimistic Update(楽観的更新)
  10. 10. データ変換: superjson Transformer
    1. 10.1 なぜ superjson が必要か
    2. 10.2 superjson が解決する型一覧
  11. 11. WebSocket / SSE Subscription
    1. 11.1 WebSocket サーバー側設定
    2. 11.2 クライアント側 splitLink で WS と HTTP を切り替え
    3. 11.3 useSubscription でリアルタイム受信
  12. 12. テスト(callerファクトリ)
    1. 12.1 createCaller でユニットテスト
    2. 12.2 認証エラーの検証
    3. 12.3 統合テスト(MSW なしで全部書ける)
  13. 13. OpenAPI 連携と各種アダプタ
    1. 13.1 trpc-openapi で REST も同時公開
    2. 13.2 Express アダプタ
    3. 13.3 Fastify アダプタ
    4. 13.4 Hono アダプタ
    5. 13.5 AWS Lambda アダプタ
  14. 14. パフォーマンスと運用 Tips
    1. 14.1 httpBatchLink で N+1 を防ぐ
    2. 14.2 staleTime / cacheTime のチューニング
    3. 14.3 ベンチマークの目安(参考値)
  15. 15. まとめとプログラミング学習のロードマップ

1. tRPC の概要と選定基準

1.1 tRPC とは何か

tRPC は TypeScript 専用の RPC フレームワークで、サーバーで定義した手続き(procedure)の型をクライアントが import type するだけで利用できるのが最大の特徴です。スキーマファイル(GraphQL の .graphql や OpenAPI の YAML)を一切持たず、TypeScript の型推論だけで 完全な型安全を実現します。コード生成も不要、サーバー側でルーターを書き換えると、保存と同時にクライアント側にエラーが出ます。

1.2 tRPC 11 の3つの強み

  1. ゼロスキーマ・ゼロコード生成: GraphQL/OpenAPI のような中間定義が不要で、開発体験が圧倒的に高速
  2. React Query との一体化: useQuery / useMutation / useInfiniteQuery がそのまま型安全に使える
  3. Next.js App Router 完全対応: RSC でも Client Components でも同じ API で呼び出せる(11.x で新設計)

1.3 REST / GraphQL / Server Actions との比較

項目 tRPC 11 REST GraphQL Server Actions
型安全 ◎(TS 型推論) △(OpenAPI 必要) ○(codegen 必要) ◎(同一プロセス)
学習コスト
外部公開 API △(trpc-openapi で可) ×
非 TS クライアント ×
サブスクリプション ◎(WS/SSE) × ×
フレーム連携 React Query 一体 汎用 Apollo/urql Next.js 専用

結論として、TypeScript フルスタック(Next.js / Remix / Vite + Express など)で社内向け API を作るのが最も得意分野です。外部公開 API を作る場合は trpc-openapi で REST 仕様も同時に出力できます。

2. インストールとプロジェクト初期化

2.1 必要なパッケージ

tRPC 11 はサーバー本体・クライアント本体・React 統合・Zod などを個別にインストールします。Next.js を例にした最小構成は以下です。

# tRPC コア + Zod + React Query
npm install @trpc/server@next @trpc/client@next @trpc/react-query@next 
            @trpc/next@next @tanstack/react-query@5 zod superjson

# 開発依存
npm install -D typescript @types/node

2.2 tsconfig.json の重要設定

tRPC は TypeScript の strict モード前提です。最低でも strictmoduleResolution: "Bundler" を有効化してください。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "jsx": "preserve",
    "incremental": true,
    "paths": { "~/*": ["./src/*"] }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx"]
}

2.3 ディレクトリ構成のおすすめ

src/
  server/
    trpc.ts             # initTRPC / context / middleware
    routers/
      _app.ts           # ルートルーター
      post.ts           # 投稿サブルーター
      user.ts           # ユーザーサブルーター
    context.ts          # createContext
  trpc/
    client.tsx          # クライアント側 Provider
    server.ts           # RSC 用 caller
  app/
    api/trpc/[trpc]/route.ts  # Next.js App Router の handler

3. tRPC の基本: initTRPC とルーター

3.1 initTRPC の初期化

tRPC は initTRPC.context<Context>().create() を 1 回だけ実行し、その戻り値からヘルパー(router / procedure / middleware)を取り出します。プロジェクト全体で 1 インスタンスのみを共有するのが鉄則です。

// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import type { Context } from "./context";

const t = initTRPC.context<Context>().create({
  transformer: superjson,
  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;

3.2 最小のルーター(Hello World)

// src/server/routers/_app.ts
import { z } from "zod";
import { publicProcedure, router } from "../trpc";

export const appRouter = router({
  hello: publicProcedure
    .input(z.object({ name: z.string() }))
    .query(({ input }) => {
      return { greeting: `Hello, ${input.name}!` };
    }),
});

// クライアントが import type する型
export type AppRouter = typeof appRouter;

3.3 ルーターのネスト(サブルーター)

// src/server/routers/post.ts
import { z } from "zod";
import { publicProcedure, router } from "../trpc";

export const postRouter = router({
  list: publicProcedure.query(() => {
    return [{ id: 1, title: "Hello tRPC" }];
  }),
  byId: publicProcedure
    .input(z.object({ id: z.number().int().positive() }))
    .query(({ input }) => {
      return { id: input.id, title: `Post #${input.id}` };
    }),
});
// src/server/routers/_app.ts(ネストしたルートルーター)
import { router } from "../trpc";
import { postRouter } from "./post";
import { userRouter } from "./user";

export const appRouter = router({
  post: postRouter,
  user: userRouter,
});

export type AppRouter = typeof appRouter;

4. Procedure: query / mutation / subscription

4.1 query: 読み取り用 procedure

query は GET 相当の副作用のない読み取りを表現します。React Query 側で自動的にキャッシュされ、useQuery から呼び出します。

// 読み取り procedure
const getUser = publicProcedure
  .input(z.object({ id: z.string().uuid() }))
  .query(async ({ input, ctx }) => {
    const user = await ctx.db.user.findUnique({ where: { id: input.id } });
    if (!user) throw new TRPCError({ code: "NOT_FOUND" });
    return user;
  });

4.2 mutation: 書き込み用 procedure

mutation は POST/PUT/DELETE 相当で、副作用のある操作を表します。useMutation から呼び出し、結果を React Query キャッシュへ反映します。

const createPost = publicProcedure
  .input(
    z.object({
      title: z.string().min(1).max(120),
      body: z.string().min(1),
    })
  )
  .mutation(async ({ input, ctx }) => {
    const post = await ctx.db.post.create({
      data: { title: input.title, body: input.body, authorId: ctx.user!.id },
    });
    return post;
  });

4.3 subscription: ストリーミング購読

tRPC 11 では Server-Sent Events(SSE)ベースのサブスクリプションが標準化されました。WebSocket も引き続きサポートされます。

// SSE Subscription(11.x 推奨)
import { observable } from "@trpc/server/observable";
import EventEmitter from "node:events";

const ee = new EventEmitter();

const onPostAdd = publicProcedure.subscription(() => {
  return observable<{ id: number; title: string }>((emit) => {
    const handler = (post: { id: number; title: string }) => emit.next(post);
    ee.on("add", handler);
    return () => ee.off("add", handler);
  });
});

5. input バリデーション(Zod 連携)

5.1 input を Zod で型付ける

tRPC は input() に Zod スキーマを渡すと、その出力型がそのまま ctx 内の input の型になります。実行時バリデーションと TS 型推論が 同時に得られます。

import { z } from "zod";

const CreateUserInput = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(40),
  age: z.number().int().min(0).max(150).optional(),
});

const createUser = publicProcedure
  .input(CreateUserInput)
  .mutation(({ input }) => {
    // input: { email: string; name: string; age?: number }
    return { ok: true, input };
  });

5.2 複数 input を chain でマージ

.input() は複数回チェインでき、すべての結果がマージされます。共通バリデーション(例: workspaceId)を別 procedure ファクトリに切り出すときに便利です。

const withWorkspace = publicProcedure.input(
  z.object({ workspaceId: z.string().uuid() })
);

const listPosts = withWorkspace
  .input(z.object({ limit: z.number().min(1).max(100).default(20) }))
  .query(({ input }) => {
    // input: { workspaceId: string; limit: number }
    return { workspaceId: input.workspaceId, limit: input.limit };
  });

5.3 output バリデーション

output() を付けると、サーバーが返す値もスキーマで検証されます。レスポンスの型崩れを実行時に検出できる「最後の砦」です。

const Post = z.object({
  id: z.string().uuid(),
  title: z.string(),
  createdAt: z.date(),
});

const getPost = publicProcedure
  .input(z.object({ id: z.string().uuid() }))
  .output(Post)
  .query(async ({ input, ctx }) => {
    return ctx.db.post.findUniqueOrThrow({ where: { id: input.id } });
  });

6. Context と Middleware

6.1 createContext: リクエストごとの依存注入

Context にはセッション・DB クライアント・ロガーなど、リクエストスコープで共有したい値を入れます。Next.js App Router の例:

// src/server/context.ts
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { db } from "~/lib/db";
import { getSession } from "~/lib/auth";

export async function createContext(opts: FetchCreateContextFnOptions) {
  const session = await getSession(opts.req);
  return {
    db,
    session,
    user: session?.user ?? null,
    headers: opts.req.headers,
  };
}

export type Context = Awaited<ReturnType<typeof createContext>>;

6.2 middleware の作り方

middleware は procedure チェインに挟める関数の合成です。ctx を変換して次へ渡せるので、認証情報の絞り込みや権限チェックに使います。

// 認証必須 middleware
import { TRPCError } from "@trpc/server";
import { middleware, publicProcedure } from "../trpc";

const isAuthed = middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({
    ctx: { ...ctx, user: ctx.user }, // user を non-null に narrowing
  });
});

export const protectedProcedure = publicProcedure.use(isAuthed);

6.3 protectedProcedure を使う

// 認証必須の procedure 例
const me = protectedProcedure.query(({ ctx }) => {
  // ctx.user は非 null として確定
  return { id: ctx.user.id, email: ctx.user.email };
});

6.4 ロガー middleware

const loggerMiddleware = middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const ms = Date.now() - start;
  console.log(`[trpc] ${type} ${path} - ${result.ok ? "OK" : "ERR"} ${ms}ms`);
  return result;
});

export const loggedProcedure = publicProcedure.use(loggerMiddleware);

6.5 Rate Limit middleware

// 簡易レートリミット(本番では Redis 等を使う)
const buckets = new Map<string, { count: number; resetAt: number }>();

const rateLimit = (max = 60, windowMs = 60_000) =>
  middleware(({ ctx, next }) => {
    const key = ctx.user?.id ?? ctx.headers.get("x-forwarded-for") ?? "anon";
    const now = Date.now();
    const bucket = buckets.get(key) ?? { count: 0, resetAt: now + windowMs };
    if (now > bucket.resetAt) {
      bucket.count = 0;
      bucket.resetAt = now + windowMs;
    }
    bucket.count++;
    buckets.set(key, bucket);
    if (bucket.count > max) {
      throw new TRPCError({ code: "TOO_MANY_REQUESTS" });
    }
    return next();
  });

export const limitedProcedure = publicProcedure.use(rateLimit(30, 60_000));

7. エラーハンドリング

7.1 TRPCError の投げ方

tRPC は HTTP ステータスに対応した code を持つ TRPCError を投げて、クライアント側で型安全に判定します。

import { TRPCError } from "@trpc/server";

const deletePost = protectedProcedure
  .input(z.object({ id: z.string().uuid() }))
  .mutation(async ({ input, ctx }) => {
    const post = await ctx.db.post.findUnique({ where: { id: input.id } });
    if (!post) throw new TRPCError({ code: "NOT_FOUND" });
    if (post.authorId !== ctx.user.id) {
      throw new TRPCError({ code: "FORBIDDEN", message: "他人の投稿は削除できません" });
    }
    await ctx.db.post.delete({ where: { id: input.id } });
    return { ok: true };
  });

7.2 errorFormatter で Zod エラーを整形

セクション 3.1 の errorFormatter により、クライアント側で error.data.zodError を参照できます。フォームのフィールド別エラー表示が一発で書けます。

// クライアント側でフィールド別エラーを表示
const createUser = trpc.user.create.useMutation();

await createUser
  .mutateAsync({ email: "bad", name: "" })
  .catch((err) => {
    const fieldErrors = err.data?.zodError?.fieldErrors;
    // { email: ["Invalid email"], name: ["String must contain at least 1..."] }
    console.log(fieldErrors);
  });

7.3 onError でサーバー側ログを集約

// Next.js App Router の handler 例
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";

export function handler(req: Request) {
  return fetchRequestHandler({
    endpoint: "/api/trpc",
    req,
    router: appRouter,
    createContext,
    onError({ error, path, type }) {
      console.error(`[trpc:${type}] ${path}:`, error);
      // Sentry / Datadog 等へも転送
    },
  });
}

8. Next.js App Router 統合

8.1 API Route(fetch アダプタ)

App Router では app/api/trpc/[trpc]/route.ts を作り、fetchRequestHandlerGET / POST としてエクスポートします。

// 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: () => createContext({ req, resHeaders: new Headers() }),
  });

export { handler as GET, handler as POST };

8.2 React Query Provider のセットアップ

// src/trpc/client.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useState } from "react";
import superjson from "superjson";
import type { AppRouter } from "~/server/routers/_app";

export const trpc = createTRPCReact<AppRouter>();

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        loggerLink({ enabled: () => process.env.NODE_ENV === "development" }),
        httpBatchLink({
          url: "/api/trpc",
          transformer: superjson,
        }),
      ],
    })
  );
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
}

8.3 ルート Layout で Provider をマウント

// src/app/layout.tsx
import { TRPCProvider } from "~/trpc/client";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>
        <TRPCProvider>{children}</TRPCProvider>
      </body>
    </html>
  );
}

8.4 RSC から直接呼ぶ(server caller)

RSC では HTTP を経由せず、同一プロセス内で procedure を直接呼べる caller を使うのが推奨です。ネットワーク往復が消え、レンダリングが高速化します。

// src/trpc/server.ts
import { appRouter } from "~/server/routers/_app";
import { createContext } from "~/server/context";

export const createCaller = async () => {
  const ctx = await createContext({ req: new Request("http://localhost") } as any);
  return appRouter.createCaller(ctx);
};
// src/app/posts/page.tsx(RSC)
import { createCaller } from "~/trpc/server";

export default async function PostsPage() {
  const caller = await createCaller();
  const posts = await caller.post.list();
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

9. クライアント側: useQuery / useMutation

9.1 useQuery で読み取り

"use client";
import { trpc } from "~/trpc/client";

export function PostList() {
  const posts = trpc.post.list.useQuery();
  if (posts.isLoading) return <p>Loading...</p>;
  if (posts.error) return <p>Error: {posts.error.message}</p>;
  return (
    <ul>
      {posts.data?.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

9.2 useMutation で書き込み

"use client";
import { trpc } from "~/trpc/client";
import { useState } from "react";

export function CreatePostForm() {
  const [title, setTitle] = useState("");
  const utils = trpc.useUtils();
  const create = trpc.post.create.useMutation({
    onSuccess: () => {
      utils.post.list.invalidate(); // キャッシュ無効化
    },
  });
  return (
    <form onSubmit={(e) => { e.preventDefault(); create.mutate({ title, body: "..." }); }}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button disabled={create.isPending}>投稿</button>
    </form>
  );
}

9.3 invalidate でキャッシュ更新

utils(useUtils())経由で、特定 procedure のキャッシュだけを再フェッチさせます。GraphQL の Apollo cache や SWR の mutate に相当します。

const utils = trpc.useUtils();

// 全 list キャッシュを無効化
await utils.post.list.invalidate();

// 入力条件で絞って無効化
await utils.post.list.invalidate({ tag: "news" });

// プリフェッチ
await utils.post.byId.prefetch({ id: 42 });

// 楽観的に setData
utils.post.list.setData(undefined, (old) => [...(old ?? []), newPost]);

9.4 Infinite Queries(ページング)

カーソルベースのページングは useInfiniteQuery で実装します。procedure 側は nextCursor を返すように作ります。

// サーバー側: cursor ベースの infinite list
const infiniteList = publicProcedure
  .input(z.object({ limit: z.number().default(20), cursor: z.string().optional() }))
  .query(async ({ input, ctx }) => {
    const items = await ctx.db.post.findMany({
      take: input.limit + 1,
      cursor: input.cursor ? { id: input.cursor } : undefined,
      orderBy: { id: "asc" },
    });
    let nextCursor: string | undefined;
    if (items.length > input.limit) nextCursor = items.pop()!.id;
    return { items, nextCursor };
  });
// クライアント側
const q = trpc.post.infiniteList.useInfiniteQuery(
  { limit: 20 },
  { getNextPageParam: (last) => last.nextCursor }
);
return (
  <>
    {q.data?.pages.flatMap((p) => p.items).map((it) => (
      <div key={it.id}>{it.title}</div>
    ))}
    <button onClick={() => q.fetchNextPage()} disabled={!q.hasNextPage}>
      もっと見る
    </button>
  </>
);

9.5 Optimistic Update(楽観的更新)

const utils = trpc.useUtils();
const toggleLike = trpc.post.toggleLike.useMutation({
  onMutate: async ({ id }) => {
    await utils.post.byId.cancel({ id });
    const prev = utils.post.byId.getData({ id });
    utils.post.byId.setData({ id }, (old) =>
      old ? { ...old, liked: !old.liked, likeCount: old.likeCount + (old.liked ? -1 : 1) } : old
    );
    return { prev };
  },
  onError: (_e, { id }, ctx) => {
    if (ctx?.prev) utils.post.byId.setData({ id }, ctx.prev);
  },
  onSettled: (_d, _e, { id }) => utils.post.byId.invalidate({ id }),
});

10. データ変換: superjson Transformer

10.1 なぜ superjson が必要か

標準 JSON では Date / Map / Set / BigInt / undefined がシリアライズできず、tRPC 越しに渡すと文字列に化けます。superjson はこれらを保ったまま JSON で運べる軽量ラッパーです。

// サーバー側 initTRPC
import superjson from "superjson";
const t = initTRPC.context<Context>().create({ transformer: superjson });
// クライアント側 createClient
import { httpBatchLink } from "@trpc/client";
import superjson from "superjson";

trpc.createClient({
  links: [httpBatchLink({ url: "/api/trpc", transformer: superjson })],
});

10.2 superjson が解決する型一覧

  • Date(new Date())
  • BigInt(1n)
  • Map / Set
  • RegExp
  • Error
  • undefined(プロパティの存在を保持)

11. WebSocket / SSE Subscription

11.1 WebSocket サーバー側設定

// src/server/ws.ts
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { appRouter } from "./routers/_app";
import { createContext } from "./context";

const wss = new WebSocketServer({ port: 3001 });
applyWSSHandler({ wss, router: appRouter, createContext: createContext as any });
console.log("WS listening on :3001");

11.2 クライアント側 splitLink で WS と HTTP を切り替え

import { createWSClient, httpBatchLink, splitLink, wsLink } from "@trpc/client";

const wsClient = createWSClient({ url: "ws://localhost:3001" });

trpc.createClient({
  links: [
    splitLink({
      condition: (op) => op.type === "subscription",
      true: wsLink({ client: wsClient }),
      false: httpBatchLink({ url: "/api/trpc" }),
    }),
  ],
});

11.3 useSubscription でリアルタイム受信

"use client";
import { trpc } from "~/trpc/client";
import { useState } from "react";

export function LiveFeed() {
  const [items, setItems] = useState<{ id: number; title: string }[]>([]);
  trpc.post.onAdd.useSubscription(undefined, {
    onData: (post) => setItems((s) => [post, ...s]),
  });
  return (
    <ul>
      {items.map((i) => (
        <li key={i.id}>{i.title}</li>
      ))}
    </ul>
  );
}

12. テスト(callerファクトリ)

12.1 createCaller でユニットテスト

tRPC ルーターは appRouter.createCaller(ctx)HTTP を介さず直接呼べるため、Vitest / Jest で純粋関数のように単体テストできます。

// tests/post.test.ts
import { describe, it, expect } from "vitest";
import { appRouter } from "~/server/routers/_app";

describe("post.byId", () => {
  it("returns the post", async () => {
    const caller = appRouter.createCaller({
      db: mockDb,
      user: { id: "u1", email: "a@b.c" },
      session: null,
      headers: new Headers(),
    } as any);
    const res = await caller.post.byId({ id: 1 });
    expect(res.id).toBe(1);
  });
});

12.2 認証エラーの検証

it("rejects when not logged in", async () => {
  const caller = appRouter.createCaller({ db: mockDb, user: null } as any);
  await expect(caller.post.create({ title: "x", body: "y" }))
    .rejects.toMatchObject({ code: "UNAUTHORIZED" });
});

12.3 統合テスト(MSW なしで全部書ける)

tRPC は HTTP を介さない caller があるため、MSW(Mock Service Worker)で REST をモックする必要がありません。サーバーとクライアントを同じプロセスで結合テストできるのが大きな利点です。

13. OpenAPI 連携と各種アダプタ

13.1 trpc-openapi で REST も同時公開

tRPC は基本的に TypeScript 専用ですが、trpc-openapi を組み合わせると 同一の procedure を REST + OpenAPI 仕様としても公開できます。社内は tRPC、外部公開は REST という二刀流が可能です。

import { OpenApiMeta } from "trpc-openapi";

const t = initTRPC.meta<OpenApiMeta>().context<Context>().create();

const getUser = t.procedure
  .meta({ openapi: { method: "GET", path: "/users/{id}" } })
  .input(z.object({ id: z.string().uuid() }))
  .output(z.object({ id: z.string(), name: z.string() }))
  .query(({ input, ctx }) => ctx.db.user.findUniqueOrThrow({ where: { id: input.id } }));
// OpenAPI ドキュメント生成
import { generateOpenApiDocument } from "trpc-openapi";

export const openApiDocument = generateOpenApiDocument(appRouter, {
  title: "My API",
  version: "1.0.0",
  baseUrl: "https://example.com/api",
});

13.2 Express アダプタ

// server.ts
import express from "express";
import * as trpcExpress from "@trpc/server/adapters/express";
import { appRouter } from "./routers/_app";
import { createContext } from "./context";

const app = express();
app.use(
  "/trpc",
  trpcExpress.createExpressMiddleware({ router: appRouter, createContext })
);
app.listen(3000);

13.3 Fastify アダプタ

import Fastify from "fastify";
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
import { appRouter } from "./routers/_app";
import { createContext } from "./context";

const server = Fastify();
server.register(fastifyTRPCPlugin, {
  prefix: "/trpc",
  trpcOptions: { router: appRouter, createContext },
});
server.listen({ port: 3000 });

13.4 Hono アダプタ

import { Hono } from "hono";
import { trpcServer } from "@hono/trpc-server";
import { appRouter } from "./routers/_app";
import { createContext } from "./context";

const app = new Hono();
app.use(
  "/trpc/*",
  trpcServer({ router: appRouter, createContext })
);
export default app;

13.5 AWS Lambda アダプタ

// handler.ts(API Gateway v2 / Lambda)
import { awsLambdaRequestHandler } from "@trpc/server/adapters/aws-lambda";
import { appRouter } from "./routers/_app";
import { createContext } from "./context";

export const handler = awsLambdaRequestHandler({
  router: appRouter,
  createContext: ({ event }) => createContext({ req: event } as any),
});

14. パフォーマンスと運用 Tips

14.1 httpBatchLink で N+1 を防ぐ

httpBatchLink同じイベントループで発生した複数の procedure 呼び出しを 1 リクエストにまとめて送信します。React で useQuery を同じ画面で 5 個呼んでも、ネットワーク往復は 1 回です。

14.2 staleTime / cacheTime のチューニング

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,      // 30秒は再フェッチしない
      gcTime: 5 * 60_000,     // 5分後にキャッシュGC
      retry: 1,
      refetchOnWindowFocus: false,
    },
  },
});

14.3 ベンチマークの目安(参考値)

項目 tRPC + httpBatch REST + fetch GraphQL + Apollo
同時 5 procedure 呼び出し 1 リクエスト 5 リクエスト 1 リクエスト
クライアントバンドル 最小 大(Apollo Client)
型生成ステップ 不要 OpenAPI codegen GraphQL codegen

※ 実測値はランタイム / ネットワーク / キャッシュ設定で大きく変わります。重要なのは「tRPC は同期的な複数 procedure 呼び出しを自動でバッチ化できる」点と、「codegen 不要で開発ループが短い」点です。

15. まとめとプログラミング学習のロードマップ

tRPC は TypeScript フルスタックのデファクトと言える地位を確立しました。Zod による input/output バリデーション、React Query との完全統合、Next.js App Router(RSC + Client Components)対応、WebSocket / SSE Subscription、各種アダプタ(Express / Fastify / Hono / AWS Lambda)、そして OpenAPI 連携まで揃った今、社内向け API では「とりあえず tRPC で始める」が最も合理的な選択肢です。

本記事のコードはそのまま create-next-app + tRPC ボイラープレートに貼り付けて動かせます。まずは app/api/trpc/[trpc]/route.ts と最小ルーター 1 つから始め、Context → Middleware → protectedProcedure → Subscription の順に拡張していくのが最短ルートです。REST からの移行は、既存ルートを 1 つずつ publicProcedure.query/mutation に置き換えるだけで完了します。

こうした 型安全フルスタックのスキルは、もはやモダン Web 開発の標準装備です。独学で詰まりやすいのは「DB 設計 + tRPC ルーター設計の組み合わせ」「認証フロー(NextAuth / Clerk)との結合」「監視・運用」の領域で、ここはメンター付きのスクールで一気に習得すると最短距離です。

tRPC / 型安全フルスタックを学べるおすすめスクール

  • テックアカデミー:TypeScript / Next.js / Node.js コースが豊富。現役エンジニアのマンツーマンメンタリングで tRPC のような新興フレームワークも質問可能
  • 侍エンジニア:完全オーダーメイドカリキュラム。「tRPC + Next.js + Prisma で SaaS を作りたい」など具体的な目標に合わせて学習計画を組める
  • DMM WEBCAMP:フロントエンドからバックエンド・API 設計まで体系的に。実務に近いチーム開発カリキュラムあり
  • レバテックカレッジ:大学生向け短期集中。TypeScript / API 開発の基礎を最短で習得し、長期インターンへ橋渡し

独学では到達しづらい「API 設計」「認証・認可」「運用監視」までを最短で身につけるなら、まずは無料カウンセリングで 自分のキャリア目標(Web 開発 / フルスタックエンジニア / 副業 / フリーランス) を相談してみてください。tRPC のような最新フレームワークをいち早くキャッチアップできる土台が、確実に短期間で手に入ります。

コメント

タイトルとURLをコピーしました