用 Claude Code 和 tRPC 构建类型安全 API
用Claude Code和tRPC构建类型安全API,覆盖Next.js、Zod校验、权限、缓存失效和审查提示。
为什么把 tRPC 和 Claude Code 放在一起
tRPC 的价值在于让服务器端的 TypeScript 路由成为唯一的 API 契约。前端不需要手写一份 SDK,也不需要在每次改字段后再同步一份类型文件;React 侧导入 AppRouter 类型后,就能得到输入、输出和错误处理的提示。对初学者来说,可以把它理解成“服务器和客户端共用同一张类型说明书”。
Claude Code 的作用不是简单生成几段样板代码,而是帮助团队把目录结构、认证上下文、Zod 校验、React Query 缓存失效和代码审查规则统一起来。tRPC 会让类型链路非常顺滑,但类型正确并不代表权限正确,也不代表运行时输入可信。因此,Claude Code 最适合承担“生成后再严格审查”的角色,尤其要盯住权限、租户隔离和缓存更新。
本文使用 Next.js App Router 演示一个项目管理 API。为了让示例容易复制,数据层使用内存里的 projectStore;真实项目可以替换成 Prisma、Drizzle、Supabase 或现有 REST 服务。上线前一定要把示例里的演示登录替换为真实认证,并补上持久化、审计日志和权限测试。
适合的场景
tRPC 适合前后端都在同一个 TypeScript 项目中快速迭代的场景。如果你要给外部第三方长期提供公开 API,OpenAPI 或 GraphQL 可能更合适;但在管理后台、内部工具、表单提交和薄 BFF 中,tRPC 的开发效率非常明显。
| 场景 | tRPC 的好处 | Claude Code 可以做什么 | 风险 |
|---|---|---|---|
| 管理后台 CRUD | 列表、创建、更新、删除的输入输出都能自动提示 | 生成 router、Zod schema、mutation 和 invalidation | 只在 UI 隐藏按钮,没有在服务器鉴权 |
| 内部工具 | 小流程可以跟着业务快速修改 | 根据现有模型补 procedure | context 塞入太多依赖 |
| 表单提交 | 运行时校验邮箱、长度、枚举值 | 生成错误处理和防重复提交 | 只相信 TypeScript,不做 Zod 校验 |
| 薄 BFF | 给页面返回刚好需要的数据形状 | 整理外部 API 响应 | 缓存刷新时机不清楚 |
flowchart LR
UI["React component"]
Client["tRPC React client"]
Router["AppRouter"]
Procedure["protected/admin procedure"]
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
推荐的文件结构如下。src/server 只放服务器代码,App Router 的 HTTP 入口放在 api/trpc,React Provider 放在 src/trpc。
src/
app/api/trpc/[trpc]/route.ts
app/projects/project-list.tsx
server/trpc.ts
server/routers/_app.ts
server/routers/project.ts
trpc/client.tsx
给 Claude Code 的第一条指令可以写清楚边界:服务器文件不要导入 client component,client component 不要直接访问数据层,共享类型通过 tRPC router 传递,不要再维护一套平行的 DTO。这个约束能减少很多看似能跑、后期却难维护的代码。
建立 context、procedure 和权限边界
context 是每个请求都会带进 procedure 的信息。它应该尽量小:用户会话、团队 ID、角色和数据库入口通常够用。下面的代码使用 header 模拟登录,只适合本地演示。
// 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> {
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 分开非常重要。以后新增 delete、invite、billing 这类 mutation 时,审查者只要看到它没有使用 admin procedure,就能立刻提出问题。权限逻辑散落在每个 resolver 内部,反而更容易漏。
用 Zod 写可复制的 router
TypeScript 只在开发和构建阶段生效,不能保证浏览器或脚本发来的 JSON 一定可信。Zod 是运行时校验工具,负责在业务逻辑执行前检查邮箱、字符串长度、枚举值和数字范围。
// src/server/routers/project.ts
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
type ProjectStatus = "todo" | "doing" | "done";
type Project = {
id: string;
teamId: string;
title: string;
ownerEmail: string;
status: ProjectStatus;
createdAt: string;
};
const projects = new Map<string, Project>();
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(),
});
export const projectRouter = createTRPCRouter({
list: protectedProcedure.input(listProjectsInput).query(({ ctx, input }) => {
return [...projects.values()]
.filter((project) => project.teamId === ctx.session.teamId)
.filter((project) => !input.status || project.status === input.status)
.filter((project) => !input.query || project.title.includes(input.query))
.slice(0, input.limit);
}),
create: adminProcedure.input(createProjectInput).mutation(({ ctx, input }) => {
const project: Project = {
id: crypto.randomUUID(),
teamId: ctx.session.teamId,
title: input.title,
ownerEmail: input.ownerEmail,
status: "todo",
createdAt: new Date().toISOString(),
};
projects.set(project.id, project);
return project;
}),
updateStatus: protectedProcedure
.input(z.object({ id: z.string().uuid(), status: projectStatus }))
.mutation(({ ctx, input }) => {
const project = projects.get(input.id);
if (!project || project.teamId !== ctx.session.teamId) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const nextProject = { ...project, status: input.status };
projects.set(input.id, nextProject);
return nextProject;
}),
});
注意 updateStatus 中的 teamId 检查。id 是 UUID 并不代表用户有权修改它。多租户系统里,少了这一行就可能出现 A 团队修改 B 团队数据的事故。
连接 App Router 并在客户端调用
先把 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 };
// 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>();
export function TRPCProvider({ children }: { children: 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>
);
}
// 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 (
<form
onSubmit={(event) => {
event.preventDefault();
createProject.mutate({ title, ownerEmail: "owner@example.com" });
}}
>
<input value={title} onChange={(event) => setTitle(event.target.value)} />
<button type="submit">Add</button>
<pre>{JSON.stringify(projects.data, null, 2)}</pre>
</form>
);
}
mutation 成功后调用 utils.project.list.invalidate(),是为了让列表重新获取数据。很多后台页面的“刚创建却看不到”问题,都是因为没有定义清楚哪些 query 会被 mutation 影响。
让 Claude Code 做严格审查
请审查这个 Next.js App Router + tRPC 实现。
请重点检查:
1. publicProcedure / protectedProcedure / adminProcedure 是否用对
2. 所有外部输入是否都有 Zod runtime validation
3. teamId 和 userId 的租户边界是否在读写前检查
4. mutation 后是否 invalidates 受影响的 query
5. router 是否按领域拆分,是否过大
6. context 是否塞入了不必要的依赖
7. 返回给客户端的数据是否包含秘密字段
请用表格输出严重程度、文件、问题、修复方案和需要补的测试。
把这段提示放进 CLAUDE.md 或 PR 模板,可以让每次 tRPC 改动都按同一套标准检查。对于权限相关差异,建议先让 Claude Code 只给 diff 和风险说明,再由人确认后应用。
常见失败例
第一,context 肥大。把数据库、支付客户端、feature flag、日志器、页面临时状态都塞进 ctx,短期方便,长期会让 procedure 难以测试。第二,权限只做在 UI。隐藏按钮不能替代服务器端的 adminProcedure。第三,只相信类型而不做 Zod 校验。TypeScript 不会检查真实请求里的 JSON。第四,router 不拆分。所有 API 都写在一个文件里,几周后就难以审查。第五,cache 和 invalidation 混乱。新增、删除、状态变更分别影响哪些列表、详情和统计数字,必须在实现前列清楚。
参考链接、内部链接和咨询入口
实现时请对照 tRPC 官方的 Routers、Procedures 和 React Query integration。运行时校验可以参考 Zod 文档,Next.js 路由处理参考 Route Handlers。
如果想继续补强输入校验,请阅读 Claude Code Zod 校验指南;想整理 TypeScript 设计,可以参考 Claude Code TypeScript 技巧。ClaudeCodeLab 也可以通过 Claude Code 咨询与培训 帮团队检查现有后台、内部工具和 tRPC 权限设计。
实际试用结果
把这套结构用于一个小型管理后台原型后,最明显的收益是先定义 protectedProcedure 和 adminProcedure,再让 Claude Code 生成 CRUD。这样审查时很容易发现某个 mutation 是否缺少管理员权限。Zod schema 放在 procedure 附近,也让表单字段、API 输入和校验规则能一起修改。反过来,context 一旦变得太方便,测试就会变重,所以最终版本把业务操作拆成了更薄的函数。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。