api

tRPC実践ガイド - エンドツーエンド型安全なAPIの構築

2025.12.02

tRPCは、TypeScriptプロジェクトでエンドツーエンドの型安全性を提供するRPCフレームワークです。APIスキーマの定義なしに、サーバーとクライアント間で完全な型推論を実現します。

tRPCの基本概念

従来のAPI vs tRPC

従来のREST API:

sequenceDiagram
    participant Client
    participant Server
    Client->>Server: fetch('/api/users')
    Server-->>Client: JSON response
    Note over Client: response: any<br/>手動で型アサーション

tRPC:

sequenceDiagram
    participant Client
    participant Server
    Client->>Server: trpc.user.getById({id})
    Server-->>Client: return user
    Note over Client: 型推論: User<br/>自動的に型安全

セットアップ

インストール

# tRPCコアパッケージ
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next

# 必要な依存関係
npm install @tanstack/react-query zod superjson

プロジェクト構造

src/
├── server/
│   ├── trpc.ts           # tRPC初期化
│   ├── context.ts        # コンテキスト定義
│   └── routers/
│       ├── _app.ts       # ルートルーター
│       ├── user.ts       # ユーザールーター
│       └── post.ts       # 投稿ルーター
├── app/
│   ├── api/trpc/[trpc]/
│   │   └── route.ts      # APIルートハンドラー
│   └── providers.tsx     # クライアントプロバイダー
└── utils/
    └── trpc.ts           # クライアントフック

サーバーサイド設定

tRPC初期化

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { 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 protectedProcedure = t.procedure.use(
  t.middleware(async ({ ctx, next }) => {
    if (!ctx.session?.user) {
      throw new TRPCError({ code: 'UNAUTHORIZED' });
    }
    return next({
      ctx: {
        ...ctx,
        session: ctx.session,
        user: ctx.session.user,
      },
    });
  })
);

// 管理者プロシージャ
export const adminProcedure = protectedProcedure.use(
  t.middleware(async ({ ctx, next }) => {
    if (ctx.user.role !== 'admin') {
      throw new TRPCError({ code: 'FORBIDDEN' });
    }
    return next({ ctx });
  })
);

コンテキスト定義

// server/context.ts
import { getServerSession } from 'next-auth';
import { prisma } from '@/lib/prisma';
import { authOptions } from '@/lib/auth';

export async function createContext(opts: { headers: Headers }) {
  const session = await getServerSession(authOptions);

  return {
    prisma,
    session,
    headers: opts.headers,
  };
}

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

ルーター定義

// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure, adminProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';

// 入力スキーマ
const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(50),
  password: z.string().min(8),
});

const updateUserSchema = z.object({
  name: z.string().min(2).max(50).optional(),
  bio: z.string().max(500).optional(),
});

export const userRouter = router({
  // 公開: ユーザー一覧取得
  list: publicProcedure
    .input(z.object({
      limit: z.number().min(1).max(100).default(10),
      cursor: z.string().nullish(),
    }))
    .query(async ({ ctx, input }) => {
      const { limit, cursor } = input;

      const users = await ctx.prisma.user.findMany({
        take: limit + 1,
        cursor: cursor ? { id: cursor } : undefined,
        orderBy: { createdAt: 'desc' },
        select: {
          id: true,
          name: true,
          email: true,
          image: true,
          createdAt: true,
        },
      });

      let nextCursor: typeof cursor = undefined;
      if (users.length > limit) {
        const nextItem = users.pop();
        nextCursor = nextItem!.id;
      }

      return {
        users,
        nextCursor,
      };
    }),

  // 公開: ユーザー詳細取得
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const user = await ctx.prisma.user.findUnique({
        where: { id: input.id },
        select: {
          id: true,
          name: true,
          email: true,
          image: true,
          bio: true,
          createdAt: true,
          _count: {
            select: { posts: true },
          },
        },
      });

      if (!user) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'User not found',
        });
      }

      return user;
    }),

  // 認証必須: プロフィール取得
  me: protectedProcedure.query(async ({ ctx }) => {
    return ctx.prisma.user.findUnique({
      where: { id: ctx.user.id },
      select: {
        id: true,
        name: true,
        email: true,
        image: true,
        bio: true,
        role: true,
      },
    });
  }),

  // 認証必須: プロフィール更新
  updateProfile: protectedProcedure
    .input(updateUserSchema)
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.user.update({
        where: { id: ctx.user.id },
        data: input,
      });
    }),

  // 管理者: ユーザー作成
  create: adminProcedure
    .input(createUserSchema)
    .mutation(async ({ ctx, input }) => {
      const exists = await ctx.prisma.user.findUnique({
        where: { email: input.email },
      });

      if (exists) {
        throw new TRPCError({
          code: 'CONFLICT',
          message: 'Email already exists',
        });
      }

      const hashedPassword = await hashPassword(input.password);

      return ctx.prisma.user.create({
        data: {
          email: input.email,
          name: input.name,
          password: hashedPassword,
        },
      });
    }),

  // 管理者: ユーザー削除
  delete: adminProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.user.delete({
        where: { id: input.id },
      });
    }),
});

