はじめに
Next.jsでAPIを構築する際、RESTやGraphQLが一般的な選択肢ですが、TypeScriptプロジェクトではtRPCが非常に強力な選択肢となります。
この記事では、tRPCをNext.jsプロジェクトに導入し、型安全なAPI通信を実現する方法を詳しく解説します。セットアップからSSR対応、エラーハンドリングまで、実践的なコード例と共に学んでいきましょう。
tRPCとは
tRPC(TypeScript Remote Procedure Call)は、クライアントとサーバー間で型安全なAPI通信を実現するライブラリです。
tRPCの主な特徴
| 特徴 | 説明 |
|---|---|
| 型安全性 | サーバーで定義した型がクライアントに自動的に推論される |
| スキーマ不要 | GraphQLのようなスキーマ定義ファイルが不要 |
| 自動補完 | IDEでの入力補完やエラー検出が強力 |
| 軽量 | バンドルサイズへの影響が最小限 |
| SSR対応 | Next.jsのSSR/SSGと統合可能 |
RESTやGraphQLとの比較
REST:
- スキーマ定義: 手動(OpenAPI等)
- 型安全性: 追加ツールが必要
- エンドポイント: URLベース
GraphQL:
- スキーマ定義: 必須(SDL)
- 型安全性: コード生成が必要
- エンドポイント: 単一
tRPC:
- スキーマ定義: 不要
- 型安全性: 自動(TypeScript)
- エンドポイント: 関数呼び出し形式
パッケージのインストール
tRPCをNext.jsに導入するには、必要なパッケージをインストールします。
# npmの場合
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod
# yarnの場合
yarn add @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod
# pnpmの場合
pnpm add @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod
各パッケージの役割は以下の通りです。
| パッケージ | 役割 |
|---|---|
@trpc/server | サーバー側でのtRPC API定義 |
@trpc/client | クライアント側でのAPI通信 |
@trpc/react-query | React Queryとの統合フック |
@trpc/next | Next.js固有の機能(SSR等) |
@tanstack/react-query | データフェッチングライブラリ |
zod | 入力バリデーションスキーマ |
プロジェクト構成
tRPCを使用するプロジェクトの推奨構成です。
src/
├── pages/
│ └── api/
│ └── trpc/
│ └── [trpc].ts # tRPCハンドラ
├── server/
│ ├── trpc.ts # tRPC初期化
│ ├── context.ts # コンテキスト定義
│ └── routers/
│ ├── _app.ts # ルートルーター
│ ├── user.ts # ユーザー関連API
│ └── post.ts # 投稿関連API
└── utils/
└── trpc.ts # クライアント設定
サーバー側の設定
tRPCの初期化
まず、tRPCの基本設定を行います。
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { type Context } from './context';
import superjson from 'superjson';
// tRPCインスタンスの作成
// 一度だけ初期化し、エクスポートして再利用する
const t = initTRPC.context<Context>().create({
// superjsonを使用してDate型などをシリアライズ可能に
transformer: superjson,
// エラーフォーマットのカスタマイズ
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
// 開発環境でのみスタックトレースを含める
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
},
};
},
});
// ルーター作成用のヘルパー
export const router = t.router;
// 公開プロシージャ(認証不要)
export const publicProcedure = t.procedure;
// 認証済みプロシージャ(ミドルウェアで認証チェック)
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
// 認証されていない場合はエラーを投げる
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'ログインが必要です',
});
}
// 認証済みユーザー情報をコンテキストに追加
return next({
ctx: {
...ctx,
// session.userが存在することを保証
session: { ...ctx.session, user: ctx.session.user },
},
});
});
コンテキストの設定
リクエストごとに利用可能なコンテキストを定義します。
// server/context.ts
import { type CreateNextContextOptions } from '@trpc/server/adapters/next';
import { type Session } from 'next-auth';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '../pages/api/auth/[...nextauth]';
import { prisma } from './db';
// コンテキストの型定義
type CreateContextOptions = {
session: Session | null;
};
// 内部用コンテキスト作成関数
const createInnerTRPCContext = (opts: CreateContextOptions) => {
return {
session: opts.session,
prisma, // Prismaクライアントを含める
};
};
// APIリクエストごとのコンテキスト作成
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
const { req, res } = opts;
// NextAuthからセッションを取得
const session = await getServerSession(req, res, authOptions);
return createInnerTRPCContext({
session,
});
};
// コンテキストの型をエクスポート
export type Context = Awaited<ReturnType<typeof createTRPCContext>>;
ルーターの作成
APIエンドポイントを定義するルーターを作成します。
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';
// ユーザー関連のルーター
export const userRouter = router({
// ユーザー情報取得(公開API)
getById: publicProcedure
// 入力バリデーション(zodスキーマ)
.input(z.object({
id: z.string().uuid('有効なUUID形式で入力してください'),
}))
// クエリ(データ取得)の実装
.query(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: { id: input.id },
select: {
id: true,
name: true,
email: true,
image: true,
createdAt: true,
},
});
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'ユーザーが見つかりません',
});
}
return user;
}),
// 現在のユーザー情報取得(認証必須API)
me: protectedProcedure.query(async ({ ctx }) => {
// protectedProcedureではctx.session.userが保証される
const user = await ctx.prisma.user.findUnique({
where: { id: ctx.session.user.id },
});
return user;
}),
// プロフィール更新(認証必須API)
updateProfile: protectedProcedure
.input(z.object({
name: z.string().min(1, '名前は必須です').max(50, '名前は50文字以内で入力してください'),
bio: z.string().max(200, '自己紹介は200文字以内で入力してください').optional(),
}))
// ミューテーション(データ変更)の実装
.mutation(async ({ ctx, input }) => {
const updatedUser = await ctx.prisma.user.update({
where: { id: ctx.session.user.id },
data: {
name: input.name,
bio: input.bio,
},
});
return updatedUser;
}),
});
// server/routers/post.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
// 投稿関連のルーター
export const postRouter = router({
// 投稿一覧取得(ページネーション対応)
list: publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().optional(), // カーソルベースページネーション
}))
.query(async ({ ctx, input }) => {
const { limit, cursor } = input;
const posts = await ctx.prisma.post.findMany({
take: limit + 1, // 次のページがあるか確認用に+1
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: 'desc' },
include: {
author: {
select: { id: true, name: true, image: true },
},
},
});
// 次のページがあるかチェック
let nextCursor: string | undefined;
if (posts.length > limit) {
const nextItem = posts.pop(); // 余分な1件を削除
nextCursor = nextItem!.id;
}
return {
posts,
nextCursor,
};
}),
// 投稿作成
create: protectedProcedure
.input(z.object({
title: z.string().min(1).max(100),
content: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const post = await ctx.prisma.post.create({
data: {
title: input.title,
content: input.content,
authorId: ctx.session.user.id,
},
});
return post;
}),
});
ルートルーターの統合
各ルーターを統合したルートルーターを作成します。
// 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;
APIハンドラの設定
Next.jsのAPIルートにtRPCハンドラを設定します。
// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';
import { createTRPCContext } from '../../../server/context';
// Next.js APIハンドラの作成
export default createNextApiHandler({
router: appRouter,
createContext: createTRPCContext,
// エラーハンドリング
onError: ({ path, error }) => {
if (process.env.NODE_ENV === 'development') {
console.error(`tRPCエラー [${path}]:`, error);
}
},
// レスポンスのバッチ処理設定
batching: {
enabled: true,
},
});
クライアント側の設定
tRPCクライアントの初期化
// utils/trpc.ts
import { httpBatchLink, loggerLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import type { AppRouter } from '../server/routers/_app';
import superjson from 'superjson';
// ベースURLの取得
function getBaseUrl() {
if (typeof window !== 'undefined') {
// ブラウザでは相対パスを使用
return '';
}
// Vercelでのデプロイ時
if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`;
}
// ローカル開発時
return `http://localhost:${process.env.PORT ?? 3000}`;
}
// tRPCクライアントの作成
export const trpc = createTRPCNext<AppRouter>({
config({ ctx }) {
return {
// データのシリアライズ設定
transformer: superjson,
// 通信リンクの設定
links: [
// 開発環境でのロギング
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === 'development' ||
(opts.direction === 'down' && opts.result instanceof Error),
}),
// HTTPバッチリンク(複数のリクエストをまとめて送信)
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
// リクエストヘッダーのカスタマイズ
headers() {
if (ctx?.req) {
// SSR時はクライアントのヘッダーを転送
const { connection: _connection, ...headers } = ctx.req.headers;
return {
...headers,
'x-ssr': '1',
};
}
return {};
},
}),
],
};
},
// SSRの有効化
ssr: true,
// SSR時のレスポンスヘッダー設定
responseMeta({ clientErrors }) {
if (clientErrors.length) {
// エラー時はキャッシュしない
return {
headers: {
'Cache-Control': 'no-store',
},
};
}
return {};
},
});
Providerの設定
// pages/_app.tsx
import type { AppProps } from 'next/app';
import { trpc } from '../utils/trpc';
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
// tRPCのHOCでラップ
// これによりReact Query Providerも自動的に設定される
export default trpc.withTRPC(MyApp);
コンポーネントでの使用
クエリ(データ取得)
// components/UserProfile.tsx
import { trpc } from '../utils/trpc';
type UserProfileProps = {
userId: string;
};
export function UserProfile({ userId }: UserProfileProps) {
// useQueryフックでデータ取得
const { data: user, isLoading, error, refetch } = trpc.user.getById.useQuery(
{ id: userId },
{
// 5分間キャッシュを保持
staleTime: 5 * 60 * 1000,
// エラー時は3回までリトライ
retry: 3,
// コンポーネントマウント時に自動取得
enabled: !!userId,
}
);
if (isLoading) {
return <div className="animate-pulse">読み込み中...</div>;
}
if (error) {
return (
<div className="text-red-500">
<p>エラー: {error.message}</p>
<button onClick={() => refetch()}>再試行</button>
</div>
);
}
if (!user) {
return <div>ユーザーが見つかりません</div>;
}
return (
<div className="p-4 border rounded-lg">
<h2 className="text-xl font-bold">{user.name}</h2>
<p className="text-gray-600">{user.email}</p>
</div>
);
}
ミューテーション(データ変更)
// components/ProfileForm.tsx
import { useState } from 'react';
import { trpc } from '../utils/trpc';
export function ProfileForm() {
const [name, setName] = useState('');
const [bio, setBio] = useState('');
// React Query Contextを取得
const utils = trpc.useUtils();
// useMutationフックでデータ変更
const updateProfile = trpc.user.updateProfile.useMutation({
// 成功時の処理
onSuccess: (data) => {
alert('プロフィールを更新しました');
// 関連するクエリを無効化して再取得
utils.user.me.invalidate();
},
// エラー時の処理
onError: (error) => {
alert(`エラー: ${error.message}`);
},
// 楽観的更新(オプション)
onMutate: async (newData) => {
// 既存のクエリをキャンセル
await utils.user.me.cancel();
// 現在のデータを保存
const previousData = utils.user.me.getData();
// 楽観的に更新
utils.user.me.setData(undefined, (old) =>
old ? { ...old, ...newData } : old
);
return { previousData };
},
// エラー時にロールバック
onSettled: () => {
utils.user.me.invalidate();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// ミューテーションを実行
updateProfile.mutate({ name, bio });
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name">名前</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="border rounded px-3 py-2"
required
/>
</div>
<div>
<label htmlFor="bio">自己紹介</label>
<textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
className="border rounded px-3 py-2"
maxLength={200}
/>
</div>
<button
type="submit"
disabled={updateProfile.isPending}
className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
>
{updateProfile.isPending ? '更新中...' : '更新'}
</button>
</form>
);
}
無限スクロール
// components/PostList.tsx
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { trpc } from '../utils/trpc';
export function PostList() {
const { ref, inView } = useInView();
// useInfiniteQueryで無限スクロール
const {
data,
isLoading,
error,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = trpc.post.list.useInfiniteQuery(
{ limit: 10 },
{
// 次のページを取得するためのカーソルを指定
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
// 要素が表示されたら次のページを読み込み
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) {
return <div>読み込み中...</div>;
}
if (error) {
return <div>エラー: {error.message}</div>;
}
return (
<div className="space-y-4">
{data?.pages.flatMap((page) =>
page.posts.map((post) => (
<article key={post.id} className="p-4 border rounded">
<h2 className="text-lg font-bold">{post.title}</h2>
<p className="text-gray-600">{post.content}</p>
<p className="text-sm text-gray-400">
投稿者: {post.author.name}
</p>
</article>
))
)}
{/* 無限スクロールのトリガー要素 */}
<div ref={ref}>
{isFetchingNextPage ? '読み込み中...' : hasNextPage ? '' : '全ての投稿を読み込みました'}
</div>
</div>
);
}
サーバーサイドレンダリング(SSR)の設定
getServerSidePropsでの使用
// pages/user/[id].tsx
import { createServerSideHelpers } from '@trpc/react-query/server';
import type { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next';
import superjson from 'superjson';
import { appRouter } from '../../server/routers/_app';
import { createTRPCContext } from '../../server/context';
import { trpc } from '../../utils/trpc';
// SSRでデータをプリフェッチ
export const getServerSideProps = async (
context: GetServerSidePropsContext<{ id: string }>
) => {
// サーバーサイドヘルパーを作成
const helpers = createServerSideHelpers({
router: appRouter,
ctx: await createTRPCContext(context),
transformer: superjson,
});
const id = context.params?.id as string;
// データをプリフェッチ
await helpers.user.getById.prefetch({ id });
return {
props: {
// プリフェッチしたデータをシリアライズ
trpcState: helpers.dehydrate(),
id,
},
};
};
export default function UserPage(
props: InferGetServerSidePropsType<typeof getServerSideProps>
) {
const { id } = props;
// SSRでプリフェッチされたデータを使用
// 初回レンダリングで即座にデータが利用可能
const { data: user } = trpc.user.getById.useQuery({ id });
return (
<div>
<h1>{user?.name}</h1>
</div>
);
}
App Router(Next.js 13以降)での使用
Server Componentsでの使用
// app/user/[id]/page.tsx
import { createCaller } from '../../../server/routers/_app';
import { createTRPCContext } from '../../../server/context';
// Server Componentから直接tRPCを呼び出す
export default async function UserPage({ params }: { params: { id: string } }) {
// サーバー側でcallerを作成
const ctx = await createTRPCContext();
const caller = createCaller(ctx);
// 直接プロシージャを呼び出し
const user = await caller.user.getById({ id: params.id });
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
Client Componentsでの使用
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from '../utils/trpc-client';
import superjson from 'superjson';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
transformer: superjson,
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
エラーハンドリング
カスタムエラーの定義
// server/errors.ts
import { TRPCError } from '@trpc/server';
// ビジネスロジック用のカスタムエラー
export class BusinessError extends TRPCError {
constructor(message: string, code: 'NOT_FOUND' | 'BAD_REQUEST' | 'CONFLICT' = 'BAD_REQUEST') {
super({ code, message });
}
}
// バリデーションエラー
export const validationError = (field: string, message: string) =>
new TRPCError({
code: 'BAD_REQUEST',
message: `${field}: ${message}`,
});
// 認証エラー
export const authError = (message = '認証が必要です') =>
new TRPCError({
code: 'UNAUTHORIZED',
message,
});
// 権限エラー
export const forbiddenError = (message = 'この操作を行う権限がありません') =>
new TRPCError({
code: 'FORBIDDEN',
message,
});
クライアント側でのエラーハンドリング
// hooks/useErrorHandler.ts
import { TRPCClientError } from '@trpc/client';
import type { AppRouter } from '../server/routers/_app';
export function useErrorHandler() {
const handleError = (error: unknown) => {
if (error instanceof TRPCClientError<AppRouter>) {
// tRPCエラーの処理
switch (error.data?.code) {
case 'UNAUTHORIZED':
// ログインページへリダイレクト
window.location.href = '/login';
break;
case 'NOT_FOUND':
alert('リソースが見つかりません');
break;
case 'BAD_REQUEST':
alert(`入力エラー: ${error.message}`);
break;
default:
alert(`エラーが発生しました: ${error.message}`);
}
} else {
// その他のエラー
console.error('予期せぬエラー:', error);
alert('予期せぬエラーが発生しました');
}
};
return { handleError };
}
テスト
ユニットテスト
// __tests__/server/routers/user.test.ts
import { describe, it, expect, vi } from 'vitest';
import { appRouter } from '../../../server/routers/_app';
import { createInnerTRPCContext } from '../../../server/context';
describe('userRouter', () => {
// 認証済みコンテキストのモック
const mockAuthContext = createInnerTRPCContext({
session: {
user: { id: 'test-user-id', name: 'Test User', email: 'test@example.com' },
expires: new Date().toISOString(),
},
});
// callerを作成
const caller = appRouter.createCaller(mockAuthContext);
it('getByIdで存在するユーザーを取得できる', async () => {
// Prismaをモック
vi.mocked(mockAuthContext.prisma.user.findUnique).mockResolvedValueOnce({
id: 'user-1',
name: 'John Doe',
email: 'john@example.com',
image: null,
createdAt: new Date(),
});
const result = await caller.user.getById({ id: 'user-1' });
expect(result).toMatchObject({
id: 'user-1',
name: 'John Doe',
});
});
it('存在しないユーザーはNOT_FOUNDエラーを返す', async () => {
vi.mocked(mockAuthContext.prisma.user.findUnique).mockResolvedValueOnce(null);
await expect(caller.user.getById({ id: 'non-existent' })).rejects.toThrow(
'ユーザーが見つかりません'
);
});
});
トラブルシューティング
よくある問題と解決策
| 問題 | 原因 | 解決策 |
|---|---|---|
| 型エラーが発生する | AppRouter型が古い | tscを再実行、IDEを再起動 |
| SSRでデータが取得できない | ssr設定が無効 | ssr: trueを設定 |
| CORSエラー | APIのURL設定ミス | getBaseUrl関数を確認 |
| 認証が効かない | コンテキスト設定ミス | createContextを確認 |
デバッグのヒント
// 開発時のログ出力
export const trpc = createTRPCNext<AppRouter>({
config({ ctx }) {
return {
links: [
loggerLink({
enabled: (opts) => {
// 全てのリクエストをログ出力
console.log('tRPC Request:', opts.path, opts.input);
return true;
},
}),
// ... 他のリンク
],
};
},
});
まとめ
tRPCをNext.jsに導入することで、以下のメリットが得られます。
- クライアントとサーバー間で型を完全に共有
- スキーマ定義ファイルの管理が不要
- IDEの自動補完とエラー検出が強力
- React Queryとの統合によるキャッシュ管理
- SSR/SSGとの統合が容易
TypeScriptを使用したNext.jsプロジェクトでは、tRPCが非常に効率的な選択肢となります。ぜひ導入を検討してみてください。