tRPC入門:スキーマ共有なしでフロントとバックの型をつなぐ
tRPCの使い方を一人称で解説。router/procedure、zodのinput検証、React Query連携、いつtRPCを選ぶかまで、コピペで動くTypeScriptと失敗談で。
バックエンドのレスポンスに dueDate を足した。フロントはまだ知らない。
REST時代の僕なら、ここで型定義ファイルを手で書き直して、たぶん1か所書き忘れて、本番で undefined を踏んでいました。「型は通ってるのに画面が壊れる」あの感じ。OpenAPIから型を生成する仕組みも入れてはいたけど、生成を回し忘れるとすぐズレる。
tRPCを使い始めてから、この「フロントとバックの型がズレる」事故がまるごと消えました。サーバーのルーター定義を書き換えた瞬間、フロント側の project.dueDate に補完が出て、書き忘れていた箇所が赤線で光る。スキーマファイルも、コード生成のステップも、一切ありません。
この記事では、tRPCの土台(router と procedure)、zodでの入力検証、React Queryとの連携を、コピペで動くコードでひと通り作ります。あわせて「いつtRPCを選び、いつGraphQLやRESTにするか」も僕の基準で書きます。サンプルはNext.js App Routerですが、考え方はExpressでも同じです。
この記事の要点
- tRPCは、サーバーの
appRouterの型をフロントへ渡すだけで、入力・戻り値・エラーの型がつながる。スキーマ共有もコード生成も要らない。 - 効くのは「同じTypeScriptモノレポでフロントとバックを一緒に育てる」とき。管理画面、社内ツール、BFFが典型。
- 土台は
router(手続きの束)とprocedure(1つのAPI)。入力は zod で実行時にも検証する。型だけ信じると外から叩かれて落ちる。 - React Queryと連携すると、mutation後の
invalidate(再取得)まで型補完で書ける。 - ブラウザ公開API・別言語クライアント・チームをまたぐ契約が要るならtRPCは不向き。そこはRESTやGraphQLの出番。
tRPCは結局なにをしてくれるのか
ひとことで言うと、サーバーで書いた関数を、フロントから型付きでそのまま呼べるようにする道具です。
REST APIだと、サーバーは POST /api/projects というURLを公開し、フロントは fetch でそこを叩いて、返ってきたJSONを「たぶんこういう形」と信じて使います。この「たぶん」を埋めるために、OpenAPI定義を書いて型を生成したり、手で型を二重管理したりする。tRPCはこの中間をまるごと飛ばします。
仕組みは拍子抜けするほど単純です。サーバーで appRouter という巨大なオブジェクトを定義し、その型だけを export type AppRouter = typeof appRouter でフロントに渡す。値(実装)は渡らず、型情報だけが渡る。フロントはその型から trpc.project.list.useQuery() のような呼び出しを生やします。URLもHTTPメソッドも、裏でtRPCが面倒を見るので、書く人は意識しません。
初心者向けに言い換えると、APIの「注文票」と「受け取り票」を、別々の紙ではなく同じTypeScriptの1枚から刷る方法です。注文票(入力の形)を直せば、受け取り票(フロントの呼び出し)も同時に直る。ズレようがない。
いつtRPCを選び、いつ選ばないか
ここが一番大事なので先に書きます。tRPCは万能ではありません。選んでいい場面が決まっています。
向いているのは、フロントとバックが同じTypeScriptリポジトリにいて、APIが頻繁に変わるケースです。逆に、APIを外部に公開する・別言語のクライアントがいる・チームをまたいで契約を固定したい、といった場面では素直にRESTやGraphQLを使ったほうが幸せになります。
| やりたいこと | 向いている選択 | 理由 |
|---|---|---|
| 同一TSモノレポの管理画面・社内ツール | tRPC | 型が直結。スキーマ管理ゼロで変更が速い |
| ブラウザ公開API・モバイル(別言語)から叩く | REST | URL契約が言語非依存。tRPCの型恩恵が消える |
| クライアントが取得項目を細かく選びたい | GraphQL | クエリで欲しいフィールドだけ取れる |
| 外部パートナーに渡す安定した契約 | REST / GraphQL | バージョン管理・ドキュメント文化が成熟 |
tRPCの型補完は「同じ言語・同じリポジトリ」という前提があって初めて効きます。別言語のモバイルアプリから叩いた瞬間、TypeScriptの型は届かないので、tRPCの一番おいしい部分が消えます。そうなると、ただのRPCを自前で書いているのと変わりません。
別アプローチの作り方は、フィールドを選んで取りたいならGraphQL入門からN+1・DataLoaderまで。RESTと住み分けて作る実装手順、URL契約をきっちり固めたいならClaude CodeでREST API設計を固める: エンドポイント、エラー、ページング、冪等性にまとめてあります。この3つは「対立」ではなく住み分けです。
まず土台:router と procedure を作る
tRPCの登場人物は2つだけ覚えれば動きます。procedure(1つのAPI) と、それを束ねる router です。
procedureには3種類あります。誰でも呼べる publicProcedure、ログイン必須の protectedProcedure、管理者だけの adminProcedure。最初にこの3つを分けて用意しておくのが、後で効いてくるコツです。全部を publicProcedure で作って中で if (権限) を書くと、必ずどこかで書き忘れて認可漏れが起きます。
依存を入れます。
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjson
次に土台。context は、各procedureに毎回渡されるリクエスト単位の情報です。ログイン中のユーザーやロールを入れます。便利だからと何でも詰めると、テストしづらい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;
};
// デモ用。本番ではauth.js / Clerk / Supabase Authなどに置き換える
export async function createContext({
headers,
}: {
headers: Headers;
}): Promise<Context> {
const roleHeader = headers.get("x-user-role");
const role: Role = roleHeader === "admin" ? "admin" : "member";
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: "ログインが必要です" });
}
// ここでsessionをnon-nullに絞り込んで次へ渡す
return next({ ctx: { session: ctx.session } });
});
export const protectedProcedure = t.procedure.use(requireUser);
// 管理者だけ。protectedの上に重ねる
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.session.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "管理者権限が必要です" });
}
return next();
});
adminProcedure を protectedProcedure の上に積んでいるのがポイントです。これで「ログイン済み」かつ「管理者」が型と実行時の両方で保証されます。新しいmutationを書くとき、delete や invite のような破壊的な操作には adminProcedure を選ぶ、と決めておけば、procedure名を見るだけで権限が分かります。
input検証:型だけ信じると外から落とされる
ここで一番強調したいのが zod です。
TypeScriptの型は「ビルド時の約束」でしかありません。ブラウザや外部クライアントから飛んでくるJSONは、TypeScript上では型が付いて見えても、実行時には何が入っているか分かりません。limit に文字列の "abc" が来ても、TypeScriptは止めてくれない。ここをzodで受け止めます。
tRPCのprocedureに .input(zodスキーマ) を付けると、(1)その型がフロントの引数に伝わり、(2)実行時にもサーバーで検証される、という二重の効果が出ます。型と検証が1か所にまとまるのがtRPCの気持ちいいところです。
まず簡単なインメモリのストア。Prisma・Drizzle・Supabaseに置き換えやすい形にしてあります。
// 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; limit: number }) {
return [...projects.values()]
.filter((p) => p.teamId === input.teamId)
.filter((p) => !input.status || p.status === input.status)
.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 next = { ...project, status };
projects.set(id, next);
return next;
},
};
次が本体のルーターです。procedureごとに .input() でzodスキーマを付けています。
// 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"]);
// 一覧の入力。limitは1〜50、未指定なら20
const listInput = z
.object({
status: projectStatus.optional(),
limit: z.number().int().min(1).max(50).default(20),
})
.default({ limit: 20 });
// 作成の入力。業務ルール(文字数・メール形式)もスキーマに入れる
const createInput = z.object({
title: z.string().trim().min(2).max(80),
ownerEmail: z.string().email(),
dueDate: z.string().datetime().optional(),
});
export const projectRouter = createTRPCRouter({
// 読み取りはquery
list: protectedProcedure.input(listInput).query(({ ctx, input }) => {
return projectStore.list({
teamId: ctx.session.teamId,
status: input.status,
limit: input.limit,
});
}),
// 書き込みはmutation。作成は管理者だけ
create: adminProcedure.input(createInput).mutation(({ ctx, input }) => {
return projectStore.create({
teamId: ctx.session.teamId,
title: input.title,
ownerEmail: input.ownerEmail,
dueDate: input.dueDate,
status: "todo",
});
}),
// 状態更新。idはUUIDだが、それだけでは安全ではない(下のコメント)
updateStatus: protectedProcedure
.input(z.object({ id: z.string().uuid(), status: projectStatus }))
.mutation(({ ctx, input }) => {
const project = projectStore.findById(input.id);
// 別チームのidを知っていても触らせない(テナント境界)
if (!project || project.teamId !== ctx.session.teamId) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return projectStore.updateStatus(input.id, input.status);
}),
});
updateStatus で project.teamId !== ctx.session.teamId を確認している行が地味に重要です。id がUUIDだから安全、ではありません。他チームのIDを知っているユーザーが更新できてしまったら、型は完璧でもセキュリティ事故です。zodは「形」を守りますが、「誰のデータか」は自分で守る必要があります。
App Routerにつなぐ
ルーターをまとめて、Next.jsのRoute Handlerに1本だけ繋ぎます。ここで AppRouter の型が確定し、これがフロントへの「契約書」になります。
// src/server/routers/_app.ts
import { createTRPCRouter } from "../trpc";
import { projectRouter } from "./project";
export const appRouter = createTRPCRouter({
project: projectRouter,
});
// 値ではなく型だけをexportする(サーバーコードがフロントへ漏れない)
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 };
戻り値の型もそのままフロントへ流れる、という点だけ覚えておいてください。便利な反面、戻り値にパスワードハッシュやトークンを入れると、その型ごとクライアントに見えてしまいます。返すのは公開していい形だけに整えます。
クライアントから型安全に呼ぶ
フロント側は、tRPCのReact Query連携を使います。createTRPCReact<AppRouter>() に先ほどの型を渡すと、trpc.project.list.useQuery() のようなフックが型付きで生えます。
// 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 }) {
// SSRで毎リクエスト新しいclientになるようuseStateで保持する
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>
);
}
そして画面。useMutation の onSuccess で utils.project.list.invalidate() を呼び、一覧を再取得しています。ここを忘れると「作成したのに一覧に出ない」が起きます。
// 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 projects = trpc.project.list.useQuery({ limit: 20 });
const createProject = trpc.project.create.useMutation({
onSuccess: async () => {
setTitle("");
// 作成が成功したら一覧を再取得する
await utils.project.list.invalidate();
},
});
return (
<section>
<form
onSubmit={(e) => {
e.preventDefault();
// 引数の型はcreateInputから補完される。存在しない項目は赤線になる
createProject.mutate({ title, ownerEmail: "[email protected]" });
}}
>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button type="submit" disabled={createProject.isPending}>
追加
</button>
</form>
{projects.isLoading ? <p>読み込み中...</p> : null}
{projects.error ? <p>{projects.error.message}</p> : null}
<ul>
{projects.data?.map((project) => (
<li key={project.id}>
{project.title} — {project.status}
</li>
))}
</ul>
</section>
);
}
このUIは最小限ですが、入力型・mutation引数・戻り値・invalidate 対象まで、すべて AppRouter から補完されます。status: "finished" のような存在しない値を渡せばTypeScriptが止める。さらにzodが実行時にも止めるので、APIを直接叩かれても守れます。冒頭の dueDate の話に戻ると、サーバーで dueDate を足した瞬間、ここの project.dueDate に補完が出る。これがtRPCの全部です。
Claude Codeに頼むときのレビュー観点
tRPCは型のつながりが気持ちよく見えるぶん、認可・入力検証・キャッシュの確認が甘くなりがちです。だからClaude Codeには「実装して」だけでなく、レビュー観点を渡します。これを CLAUDE.md に貼っておくと毎回同じ品質で見てくれます。
このNext.js App Router + tRPC実装をレビューしてください。
観点:
1. public / protected / adminProcedure の使い分けが正しいか
2. zodでruntime validationされていない入力がないか
3. teamIdやuserIdのテナント境界チェックが漏れていないか
4. mutation後に必要なquery invalidationがあるか
5. routerが大きくなりすぎていないか(ドメイン単位で分割すべきか)
6. クライアントへ返してはいけない秘密情報が戻り値に入っていないか
問題があれば、重大度・該当ファイル・修正案・追加すべきテストを表で出してください。
自動修正まで任せる場合も、最初は「差分を出すだけ」にして、認可まわりだけは僕が目で見てから適用しています。
僕がハマった落とし穴
正直に書きます。最初のtRPC導入はキレイに見えて中身がスカスカでした。
ひとつ目は、context の肥大化。便利だからと ctx にDB・外部APIクライアント・feature flag・ロガー・請求プラン・画面都合の一時値まで詰めたら、procedureが何に依存しているのか分からなくなりました。今はリクエスト単位の最小情報だけ置いて、業務ロジックは別の関数に逃がしています。テストもこのほうが軽い。
ふたつ目は、型を信じてzodを省いたこと。「TypeScriptで型が付いてるからいいだろう」と .input() を付けずに進めたら、フォームから空文字や巨大な文字列が普通に通ってDBに入りました。型はビルド時の約束で、ランタイムは守ってくれない。z.string().email() や z.number().int().max(50) のような業務上の境界は、必ずスキーマに書くようにしました。詳しくはZodの入力バリデーションをClaude Codeに任せて型安全にする手順に切り出してあります。
みっつ目は、router を1ファイルに全部書いたこと。最初は appRouter に直接書けて気持ちいいのですが、数週間で巨大化して差分が読めなくなりました。projectRouter userRouter billingRouter のようにドメイン単位で割り、共通のprocedureだけ trpc.ts に寄せたら、レビューがぐっと楽になりました。
よくある質問
Q. tRPCとGraphQL、どっちを使えばいい? A. クライアントが「欲しいフィールドだけ選んで取る」必要があるならGraphQL。そうでなく、同じTSモノレポでサーバーとフロントを一緒に変えていくならtRPCのほうが軽くて速いです。GraphQLはスキーマ定義とリゾルバの手間があるぶん、外部公開や多クライアントで強みが出ます。
Q. tRPCを使うとRESTは要らなくなる? A. いいえ。ブラウザ公開API、Webhook受け口、別言語のモバイル/サーバーから叩かれる口は、URL契約が言語非依存なRESTのほうが向いています。社内向けはtRPC、外向けはREST、と1つのアプリで併用して問題ありません。
Q. zodは必須?型があるなら省けない?
A. 実質必須です。TypeScriptの型はビルド時にしか効かず、外から来るJSONを実行時に保証しません。.input() にzodを付けると、フロントへの型伝播とサーバーの実行時検証が同時に手に入ります。省くと外から壊されます。
Q. Next.jsのServer Componentからも呼べる?
A. 呼べます。クライアントの useQuery とは別に、サーバー側で直接呼ぶためのcaller(appRouter.createCaller(ctx))があります。RSCでの初期表示はcaller、操作はクライアントのReact Query、という使い分けが定番です。
Q. 既存のExpress APIに後付けできる? A. できます。tRPCはNext.js専用ではなく、Expressやstandaloneのアダプタが公式にあります。既存RESTを残したまま、新規の社内向けエンドポイントだけtRPCで足す、という入れ方が現実的です。
実際に試した結果
小さな管理画面サンプルで通しで作ってみて、一番効いたのは protectedProcedure と adminProcedure を先に作ってからルーターを増やす順番でした。先にCRUDを生成して後から認可を足すと抜けが出ますが、procedureを先に分けておくと、Claude Codeのレビューで「このmutationはadminProcedureでは?」と指摘させやすい。
zodスキーマをprocedureのすぐ近くに置いたのも当たりでした。フォーム項目を1つ足すとき、クライアント・API・検証条件の差分が同じ場所にまとまり、まとめて確認できる。冒頭の「dueDate を足してフロントがズレる」事故は、この構成にしてから一度も起きていません。逆に、便利さに負けてcontextに値を増やすほどテストが重くなったので、DBや外部APIは薄い関数に分けるのが正解でした。
公式の最新仕様はtRPC公式ドキュメントで確認してから入れるのが安全です。手を動かして詰まったところは、ClaudeCodeLabの研修・導入相談でも一緒に点検しています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。