投稿ルーター

// server/routers/post.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';

const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  published: z.boolean().default(false),
});

export const postRouter = router({
  // 公開済み投稿一覧
  list: publicProcedure
    .input(z.object({
      limit: z.number().min(1).max(50).default(10),
      cursor: z.string().nullish(),
      authorId: z.string().optional(),
    }))
    .query(async ({ ctx, input }) => {
      const { limit, cursor, authorId } = input;

      const posts = await ctx.prisma.post.findMany({
        take: limit + 1,
        cursor: cursor ? { id: cursor } : undefined,
        where: {
          published: true,
          ...(authorId && { authorId }),
        },
        orderBy: { createdAt: 'desc' },
        include: {
          author: {
            select: { id: true, name: true, image: true },
          },
          _count: {
            select: { comments: true, likes: true },
          },
        },
      });

      let nextCursor: typeof cursor = undefined;
      if (posts.length > limit) {
        const nextItem = posts.pop();
        nextCursor = nextItem!.id;
      }

      return { posts, nextCursor };
    }),

  // 投稿詳細
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const post = await ctx.prisma.post.findUnique({
        where: { id: input.id },
        include: {
          author: {
            select: { id: true, name: true, image: true },
          },
          comments: {
            include: {
              author: {
                select: { id: true, name: true, image: true },
              },
            },
            orderBy: { createdAt: 'desc' },
          },
        },
      });

      if (!post) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Post not found',
        });
      }

      // 非公開投稿は作者のみ閲覧可能
      if (!post.published && post.authorId !== ctx.session?.user?.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Access denied',
        });
      }

      return post;
    }),

  // 投稿作成
  create: protectedProcedure
    .input(createPostSchema)
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),

  // 投稿更新
  update: protectedProcedure
    .input(z.object({
      id: z.string(),
      data: createPostSchema.partial(),
    }))
    .mutation(async ({ ctx, input }) => {
      const post = await ctx.prisma.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: 'Not your post',
        });
      }

      return ctx.prisma.post.update({
        where: { id: input.id },
        data: input.data,
      });
    }),

  // いいね
  like: protectedProcedure
    .input(z.object({ postId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const existing = await ctx.prisma.like.findUnique({
        where: {
          userId_postId: {
            userId: ctx.user.id,
            postId: input.postId,
          },
        },
      });

      if (existing) {
        // いいね解除
        await ctx.prisma.like.delete({
          where: { id: existing.id },
        });
        return { liked: false };
      }

      // いいね
      await ctx.prisma.like.create({
        data: {
          userId: ctx.user.id,
          postId: input.postId,
        },
      });
      return { liked: true };
    }),
});

ルートルーター

// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';

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

export type AppRouter = typeof appRouter;

Next.js統合

APIルートハンドラー

// 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({ headers: req.headers }),
    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 };

クライアントフック

// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';

export const trpc = createTRPCReact<AppRouter>();

プロバイダー設定

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { useState } from 'react';
import superjson from 'superjson';
import { trpc } from '@/utils/trpc';

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,
            refetchOnWindowFocus: false,
          },
        },
      })
  );

  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        loggerLink({
          enabled: (opts) =>
            process.env.NODE_ENV === 'development' ||
            (opts.direction === 'down' && opts.result instanceof Error),
        }),
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
          headers: () => {
            return {
              'x-trpc-source': 'react',
            };
          },
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}

クライアントサイド使用例

データ取得

// app/users/page.tsx
'use client';

import { trpc } from '@/utils/trpc';

