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

中級 | 45分 で読める | 2025.12.02

公式ドキュメント

今回やること

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

この記事では、tRPCを導入する判断基準、最小構成、入力検証、認証付きprocedure、クライアント呼び出しまでを順番に確認します。

ポイント: tRPCではサーバー側のルーター定義からクライアントの型が自動推論されます。OpenAPIスキーマやコード生成が不要なため、開発速度が大幅に向上します。

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はTypeScript monorepoに最適化されています。サーバーとクライアントが異なる言語の場合や、外部向けOpenAPI公開が必要な場合はREST/GraphQLを検討してください。

実践メモ: tRPCはTanStack Queryと統合されているため、キャッシュ管理・楽観的更新・無限スクロールがそのまま使えます。

セットアップ

インストール

# 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を導入したら、次を確認します。

確認項目見ること
型補完クライアント側でprocedure名と入力型が補完される
入力検証不正な入力でZodエラーが返る
認証境界未ログイン時にprotected procedureが失敗する
エラー表示クライアントでエラー内容を握りつぶしていない

型が効かない場合は、サーバー側の AppRouter 型をクライアントへ正しくexport/importできているか確認します。

練習

次の小さなAPIを作ってください。

  1. post.list で投稿一覧を返す
  2. post.create でタイトルと本文を受け取る
  3. z.object で入力を検証する
  4. 未ログインでは post.create を実行できないようにする
  5. クライアント側で成功時と失敗時の表示を分ける

次のステップ

入力検証を先に固めたい場合は Zodバリデーション入門 を確認してください。RESTとの違いを整理したい場合は REST APIのテストを書こうGraphQL実装ガイド が比較材料になります。

まとめ

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

tRPCの利点

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

採用判断

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

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

参考リンク

参考リソース

← 一覧に戻る
PR
PR
PR
PR