Use Cases (更新: 2026/6/2)

用 Claude Code 和 tRPC 构建类型安全 API

用Claude Code和tRPC构建类型安全API,覆盖Next.js、Zod校验、权限、缓存失效和审查提示。

用 Claude Code 和 tRPC 构建类型安全 API

为什么把 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 隐藏按钮,没有在服务器鉴权
内部工具小流程可以跟着业务快速修改根据现有模型补 procedurecontext 塞入太多依赖
表单提交运行时校验邮箱、长度、枚举值生成错误处理和防重复提交只相信 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();
});

protectedProcedureadminProcedure 分开非常重要。以后新增 deleteinvitebilling 这类 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 官方的 RoutersProceduresReact Query integration。运行时校验可以参考 Zod 文档,Next.js 路由处理参考 Route Handlers

如果想继续补强输入校验,请阅读 Claude Code Zod 校验指南;想整理 TypeScript 设计,可以参考 Claude Code TypeScript 技巧。ClaudeCodeLab 也可以通过 Claude Code 咨询与培训 帮团队检查现有后台、内部工具和 tRPC 权限设计。

实际试用结果

把这套结构用于一个小型管理后台原型后,最明显的收益是先定义 protectedProcedureadminProcedure,再让 Claude Code 生成 CRUD。这样审查时很容易发现某个 mutation 是否缺少管理员权限。Zod schema 放在 procedure 附近,也让表单字段、API 输入和校验规则能一起修改。反过来,context 一旦变得太方便,测试就会变重,所以最终版本把业务操作拆成了更薄的函数。

#Claude Code #tRPC #TypeScript #API #type safety
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。