export default function UsersPage() {
  // 基本的なクエリ
  const { data, isLoading, error } = trpc.user.list.useQuery({
    limit: 20,
  });

  // 無限スクロール
  const {
    data: infiniteData,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = trpc.user.list.useInfiniteQuery(
    { limit: 10 },
    {
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    }
  );

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {data?.users.map((user) => (
          <li key={user.id}>
            {user.name} - {user.email}
          </li>
        ))}
      </ul>
    </div>
  );
}

データ更新

// app/profile/page.tsx
'use client';

import { trpc } from '@/utils/trpc';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const updateProfileSchema = z.object({
  name: z.string().min(2).max(50),
  bio: z.string().max(500).optional(),
});

type UpdateProfileInput = z.infer<typeof updateProfileSchema>;

export default function ProfilePage() {
  const utils = trpc.useUtils();

  // プロフィール取得
  const { data: profile, isLoading } = trpc.user.me.useQuery();

  // 更新ミューテーション
  const updateProfile = trpc.user.updateProfile.useMutation({
    onSuccess: () => {
      // キャッシュの無効化
      utils.user.me.invalidate();
    },
  });

  const form = useForm<UpdateProfileInput>({
    resolver: zodResolver(updateProfileSchema),
    defaultValues: {
      name: profile?.name || '',
      bio: profile?.bio || '',
    },
  });

  const onSubmit = async (data: UpdateProfileInput) => {
    await updateProfile.mutateAsync(data);
  };

  if (isLoading) return <div>Loading...</div>;

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="name">Name</label>
        <input {...form.register('name')} />
        {form.formState.errors.name && (
          <p>{form.formState.errors.name.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="bio">Bio</label>
        <textarea {...form.register('bio')} />
      </div>

      <button
        type="submit"
        disabled={updateProfile.isPending}
      >
        {updateProfile.isPending ? 'Saving...' : 'Save'}
      </button>

      {updateProfile.error && (
        <p className="text-red-500">
          {updateProfile.error.message}
        </p>
      )}
    </form>
  );
}

楽観的更新

// いいねボタンの楽観的更新
function LikeButton({ postId, initialLiked }: {
  postId: string;
  initialLiked: boolean;
}) {
  const utils = trpc.useUtils();

  const likeMutation = trpc.post.like.useMutation({
    // 楽観的更新
    onMutate: async ({ postId }) => {
      // 進行中のクエリをキャンセル
      await utils.post.getById.cancel({ id: postId });

      // 現在のデータを保存
      const previousPost = utils.post.getById.getData({ id: postId });

      // 楽観的に更新
      utils.post.getById.setData({ id: postId }, (old) => {
        if (!old) return old;
        return {
          ...old,
          _count: {
            ...old._count,
            likes: old._count.likes + (previousPost?.liked ? -1 : 1),
          },
          liked: !previousPost?.liked,
        };
      });

      return { previousPost };
    },
    // エラー時にロールバック
    onError: (err, { postId }, context) => {
      utils.post.getById.setData(
        { id: postId },
        context?.previousPost
      );
    },
    // 完了時に再フェッチ
    onSettled: (_, __, { postId }) => {
      utils.post.getById.invalidate({ id: postId });
    },
  });

  return (
    <button
      onClick={() => likeMutation.mutate({ postId })}
      disabled={likeMutation.isPending}
    >
      {initialLiked ? '❤️' : '🤍'}
    </button>
  );
}

Server Components対応

// Server Component用のcaller
// server/api.ts
import 'server-only';
import { appRouter } from './routers/_app';
import { createContext } from './context';
import { headers } from 'next/headers';

export async function createCaller() {
  const context = await createContext({
    headers: headers(),
  });
  return appRouter.createCaller(context);
}

// 使用例
// app/posts/[id]/page.tsx
import { createCaller } from '@/server/api';

export default async function PostPage({
  params,
}: {
  params: { id: string };
}) {
  const trpc = await createCaller();
  const post = await trpc.post.getById({ id: params.id });

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

まとめ

tRPCは、TypeScriptプロジェクトでエンドツーエンドの型安全性を実現する強力なツールです。

tRPCの利点

特徴メリット
型安全性APIスキーマ定義不要
開発体験自動補完とリファクタリング
バンドルサイズランタイムオーバーヘッド最小
学習コストReact Query知識を活用

採用判断

  • 推奨: TypeScript monorepo、Next.js
  • 注意: OpenAPI必要時、多言語クライアント

tRPCにより、フルスタックTypeScript開発の生産性が大幅に向上します。

参考リンク

← 一覧に戻る