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開発の生産性が大幅に向上します。