Claude CodeとtRPCで型安全APIを作る実装ガイド
Claude CodeとtRPCで型安全APIを作る手順。Next.js、Zod、認可、落とし穴、実装レビューまで解説。
tRPCとClaude Codeで何を解決するのか
tRPCは、TypeScriptで書いたサーバー側のルーター定義から、クライアント側の呼び出し型を自動で共有する仕組みです。REST APIのようにOpenAPI定義を別で管理するのではなく、appRouterの型をReact側へ渡すことで、入力、戻り値、エラーの扱いを開発中に確認できます。初心者向けに言い換えると、APIの「注文票」と「受け取り票」を同じTypeScriptから作る方法です。
Claude Codeを組み合わせる価値は、単にコードを生成することではありません。既存のディレクトリ構成、認証方法、DBアクセス、React Queryの使い方、チームの命名規則を読み込ませたうえで、ルーター分割、Zodバリデーション、認可チェック、キャッシュ無効化まで同じ方針でそろえられる点にあります。tRPCは型が強いぶん、設計が雑だと「型は合っているが業務ルールは漏れている」状態になりがちです。Claude Codeには、そこを批判的にレビューさせる役割を持たせます。
この記事では、Next.js App Routerで動く標準的なTypeScript構成を例にします。DBは説明を短くするためインメモリのprojectStoreにしていますが、Prisma、Drizzle、Supabaseなどへ置き換えやすい形にしてあります。実プロジェクトでは、このサンプルをそのまま本番に置くのではなく、認証、永続化、監査ログ、レート制限を追加してください。
全体像と3つ以上のユースケース
tRPCが向いているのは、同じTypeScriptリポジトリ内でサーバーとフロントエンドを一緒に育てるケースです。特に管理画面、社内ツール、BFF、フォーム送信のように、APIの変更頻度が高く、フロント側の追従漏れが事故になりやすい場面で効きます。
| ユースケース | tRPCで得られる利点 | Claude Codeに任せる作業 | 注意点 |
|---|---|---|---|
| 管理画面CRUD | 一覧、作成、更新、削除の入力型と戻り値を共有できる | ルーター分割、Zodスキーマ、React Queryの無効化 | 認可をボタン表示だけに寄せない |
| 社内ツール | 画面とAPIを同じリポジトリで素早く変更できる | 既存DBモデルからprocedureを起こす | contextに部署情報を詰め込みすぎない |
| フォーム送信 | Zodでメール、文字数、enumを実行時にも検証できる | エラーメッセージと再送防止の実装 | 型だけを信じてruntime validationを省かない |
| BFF薄型化 | フロント専用の集約APIを小さく保てる | 外部APIレスポンスの整形とテスト生成 | キャッシュと再取得タイミングを設計する |
全体の流れは次のように考えると迷いにくくなります。
flowchart LR
UI["React component"]
Client["tRPC React client"]
Router["AppRouter"]
Procedure["protectedProcedure / adminProcedure"]
Zod["Zod validation"]
Store["DB or store"]
Review["Claude Code review"]
UI --> Client --> Router --> Procedure --> Zod --> Store
Router --> Review
Review --> Procedure
依存関係とファイル配置
まずは依存関係を入れます。既存プロジェクトにReact QueryやZodが入っている場合も、バージョンをそろえてから進めてください。
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjson
この記事のサンプルは、次のファイル配置を想定します。@/エイリアスはNext.jsの標準的なtsconfig.json設定で使える前提です。
src/
app/
api/trpc/[trpc]/route.ts
projects/project-list.tsx
server/
db.ts
trpc.ts
routers/
_app.ts
project.ts
trpc/
client.tsx
Claude Codeに最初から全ファイルを丸投げするより、上の構成を渡して「この境界を守って実装して」と指示するほうが品質は安定します。生成後は、server配下にUI都合のコードが混ざっていないか、逆にclient componentからDBへ直接触っていないかを確認します。
サーバーのcontextとprocedureを作る
最初にtRPCの土台を作ります。contextは、各procedureへ渡されるリクエスト単位の情報です。認証済みユーザー、ロール、チームID、DBクライアントなどを入れます。ただし、便利だからといって何でも入れると、テストしづらく、依存関係が見えにくいAPIになります。
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
type Role = "admin" | "member";
type Session = {
userId: string;
teamId: string;
role: Role;
};
export type Context = {
session: Session | null;
};
export async function createContext({
headers,
}: {
headers: Headers;
}): Promise<Context> {
// Demo only: replace this with auth.js, Clerk, Supabase Auth, or your own session lookup.
const roleHeader = headers.get("x-user-role");
const role: Role =
roleHeader === "admin"
? "admin"
: roleHeader === "member"
? "member"
: process.env.NODE_ENV === "production"
? "member"
: "admin";
return {
session: {
userId: headers.get("x-user-id") ?? "demo-user",
teamId: headers.get("x-team-id") ?? "demo-team",
role,
},
};
}
const t = initTRPC.context<Context>().create({
transformer: superjson,
});
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
const requireUser = t.middleware(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Login is required.",
});
}
return next({
ctx: {
session: ctx.session,
},
});
});
export const protectedProcedure = t.procedure.use(requireUser);
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.session.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin role is required.",
});
}
return next();
});
このコードで重要なのは、protectedProcedureとadminProcedureを分けることです。すべてをprotectedProcedureにしてprocedure内で毎回ifを書くと、認可漏れが起きやすくなります。Claude Codeには「新しいmutationを追加するとき、どのprocedureを使うべきか理由も書いて」と指示すると、レビューしやすい差分になります。
Zodでruntime validation付きのルーターを書く
次に、プロジェクト管理用のルーターを作ります。Zodは、TypeScriptの型チェックだけでなく、実行時に受け取った値を検証するライブラリです。ブラウザから来るデータ、外部APIから来るデータ、URLパラメータは、TypeScript上で型が付いて見えても実行時には信用できません。ここをZodで受け止めます。
// src/server/db.ts
export type ProjectStatus = "todo" | "doing" | "done";
export type Project = {
id: string;
teamId: string;
title: string;
ownerEmail: string;
status: ProjectStatus;
dueDate?: string;
createdAt: string;
};
const projects = new Map<string, Project>();
export const projectStore = {
list(input: {
teamId: string;
status?: ProjectStatus;
query?: string;
limit: number;
}) {
return [...projects.values()]
.filter((project) => project.teamId === input.teamId)
.filter((project) => !input.status || project.status === input.status)
.filter((project) => {
if (!input.query) return true;
return project.title.toLowerCase().includes(input.query.toLowerCase());
})
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, input.limit);
},
findById(id: string) {
return projects.get(id) ?? null;
},
create(input: Omit<Project, "id" | "createdAt">) {
const project: Project = {
...input,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
};
projects.set(project.id, project);
return project;
},
updateStatus(id: string, status: ProjectStatus) {
const project = projects.get(id);
if (!project) return null;
const nextProject = { ...project, status };
projects.set(id, nextProject);
return nextProject;
},
remove(id: string) {
return projects.delete(id);
},
};
// src/server/routers/project.ts
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { projectStore } from "../db";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "../trpc";
const projectStatus = z.enum(["todo", "doing", "done"]);
const listProjectsInput = z
.object({
status: projectStatus.optional(),
query: z.string().trim().max(60).optional(),
limit: z.number().int().min(1).max(50).default(20),
})
.default({ limit: 20 });
const createProjectInput = z.object({
title: z.string().trim().min(2).max(80),
ownerEmail: z.string().email(),
dueDate: z.string().datetime().optional(),
});
export const projectRouter = createTRPCRouter({
list: protectedProcedure.input(listProjectsInput).query(({ ctx, input }) => {
return projectStore.list({
teamId: ctx.session.teamId,
status: input.status,
query: input.query,
limit: input.limit,
});
}),
create: adminProcedure.input(createProjectInput).mutation(({ ctx, input }) => {
return projectStore.create({
teamId: ctx.session.teamId,
title: input.title,
ownerEmail: input.ownerEmail,
dueDate: input.dueDate,
status: "todo",
});
}),
updateStatus: protectedProcedure
.input(
z.object({
id: z.string().uuid(),
status: projectStatus,
}),
)
.mutation(({ ctx, input }) => {
const project = projectStore.findById(input.id);
if (!project || project.teamId !== ctx.session.teamId) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return projectStore.updateStatus(input.id, input.status);
}),
delete: adminProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(({ ctx, input }) => {
const project = projectStore.findById(input.id);
if (!project || project.teamId !== ctx.session.teamId) {
throw new TRPCError({ code: "NOT_FOUND" });
}
projectStore.remove(input.id);
return { ok: true };
}),
});
updateStatusでproject.teamIdを確認している点が重要です。idがUUIDなら安全、というわけではありません。別チームのIDを知っているユーザーが更新できてしまうと、型安全でもセキュリティ事故です。Claude Codeには、このようなテナント境界のチェックを必ずレビュー対象に入れます。
App Routerへ接続する
ルーターをまとめて、Next.js App RouterのRoute Handlerに接続します。
// src/server/routers/_app.ts
import { createTRPCRouter } from "../trpc";
import { projectRouter } from "./project";
export const appRouter = createTRPCRouter({
project: projectRouter,
});
export type AppRouter = typeof appRouter;
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext({ headers: req.headers }),
});
export { handler as GET, handler as POST };
ここまででサーバー側のAppRouter型が確定します。RESTでありがちな「APIは変えたがフロントの型定義を更新し忘れた」という事故は起きにくくなります。一方で、戻り値に秘密情報を入れてしまうと、その型もクライアントへ伝わります。戻り値は公開してよい形に整えて返すのが基本です。
クライアントから型安全に呼び出す
React側では、tRPCのReact Query連携を使います。mutation成功後は、関連するqueryを無効化して再取得します。ここを曖昧にすると、「作成したのに一覧に出ない」「別タブでは古いまま」のような混乱が起きます。
// src/trpc/client.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useState, type ReactNode } from "react";
import superjson from "superjson";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();
function getBaseUrl() {
if (typeof window !== "undefined") return "";
return process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
}
export function TRPCProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
transformer: superjson,
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
// src/app/projects/project-list.tsx
"use client";
import { useState } from "react";
import { trpc } from "@/trpc/client";
export function ProjectList() {
const utils = trpc.useUtils();
const [title, setTitle] = useState("");
const [ownerEmail, setOwnerEmail] = useState("owner@example.com");
const projects = trpc.project.list.useQuery({ limit: 20 });
const createProject = trpc.project.create.useMutation({
onSuccess: async () => {
setTitle("");
await utils.project.list.invalidate();
},
});
const updateStatus = trpc.project.updateStatus.useMutation({
onSuccess: async () => {
await utils.project.list.invalidate();
},
});
return (
<section>
<form
onSubmit={(event) => {
event.preventDefault();
createProject.mutate({ title, ownerEmail });
}}
>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Project title"
/>
<input
value={ownerEmail}
onChange={(event) => setOwnerEmail(event.target.value)}
placeholder="owner@example.com"
/>
<button type="submit" disabled={createProject.isPending}>
Add project
</button>
</form>
{projects.isLoading ? <p>Loading...</p> : null}
{projects.error ? <p>{projects.error.message}</p> : null}
<ul>
{projects.data?.map((project) => (
<li key={project.id}>
<strong>{project.title}</strong> {project.status}
<button
type="button"
onClick={() =>
updateStatus.mutate({ id: project.id, status: "done" })
}
>
Mark done
</button>
</li>
))}
</ul>
</section>
);
}
このUIは最低限ですが、入力型、mutation引数、戻り値、invalidate対象がすべてAppRouterから補完されます。status: "finished"のような存在しない値を渡すとTypeScriptが止めます。さらにZodが実行時にも止めるので、APIを直接叩かれた場合にも守れます。
Claude Codeへのレビュー指示
Claude Codeには「実装して」だけでなく、レビュー観点を明示します。特にtRPCでは、型のつながりが気持ちよく見えるため、認可、入力検証、キャッシュ、ルーター分割の確認が甘くなりがちです。
このNext.js App Router + tRPC実装をレビューしてください。
観点:
1. publicProcedure / protectedProcedure / adminProcedure の使い分けが正しいか
2. Zodでruntime validationされていない入力がないか
3. teamIdやuserIdのテナント境界チェックが漏れていないか
4. mutation後に必要なquery invalidationがあるか
5. routerが大きくなりすぎていないか
6. contextへ不要な依存を詰め込んでいないか
7. クライアントへ返してはいけない秘密情報が戻り値に入っていないか
問題があれば、重大度、該当ファイル、修正案、追加すべきテストを表で出してください。
この指示をCLAUDE.mdやPRテンプレートに入れておくと、毎回同じ品質で確認できます。Claude Codeに自動修正まで任せる場合も、最初は「差分を出すだけ」にして、人間が認可まわりを確認してから適用するほうが安全です。
具体的な失敗例と落とし穴
1つ目は、contextの肥大化です。ctxにDB、外部APIクライアント、feature flag、ロガー、現在の組織、請求プラン、画面都合の一時値まで入れると、procedureが何に依存しているのか見えなくなります。contextにはリクエスト単位で必要な最小情報を置き、業務ロジックはサービス層やリポジトリ層へ逃がします。
2つ目は、認可漏れです。クライアント側で管理者ボタンを隠しても、API側のadminProcedureを使っていなければ守れません。特にdelete、export、invite、billingのようなmutationは、procedure名を見るだけで権限が分かるようにします。
3つ目は、型だけを信じてZodを省くことです。TypeScriptはビルド時の約束であり、ブラウザや外部クライアントから来るJSONを実行時に保証しません。z.string().email()、z.enum()、z.number().int().min().max()のように、業務上の境界もスキーマへ入れます。
4つ目は、router分割不足です。最初はappRouterに全部書けますが、数週間で巨大化します。projectRouter、userRouter、billingRouterのようにドメイン単位で分け、共通procedureだけをtrpc.tsへ寄せるとレビューしやすくなります。
5つ目は、cacheとinvalidationの混乱です。mutationのあとに何を再取得するのかを決めないまま実装すると、一覧、詳細、件数バッジ、ダッシュボードがずれます。Claude Codeには「このmutationの影響を受けるquery keyを列挙して」と聞くと漏れを見つけやすくなります。
公式ドキュメントと内部リンク
実装前に、tRPC公式のRouters、Procedures、React Query integrationを確認してください。入力検証はZod公式ドキュメント、Route HandlerはNext.js App RouterのRoute Handlersが基準になります。
関連する基礎として、入力スキーマを深掘りするならClaude Code Zodバリデーション実践ガイド、TypeScriptの型設計を整えるならClaude Code TypeScript Tipsもあわせて読むと、tRPCの設計判断がしやすくなります。
ClaudeCodeLabでは、既存のNext.js管理画面や社内ツールを題材に、Claude CodeでtRPCルーターを整理する研修・実装相談も扱っています。自社コードの認可設計、Zodスキーマ、React Queryの無効化方針まで一緒に点検したい場合は、Claude Code研修・導入相談を出発点にしてください。
実際に試した結果
この記事の構成を小さな管理画面サンプルで試した結果、最も効果が大きかったのはprotectedProcedureとadminProcedureを先に作ってからルーターを増やす順番でした。先にCRUDを生成して後から認可を足すより、Claude Codeのレビューで「このmutationはadminProcedureではないのか」と指摘させやすくなります。Zodスキーマをprocedureの近くに置いたことで、フォーム項目の追加時もクライアント、API、検証条件の差分をまとめて確認できました。一方で、contextに便利な値を増やすほどテストが重くなったため、DBや外部APIは薄い関数へ分けるほうが保守しやすいと分かりました。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。