Claude Code で型安全な API を作る(oRPC + Zod)
oRPC と Zod を使って、Next.js 16 で完全に型安全な API 層を Claude Code で作る方法。スキーマ変更が本番ではなくビルドを壊すようにする。
本当に効いてくる型安全な API の姿は、図ではなくこれです。Zod スキーマのフィールド名を変えると、それを読むクライアントコンポーネントが、ファイルを保存する前にエディタで赤くなる。更新すべき Postman コレクションはありません。3 日後に本番でランタイム 500 が出ることもありません。エラーは、あなたが何かを壊したまさにその瞬間に、直すべきまさにそのファイルに現れます。
それが、Next.js 16 アプリで oRPC と Zod が手に入れさせてくれるものです。そして Claude Code は、それを最初のパスで正しく繋ぐ速い方法です。
このセットアップが防ぐバグ
典型的な Next.js の API ルートでよくある失敗はこうです。{ id, title, done } を返すルートハンドラを定義する。どこか別の場所で、コンポーネントが task.completed を分割代入している。6 週間前に誰かがサーバー側でフィールド名を変えたのに、クライアントを誰も更新しなかったからです。TypeScript はこれを捕まえられません。fetch() が any(あるいは自分で手書きして今は盲目的に信じている型)を返すからです。バグは出荷されます。それに気づくのは、コンパイラではなく Sentry のアラートからです。
「データベースからフロントエンドまで型安全」とは、データの形について信頼できる情報源がちょうど 1 つ(Zod スキーマ)あり、サーバーハンドラもクライアント呼び出しも、その同じスキーマから型付けされる、ということです。スキーマを変えれば、その形が流れるすべての場所が、直すまで型エラーで光ります。
oRPC と Zod をインストールする
oRPC は小さなパッケージいくつかとして出荷されます。手続きを定義してハンドラとしてマウントするサーバーパッケージと、完全な推論つきでそれを呼ぶクライアントパッケージです。両方を Zod と一緒にインストールします。
npm install @orpc/server @orpc/client zod他には何も要りません。コード生成のステップも、スキーマ変更のたびに走らせる CLI もありません。型は、再生成を忘れずにいなければならないビルド成果物からではなく、TypeScript が Zod スキーマを通じて推論することから来ます。
スキーマを定義する
まず、API が返すものの形から始めます。これは小さなタスクリストの API で、タスク用の Zod オブジェクトスキーマが 1 つと、「作成」リクエストが受け付けるものの派生スキーマ(id と done はサーバー側で設定されるので、タイトルだけ)です。
// lib/schemas/task.ts
import * as z from "zod";
export const taskSchema = z.object({
id: z.string(),
title: z.string().min(1).max(200),
done: z.boolean(),
});
export const createTaskInput = taskSchema.pick({ title: true });
export type Task = z.infer<typeof taskSchema>;ここで本当の仕事をしているのは z.infer です。Task は手で保守する型ではなく、スキーマから導出されるので、ランタイムのバリデータとずれることが決してありません。taskSchema にフィールドを追加すれば、Task はそれを自動的に拾います。
チュートリアルでは「データベース」はメモリ上の配列です。本番アプリでは、これを本物の Supabase や Postgres のクエリに差し替えてください。残りのセットアップは変わりません。
// lib/db/tasks.ts
import { randomUUID } from "crypto";
import type { Task } from "@/lib/schemas/task";
const tasks: Task[] = [
{ id: randomUUID(), title: "Wire up the API layer", done: false },
];
export async function listTasks(): Promise<Task[]> {
return tasks;
}
export async function createTask(title: string): Promise<Task> {
const task: Task = { id: randomUUID(), title, done: false };
tasks.push(task);
return task;
}oRPC の手続きを定義する
oRPC の手続きは、@orpc/server のチェイン可能なビルダー os で作ります。入力スキーマ、出力スキーマ、ハンドラ関数を付けます。どちらのスキーマ呼び出しも任意ですが、両方付けることで、入り口でバリデーションが効き、出口で型付けされ検査された形が得られます。
// lib/orpc/router.ts
import * as z from "zod";
import { os } from "@orpc/server";
import { taskSchema, createTaskInput } from "@/lib/schemas/task";
import { listTasks, createTask } from "@/lib/db/tasks";
const list = os.output(z.array(taskSchema)).handler(async () => {
return listTasks();
});
const create = os
.input(createTaskInput)
.output(taskSchema)
.handler(async ({ input }) => {
return createTask(input.title);
});
export const router = {
tasks: {
list,
create,
},
};
export type AppRouter = typeof router;注目すべき点が 2 つ。1 つ目、create ハンドラの中の input はすでに { title: string } として型付けされています。キャストも手書きのインターフェースもありません。2 つ目、createTask が taskSchema に合わないものを返したら、TypeScript はコードが走る前に、そのハンドラの中でその場で指摘します。出力スキーマは、クライアントだけでなく、自分のサーバーコードに対する契約でもあるのです。
ルーターを Route Handler としてマウントする
oRPC は、素の fetch の Request/Response を話す RPC ハンドラを公開していて、これは Next.js 16 の Route Handler に直接マッピングされます。すべての手続きが 1 つのプレフィックスの下で届くよう、キャッチオールのセグメントにマウントします。
// app/rpc/[[...rest]]/route.ts
import { RPCHandler } from "@orpc/server/fetch";
import { router } from "@/lib/orpc/router";
const handler = new RPCHandler(router);
async function handleRequest(request: Request) {
const { response } = await handler.handle(request, {
prefix: "/rpc",
context: {},
});
return response ?? new Response("Not found", { status: 404 });
}
export const GET = handleRequest;
export const POST = handleRequest;
export const PUT = handleRequest;
export const PATCH = handleRequest;
export const DELETE = handleRequest;すべての HTTP 動詞が同じ handleRequest 関数を指しているのは、RPC ハンドラがどの手続きを呼ぶかをリクエスト自身から判断するからです。エンドポイントごとに別ファイルを書くことも、URL を関数に手でマッピングすることもありません。lib/orpc/router.ts の router に新しい手続きを追加することが、差分のすべてです。
Server Component から呼ぶ
Server Component の中では、自分のサーバーを呼ぶのに HTTP を往復する理由はありません。oRPC のサーバーサイドクライアント(createRouterClient)は、手続きのハンドラ関数をプロセス内で直接呼びつつ、ネットワーククライアントとまったく同じ型付けされたインターフェースを保ちます。
// lib/orpc/server-client.ts
import "server-only";
import { createRouterClient } from "@orpc/server";
import { router } from "@/lib/orpc/router";
export const orpc = createRouterClient(router, {
context: {},
});server-only のインポートはガードレールで、このファイルが誤ってクライアントコードに取り込まれると、ビルドエラーを投げます。ページで使います。
// app/tasks/page.tsx
import { orpc } from "@/lib/orpc/server-client";
export default async function TasksPage() {
const tasks = await orpc.tasks.list();
return (
<main className="max-w-lg mx-auto py-12">
<h1 className="text-2xl font-bold mb-6">Tasks</h1>
<ul className="space-y-2">
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
</main>
);
}tasks は Task[] で、taskSchema からずっと推論されています。TasksPage に手書きの戻り値型はなく、型のない fetch 呼び出しから any が忍び込むこともありません。
Client Component から呼ぶ
ブラウザはサーバー関数をプロセス内で呼べないので、クライアントサイドのクライアントは、先ほどマウントしたルートを指す RPC リンクを使って、ネットワーク越しに動きます。インターフェースはサーバーサイドのクライアントと同一です。同じメソッド名、同じ引数の形、同じ推論された戻り値型です。
// lib/orpc/client.ts
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { RouterClient } from "@orpc/server";
import type { AppRouter } from "@/lib/orpc/router";
const link = new RPCLink({
url: "/rpc",
});
export const orpc: RouterClient<AppRouter> = createORPCClient(link);フォームで使います。title は useState から来る素の文字列で、orpc.tasks.create(...) の戻り値は、.json() してキャストしなければならない Response ではなく、完全に型付けされた Task です。
// components/new-task-form.tsx
"use client";
import { useState } from "react";
import { orpc } from "@/lib/orpc/client";
export function NewTaskForm() {
const [title, setTitle] = useState("");
const [pending, setPending] = useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setPending(true);
const task = await orpc.tasks.create({ title });
setPending(false);
setTitle("");
console.log("created task", task.id, task.done);
}
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className="border rounded px-3 py-2 flex-1"
placeholder="New task"
/>
<button
type="submit"
disabled={pending}
className="bg-black text-white rounded px-4 py-2"
>
Add
</button>
</form>
);
}もし orpc.tasks.create に { title, done: false } を渡そうとすると、TypeScript は何かを走らせる前にそれを拒否します。createTaskInput は title しか受け付けないからです。これは入力スキーマが、サーバー側だけでなくクライアント側でも仕事をしている、ということです。
コンパイラが破壊的変更を捕まえるのを見る
これは実際に試してみる価値のある部分です。lib/schemas/task.ts を開いて、done を completed にリネームします。
export const taskSchema = z.object({
id: z.string(),
title: z.string().min(1).max(200),
completed: z.boolean(),
});ファイルを保存します。NewTaskForm は今や task.done でコンパイルエラーを表示します。Property 'done' does not exist on type '{ id: string; title: string; completed: boolean }'。lib/db/tasks.ts もエラーになります。createTask がいまだに done キーを持つオブジェクトを組み立てているからです。そのすべてが直るまで、何も走りません。
これが売り文句のすべてです。スキーマは 1 つのファイルで一度だけ変わり、TypeScript は古い形を前提としていた下流のあらゆる場所を見つけました。コードベース全体を .done で grep する必要も、本物のユーザーが古いコードパスに当たって数日後にランタイムエラーが出ることもありません。
Claude Code でこれを繋ぐ
新しい手続きを追加するよう Claude Code に頼めば、CLAUDE.md に慣習をはっきり書いておく限り、パターンに従います。
## API Layer
- oRPC procedures live in lib/orpc/router.ts
- Every procedure gets both .input() and .output() with a Zod schema
- Zod schemas for API-facing shapes live in lib/schemas/
- Route handler is mounted at app/rpc/[[...rest]]/route.ts, don't create new route files per endpointこれを置いておけば、「タスクを完了にする手続きを追加して」は、独自の手書きの型を持つその場しのぎの新しい API ルートではなく、同じ router オブジェクトへの新しいエントリ、必要ならスキーマの更新、そして既存のスタイルに合ったハンドラを生み出します。
$29 の Code Kit は、まさにこのパターンの上に作られています。これは Claude Code の上に載る harness であって、別のフレームワークではありません。生成される API 層はデフォルトで oRPC と Zod で、データベースのクエリから、データを描画するコンポーネントまで型安全です。Claude Code 自体を動かすには、やはり有料の Anthropic プランが必要です。harness が追加するのは、慣習をすでに繋いだ状態です。だから、それが作るすべての新機能が、毎回頼まなくてもこの同じ保証を得られます。
Posted by @speedy_devv
Claude Code でトランザクションメールを追加する(Resend + React Email)
Resend と React Email を使って、Next.js 16 アプリでウェルカム・レシート・パスワードリセットのメールを作る方法。テンプレートと送信ロジックは Claude Code が書きます。
File Uploads
Build secure user file and image uploads end to end with Claude Code: private Supabase Storage buckets, per-user RLS, signed URLs, and an upload UI wired through a type-safe oRPC API.

