Build This Now
Build This Now
What Is Claude Code?Claude Code InstallationClaude Code Native InstallerYour First Claude Code Project
speedy_devvkoen_salo
Blog/Handbook/Workflow/Building a Type-Safe API With Claude Code (oRPC + Zod)

Building a Type-Safe API With Claude Code (oRPC + Zod)

How to build a fully type-safe API layer in Next.js 16 with oRPC and Zod, using Claude Code, so schema changes break the build instead of production.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →
speedy_devvWritten by speedy_devvPublished Jul 14, 202610 min readHandbook hubWorkflow index

The version of a type-safe API that actually matters isn't a diagram, it's this: you rename a field in your Zod schema, and the client component that reads it turns red in your editor before you've even saved the file. No Postman collection to update. No runtime 500 in production three days later. The error shows up at the exact moment you broke something, in the exact file you need to fix.

That's what oRPC plus Zod gets you in a Next.js 16 app, and Claude Code is a fast way to wire it up correctly on the first pass.


Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

The bug this setup prevents

The common failure mode in a typical Next.js API route looks like this: you define a route handler that returns { id, title, done }. Somewhere else, a component destructures task.completed because someone renamed the field on the server six weeks ago and nobody updated the client. TypeScript can't catch it, because fetch() returns any (or a type you hand-wrote and now trust blindly). The bug ships. You find out from a Sentry alert, not a compiler.

"Type-safe from database to frontend" means there's exactly one source of truth for the shape of your data, a Zod schema, and both your server handler and your client call are typed off that same schema. Change the schema, and every place that shape flows through lights up with a type error until you fix it.

Installing oRPC and Zod

oRPC ships as a few small packages: a server package for defining procedures and mounting them as a handler, and a client package for calling them with full inference. Install both alongside Zod.

npm install @orpc/server @orpc/client zod

Nothing else is required. No code generation step, no CLI to run after every schema change. The types come from TypeScript inferring through the Zod schema, not from a build artifact you have to remember to regenerate.

Defining the schema

Start with the shape of the thing your API returns. This is a small task list API, one Zod object schema for a task, and a derived schema for what a "create" request accepts (just the title, since id and done get set server-side).

// 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 is doing the real work here. Task isn't a type you maintain by hand, it's derived from the schema, so it can never drift out of sync with the runtime validator. If you add a field to taskSchema, Task picks it up automatically.

For the tutorial, the "database" is an in-memory array. Swap this for real Supabase or Postgres queries in a production app, the rest of the setup doesn't change.

// 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;
}

Defining an oRPC procedure

A procedure in oRPC is built with os, a chainable builder from @orpc/server. You attach an input schema, an output schema, and a handler function. Both schema calls are optional, but attaching both is what gets you validation on the way in and a typed, checked shape on the way out.

// 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;

Two things worth noticing. First, input inside the create handler is already typed as { title: string }, no cast, no manual interface. Second, if createTask returned something that doesn't match taskSchema, TypeScript flags it right there in the handler, before the code ever runs. The output schema is a contract on your own server code, not just on the client.

Mounting the router as a Route Handler

oRPC exposes an RPC handler that speaks plain fetch Request/Response, which maps directly onto a Next.js 16 Route Handler. Mount it on a catch-all segment so every procedure in the router is reachable under one prefix.

// 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;

Every HTTP verb points at the same handleRequest function because the RPC handler figures out which procedure to call from the request itself. You don't write a separate file per endpoint, and you don't hand-map URLs to functions. Adding a new procedure to router in lib/orpc/router.ts is the entire diff.

Calling it from a Server Component

Inside a Server Component, there's no reason to round-trip through HTTP to call your own server. oRPC's server-side client (createRouterClient) calls the procedure's handler function directly, in-process, while keeping the exact same typed interface as the network client.

// 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: {},
});

The server-only import is a guardrail, it throws a build error if this file is ever pulled into client code by accident. Use it in a page:

// 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 is Task[], inferred all the way from taskSchema. No manual return type on TasksPage, no any slipping in from an untyped fetch call.

Calling it from a Client Component

