Documentation Next.js

はじめに

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-queryReact Queryとの統合フック
@trpc/nextNext.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が非常に効率的な選択肢となります。ぜひ導入を検討してみてください。

参考文献

円