The browser can't call your server functions in-process, so the client-side client goes over the wire, using the RPC link pointed at the route you mounted earlier. The interface is identical to the server-side client, same method names, same argument shapes, same inferred return types.

// 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);

Use it in a form. title is a plain string from useState, and the return value of orpc.tasks.create(...) is a fully typed Task, not a Response you have to .json() and cast.

// 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>
  );
}

If you tried to pass { title, done: false } into orpc.tasks.create, TypeScript would reject it before you ran anything, because createTaskInput only accepts title. That's the input schema doing its job on the client side, not just the server side.

Watching the compiler catch a breaking change

This is the part worth actually trying. Open lib/schemas/task.ts and rename done to completed:

export const taskSchema = z.object({
  id: z.string(),
  title: z.string().min(1).max(200),
  completed: z.boolean(),
});

Save the file. NewTaskForm now shows a compile error on task.done: Property 'done' does not exist on type '{ id: string; title: string; completed: boolean }'. lib/db/tasks.ts errors too, since createTask still builds an object with a done key. Nothing runs until every one of those is fixed.

That's the whole pitch. The schema changed once, in one file, and TypeScript found every place downstream that assumed the old shape. No grep for .done across the codebase, no runtime error days later when a real user hits the stale code path.

Using Claude Code to wire this up

Ask Claude Code to add a new procedure and it follows the pattern once your CLAUDE.md states the convention plainly:

## 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

With that in place, "add a procedure to mark a task done" produces a new entry in the same router object, a schema update if needed, and a handler that matches the existing style, instead of a fresh ad-hoc API route with its own hand-written types.

The $29 Code Kit builds on exactly this pattern. It's a harness on top of Claude Code, not a separate framework, and the API layer it generates is oRPC with Zod by default, type-safe from your database queries through to the component that renders the data. You still need a paid Anthropic plan to run Claude Code itself. What the harness adds is the convention already wired in, so every new feature it builds gets this same guarantee without you having to ask for it each time.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

Posted by @speedy_devv

Continue in Workflow

  • Agentic Commerce: How to Build an App AI Agents Can Pay For
    A plain-English guide to agentic commerce in 2026: what x402, ACP, and the Machine Payments Protocol do, plus a weekend walkthrough for shipping a paid API that AI agents can buy from.
  • Claude Code Best Practices
    Five habits separate engineers who ship with Claude Code: PRDs, modular CLAUDE.md rules, custom slash commands, /clear resets, and a system-evolution mindset.
  • Claude Code Auto Mode
    A second Sonnet model reviews every Claude Code tool call before it fires. What auto mode blocks, what it allows, and the allow rules it drops in your settings.
  • Channels, Routines, Teleport, Dispatch
    The four Claude Code features Anthropic shipped in March and April 2026 that turn the CLI into an event-driven coordination layer across phone, web, and desktop.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • Adding Authentication With Claude Code (Supabase Auth)
    Add email/password signup, Google OAuth, magic links, protected routes, and session handling to a Next.js 16 app using Claude Code and Supabase Auth.

More from Handbook

  • Agent Fundamentals
    Five ways to build specialist agents in Claude Code: Task sub-agents, .claude/agents YAML, custom slash commands, CLAUDE.md personas, and perspective prompts.
  • Agent Harness Engineering
    The harness is every layer around your AI agent except the model itself. Learn the five control levers, the constraint paradox, and why harness design determines agent performance more than the model does.
  • Agent Patterns
    Orchestrator, fan-out, validation chain, specialist routing, progressive refinement, and watchdog. Six orchestration shapes to wire Claude Code sub-agents with.
  • Agent Teams Best Practices
    Battle-tested patterns for Claude Code Agent Teams. Context-rich spawn prompts, right-sized tasks, file ownership, delegate mode, and v2.1.33-v2.1.45 fixes.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

On this page

The bug this setup prevents
Installing oRPC and Zod
Defining the schema
Defining an oRPC procedure
Mounting the router as a Route Handler
Calling it from a Server Component
Calling it from a Client Component
Watching the compiler catch a breaking change
Using Claude Code to wire this up

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →