news 2026/9/20 23:29:55

create-t3-app 中的 tRPC 实战:端到端类型安全的 Next.js API 开发指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
create-t3-app 中的 tRPC 实战:端到端类型安全的 Next.js API 开发指南

create-t3-app 中的 tRPC 实战:端到端类型安全的 Next.js API 开发指南

【免费下载链接】create-t3-appThe best way to start a full-stack, typesafe Next.js app项目地址: https://gitcode.com/gh_mirrors/cr/create-t3-app

本篇指南以 create-t3-app 官方文档(www/src/pages/pt/usage/trpc.md)为核心,系统讲解如何在由 create-t3-app 生成的项目中编写、挂载和调用 tRPC 程序(procedure)、利用 Zod 校验输入并推断前端错误类型、理解脚手架生成的每个 tRPC 文件的职责,以及通过服务端调用、CORS 配置、乐观更新和集成测试等实战片段把 tRPC 能力真正落地。读完本文,你将掌握 tRPC 从"零配置脚手架"到"生产级调用"的完整链路,并能在 Next.js 的 Pages Router 与 App Router 两种模式下自由切换。

tRPC 是什么:省去传统 API 层的端到端类型安全

tRPC 允许你在不生成任何代码、不引入额外运行时开销的前提下,编写端到端类型安全的 API。它充分利用 TypeScript 强大的类型推断能力,从 API 路由器的定义中直接推断出类型,让你在前端调用 API 程序时获得完整的类型安全与自动补全。tRPC 的创造者 Alex 在文档引言中这样解释其设计动机:

"我创建 tRPC 是为了让人们走得更快——移除传统 API 层的必要性,同时在快速迭代时依然对应用不会出问题保持信心。"

在 create-t3-app 生成的模板中,tRPC 通常与 Prisma/Drizzle、NextAuth/Better Auth 组合使用:你在后端用 TypeScript 写函数(这些函数就是 tRPC 程序,等价于传统后端中的路由处理器),然后从前端直接调用它们。由于前后端共享同一套类型系统,调用路径上的任何错误都会在编译期暴露。

从零认识一个 tRPC 程序:procedure 与 router

一个最小的 procedure

const userRouter = createTRPCRouter({ getById: publicProcedure.input(z.string()).query(({ ctx, input }) => { return ctx.prisma.user.findFirst({ where: { id: input, }, }); }), });

这个getById就是一个 tRPC 程序,等价于传统后端里的一个路由处理器。流程是:

  1. 输入校验:先用 Zod 校验输入(Zod 与 create-t3-app 校验环境变量用的是同一个库)。这里要求输入必须是字符串,如果传入的不是字符串,tRPC 会抛出一个信息明确的错误,而不会进入处理逻辑。
  2. 解析器(resolver):在.input()之后链式调用一个解析函数,它可以是查询(query)变更(mutation)订阅(subscription)。示例解析器通过 prisma 客户端查询数据库,返回id匹配的用户记录。

通过 router 组织程序并合并为 appRouter

你在routers中定义程序,每个 router 是一组带共享命名空间的关联程序的集合。你可以为userspostsmessages各建一个 router,然后在根路由里把它们合并成一个集中的appRouter

const appRouter = createTRPCRouter({ users: userRouter, posts: postRouter, messages: messageRouter, }); export type AppRouter = typeof appRouter;

注意:我们只导出 router 的类型定义,这意味着客户端永远不会 import 任何服务器端代码——这是 tRPC 保证类型安全又不泄漏服务端实现的关键设计。

在前端调用程序

tRPC 为@tanstack/react-query提供了一个封装层,让你既能使用 React Query 的全部 hooks 能力,又能享受 API 调用被类型化、被推断的额外好处:

import { useRouter } from "next/router"; import { api } from "../../utils/api"; const UserPage = () => { const { query } = useRouter(); const userQuery = api.users.getById.useQuery(query.id); return ( <div> <h1>{userQuery.data?.name}</h1> </div> ); };

一旦输入api.,自动补全就会列出所有 router;选中 router 后又会列出其程序。如果传入的输入与后端定义的校验器不匹配,TypeScript 会直接报错——这份"补全即安全"的开发体验正是 tRPC 的核心价值。

推断 Zod 校验错误:error.data.zodError

默认情况下,create-t3-app 配置了一个错误格式化器(error formatter),允许你在前端推断后端校验失败时的 Zod 错误。这背后是模板在初始化 tRPC 时注入的errorFormatter

const t = initTRPC.context<typeof createTRPCContext>().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, });

前端使用示例:

function MyComponent() { const { mutate, error } = api.post.create.useMutation(); return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); mutate({ title: formData.get('title') }); }}> <input name="title" /> {error?.data?.zodError?.fieldErrors.title && ( {/** `mutate` 返回时在 `title` 字段上有错误 */} <span className="mb-8 text-red-500"> {error.data.zodError.fieldErrors.title} </span> )} ... </form> ); }

当 mutation 因校验失败返回时,error.data.zodError.fieldErrors会携带按字段归类的错误信息,你可以直接渲染到对应输入框下方,无需自己手写任何错误解析逻辑。

脚手架生成了哪些 tRPC 文件:逐文件解剖

tRPC 依赖 create-t3-app 为你配置的大量模板文件。这些文件由 cli/src/installers/trpc.ts 中的trpcInstaller负责生成。先看它安装了哪些依赖:

  • @tanstack/react-query
  • superjson
  • @trpc/server
  • @trpc/client
  • @trpc/react-query

如果使用 Pages Router,还会额外安装@trpc/next并生成src/utils/api.ts;如果使用 App Router,则会额外安装server-only并生成src/trpc/下的多个文件。下面按 Pages Router(文档主线)逐文件说明。

📄pages/api/trpc/[trpc].ts—— API 入口

这是整个 API 的入口,负责暴露 tRPC router。模板内容如下(cli/template/extras/src/pages/api/trpc/[trpc].ts):

import { createNextApiHandler } from "@trpc/server/adapters/next"; import { env } from "~/env"; import { appRouter } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; // export API handler export default createNextApiHandler({ router: appRouter, createContext: createTRPCContext, onError: env.NODE_ENV === "development" ? ({ path, error }) => { console.error( `❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}` ); } : undefined, });

平时你几乎不需要改动这个文件。但如果你想启用 CORS 中间件之类的功能,需要知道:导出的createNextApiHandler本质上是一个接收 request/response 的 Next.js API 处理器,因此你可以把它包进任何你想用的中间件里(见下文 启用 CORS 示例)。onError仅在开发环境下打印详细的失败日志,生产环境则静默处理。

📄server/api/trpc.ts—— 上下文与 tRPC 初始化

这个文件分为两部分(以带认证与数据库的 Pages Router 模板 cli/template/extras/src/server/api/trpc-pages/with-auth-db.ts 为例):

1. 定义上下文(Context)。上下文是所有 tRPC 程序都能访问的数据,适合放数据库连接、认证信息等。create-t3-app 用两个函数来支持"没有 request 对象时使用上下文的子集":

  • createInnerTRPCContext:定义不依赖请求的上下文,比如数据库连接。你可以用它做集成测试或 tRPC 的ssg-helpers——这些场景下没有 request/response 对象。
  • createTRPCContext:定义依赖请求的上下文,比如用户会话。通过opts.req获取会话后,再调用createContextInner构造最终上下文。
const createInnerTRPCContext = (opts: CreateContextOptions) => { return { session: opts.session, db, }; }; export const createTRPCContext = async (opts: CreateNextContextOptions) => { const { req, res } = opts; // Get the session from the server using the getServerSession wrapper function const session = await auth(req, res); return createInnerTRPCContext({ session, }); };

2. 初始化 tRPC 并定义可复用的 procedure 与 middleware。按惯例,你不应该导出整个t对象,而是创建好可复用的 procedures 和 middlewares 再导出。模板导出了三类核心构建块:

  • createTRPCRouter:创建 router / 子 router 的工厂函数。
  • publicProcedure:公共程序,任何请求都能访问;不过已登录用户的会话数据依然可用。它挂载了timingMiddleware——该中间件在开发环境会给每次调用注入 100–500ms 的随机人工延迟(Math.floor(Math.random() * 400) + 100),并在终端打印[TRPC] <path> took Xms to execute,用于暴露本地开发中不会出现的请求瀑布(waterfall)问题。
  • protectedProcedure:受保护程序,只有登录用户可访问。它校验ctx.session.user是否存在,否则抛出TRPCError({ code: "UNAUTHORIZED" });通过后会把session的类型收窄为非空,保证ctx.session.user.id可用。

这个文件里还能看到superjson被用作数据转换器(data transformer)——它让你的数据类型在到达客户端时保持原样,例如后端返回Date对象,前端拿到的就是Date而不是大多数 API 会给出的字符串。同时createCallerFactory被导出,供 root.ts 构造服务端调用器。

📄server/api/routers/*.ts—— 业务路由定义

这里定义你的 API 路由与程序。按惯例为关联的程序创建独立的 router。create-t3-app 会生成一个示例 router(见 cli/template/extras/src/server/api/routers/post/with-auth-prisma.ts):

export const postRouter = createTRPCRouter({ hello: publicProcedure .input(z.object({ text: z.string() })) .query(({ input }) => { return { greeting: `Hello ${input.text}`, }; }), create: protectedProcedure .input(z.object({ name: z.string().min(1) })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { name: input.name, createdBy: { connect: { id: ctx.session.user.id } }, }, }); }), getLatest: protectedProcedure.query(async ({ ctx }) => { const post = await ctx.db.post.findFirst({ orderBy: { createdAt: "desc" }, where: { createdBy: { id: ctx.session.user.id } }, }); return post ?? null; }), });

可以看到hello(公共查询)、create(受保护变更)、getLatest(受保护查询)被组合在同一个 router 中。安装器会根据你选择的组合(是否启用 NextAuth/Better Auth、Prisma/Drizzle)从base.tswith-auth.tswith-prisma.tswith-drizzle.tswith-auth-prisma.tswith-auth-drizzle.ts中选择对应的模板文件。

📄server/api/root.ts—— 合并所有子路由

这里把所有routers/**下定义的子路由合并成单个应用 router(cli/template/extras/src/server/api/root.ts):

export const appRouter = createTRPCRouter({ post: postRouter, }); // export type definition of API export type AppRouter = typeof appRouter; export const createCaller = createCallerFactory(appRouter);

新增 router 后必须手动在这里挂载。同时它导出AppRouter类型和createCaller——后者用于服务端直接调用你的 API。

📄utils/api.ts—— 前端入口(Pages Router)

这是 tRPC 的前端入口(cli/template/extras/src/utils/api.ts)。在这里你 import 路由器的类型定义,创建 tRPC 客户端与 react-query hooks。因为后端启用了superjson数据转换器,前端也必须启用它——后端序列化的数据要在前端反序列化。

export const api = createTRPCNext<AppRouter>({ config() { return { links: [ loggerLink({ enabled: (opts) => process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), }), httpBatchLink({ transformer: superjson, url: `${getBaseUrl()}/api/trpc`, }), ], }; }, ssr: false, transformer: superjson, });

getBaseUrl会按环境智能取址:浏览器端用相对 URL,Vercel 部署时用VERCEL_URL,本地 SSR 用http://localhost:${process.env.PORT ?? 3000}。这里定义了两个tRPC link

  • httpBatchLink:tRPC 的"标准"link,支持请求批处理(把多个请求合并成一次 HTTP 请求)。
  • loggerLink:开发环境下输出有用的请求日志(opts.result instanceof Error时也记录向下的错误响应)。

最后还会导出两个推断辅助类型:RouterInputsinferRouterInputs<AppRouter>)和RouterOutputsinferRouterOutputs<AppRouter>),用于在前端手动推断输入输出类型。

App Router 模式下的文件差异

如果选择 App Router,安装器会走routeHandlerFilesrc/app/api/trpc/[trpc]/route.ts,基于fetchRequestHandler)与src/trpc/目录,见 cli/template/extras/src/app/api/trpc/[trpc]/route.ts。核心差异:

  • src/trpc/react.tsx:客户端入口(cli/template/extras/src/trpc/react.tsx)。用createTRPCReact<AppRouter>()创建api,使用httpBatchStreamLink(流式批处理),并通过TRPCReactProvider组合QueryClientProviderapi.Provider。浏览器端用单例模式复用同一个 query client。
  • src/trpc/server.ts:RSC(React Server Component)支持(cli/template/extras/src/trpc/server.ts)。通过createHydrationHelpers导出api(RSC 客户端)与HydrateClient,配合createCaller在服务端组件中直接调用程序。
  • src/trpc/query-client.ts:统一的 query client 工厂(cli/template/extras/src/trpc/query-client.ts),设置 30 秒默认staleTime避免客户端立即重复请求,并用 SuperJSON 负责 dehydration/hydration 的序列化。
  • App Router 下createTRPCContext只接收{ headers: Headers },不再依赖req/res

如何从外部调用你的 API

普通 API 可以用curlPostmanfetchInsomnia或直接在浏览器里调用端点。tRPC 略有不同——想脱离 tRPC 客户端调用程序,官方推荐两条路:

方案一:单独暴露一个程序

如果你只想把单个程序暴露出去,用服务端调用(server-side calls):创建一个普通的 Next.js API 端点,但复用 tRPC 程序的解析器部分。

import { type NextApiRequest, type NextApiResponse } from "next"; import { appRouter, createCaller } from "../../../server/api/root"; import { createTRPCContext } from "../../../server/api/trpc"; const userByIdHandler = async (req: NextApiRequest, res: NextApiResponse) => { // Create context and caller const ctx = await createTRPCContext({ req, res }); const caller = createCaller(ctx); try { const { id } = req.query; const user = await caller.user.getById(id); res.status(200).json(user); } catch (cause) { if (cause instanceof TRPCError) { // An error from tRPC occurred const httpCode = getHTTPStatusCodeFromError(cause); return res.status(httpCode).json(cause); } // Another error occurred console.error(cause); res.status(500).json({ message: "Internal server error" }); } }; export default userByIdHandler;

关键在于:createTRPCContext需要真实请求对象,所以这里显式传入{ req, res };随后createCaller(ctx)返回一个可以直接await调用各程序的调用器。tRPC 错误通过getHTTPStatusCodeFromError映射为合适的 HTTP 状态码。

方案二:把每个程序都暴露为 REST 端点

想全量暴露所有程序,社区有现成的trpc-openapi插件:只要给程序补充少量元数据,就能从 tRPC router 生成一套 OpenAPI 兼容的 REST API。

补充:tRPC 本质就是 HTTP 请求

tRPC 通过 HTTP 通信,所以理论上也可以用"普通 HTTP 请求"调用。但受限于 tRPC 使用的 RPC 协议,请求/响应的语法相当繁琐。你可以在浏览器的 Network 面板里观察真实的 tRPC 请求长什么样——官方建议仅把它当作学习练习,实际开发还是走上面两种推荐方案。

与 Next.js API 端点的对比

假设要从数据库取一个用户对象返回给前端。用 Next.js API 路由写是这样的:

import { type NextApiRequest, type NextApiResponse } from "next"; import { prisma } from "../../../server/db"; const userByIdHandler = async (req: NextApiRequest, res: NextApiResponse) => { if (req.method !== "GET") { return res.status(405).end(); } const { id } = req.query; if (!id || typeof id !== "string") { return res.status(400).json({ error: "Invalid id" }); } const examples = await prisma.example.findFirst({ where: { id, }, }); res.status(200).json(examples); }; export default userByIdHandler;

前端还要手写fetch、手动管理状态:

import { useState, useEffect } from "react"; import { useRouter } from "next/router"; const UserPage = () => { const router = useRouter(); const { id } = router.query; const [user, setUser] = useState(null); useEffect(() => { fetch(`/api/user/${id}`) .then((res) => res.json()) .then((data) => setUser(data)); }, [id]); };

对照本文开头的 tRPC 版本,可以清晰看到 tRPC 的优势:

  • 无需为每条路由拼 URL:整个 router 就是一个带自动补全的对象,移动文件也不会出现"URL 断裂"式的调试噩梦。
  • 无需手动校验 HTTP 方法query/mutation的语义已经编码在调用方式里。
  • 无需在程序里手动校验 query/body 数据:Zod 已经接管了这部分。
  • 无需手动构造响应:像普通 TypeScript 函数一样返回值、抛错误即可。
  • 前端调用自带补全与类型安全:接口契约由类型系统保证。

实用代码片段

启用 CORS

如果你的 API 需要被不同域名消费——比如在包含 React Native 应用的 monorepo 里——可能就需要启用 CORS:

import { type NextApiRequest, type NextApiResponse } from "next"; import { createNextApiHandler } from "@trpc/server/adapters/next"; import { appRouter } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; import cors from "nextjs-cors"; const handler = async (req: NextApiRequest, res: NextApiResponse) => { // Ativar CORS await cors(req, res); // Criar e chamar o handler do tRPC return createNextApiHandler({ router: appRouter, createContext: createTRPCContext, })(req, res); }; export default handler;

这正是文档前面强调的:"createNextApiHandler是一个普通 Next.js API handler,可以包进任意中间件"——这里把nextjs-corscors()与 tRPC handler 组合成了一个自定义 handler。

乐观更新(Optimistic Updates)

乐观更新指在 API 调用完成之前就更新 UI,让用户不必等待请求结束就能看到自己操作的结果。它适合对实时性要求高的场景;如果应用对数据准确性要求极高,则应避免使用,因为它并不是后端状态的"真实"反映。

const MyComponent = () => { const listPostQuery = api.post.list.useQuery(); const utils = api.useContext(); const postCreate = api.post.create.useMutation({ async onMutate(newPost) { // Cancele as requisições de saída (para que não substituam nossa atualização otimista) await utils.post.list.cancel(); // Obtenha os dados do queryCache const prevData = utils.post.list.getData(); // Atualizar os dados de forma otimista com nosso novo post utils.post.list.setData(undefined, (old) => [...old, newPost]); // Retornar os dados anteriores para que possamos reverter se algo der errado return { prevData }; }, onError(err, newPost, ctx) { // Se a mutation falhar, usar o valor de contexto de onMutate utils.post.list.setData(undefined, ctx.prevData); }, onSettled() { // Sincronizar com o servidor assim que a mutação for estabelecida utils.post.list.invalidate(); }, }); };

三段钩子的分工很清晰:onMutate取消进行中的列表请求并乐观写入新数据(同时缓存旧数据用于回滚);onError在失败时用onMutate返回的ctx.prevData还原;onSettled在请求尘埃落定后invalidate()使列表失效,与服务器重新同步。

集成测试示例

下面的集成测试用 Vitest 验证 router 是否按预期工作、输入解析器是否推断出正确类型、返回数据是否符合预期输出:

import { type inferProcedureInput } from "@trpc/server"; import { expect, test } from "vitest"; import { appRouter, type AppRouter } from "~/server/api/root"; import { createInnerTRPCContext } from "~/server/api/trpc"; test("example router", async () => { const ctx = await createInnerTRPCContext({ session: null }); const caller = appRouter.createCaller(ctx); type Input = inferProcedureInput<AppRouter["example"]["hello"]>; const input: Input = { text: "test", }; const example = await caller.example.hello(input); expect(example).toMatchObject({ greeting: "Hello test" }); });

注意这里用的是createInnerTRPCContext而不是createTRPCContext——因为测试环境没有真实的 request 对象,这正是模板把上下文拆成 inner/outer 两层的意义所在。

如果程序受保护(需要登录),构造上下文时传入 mock 的 session 即可:

test("protected example router", async () => { const ctx = await createInnerTRPCContext({ session: { user: { id: "123", name: "John Doe" }, expires: "1", }, }); const caller = appRouter.createCaller(ctx); // ... });

进一步阅读

  • tRPC 官方文档与大量可运行的示例代码,可在 tRPC 官方仓库的examples目录中查阅。
  • React Query 官方文档中关于乐观更新的章节,适合深入理解onMutate/onError/onSettled的生命周期。
  • 本仓库中 tRPC 安装与模板相关的核心文件:cli/src/installers/trpc.ts(安装与文件生成逻辑)、cli/template/extras/src/utils/api.ts(Pages Router 前端入口)、cli/template/extras/src/server/api/trpc-pages/with-auth-db.ts(服务端上下文与初始化)、cli/template/extras/src/server/api/root.ts(路由合并)。

【免费下载链接】create-t3-appThe best way to start a full-stack, typesafe Next.js app项目地址: https://gitcode.com/gh_mirrors/cr/create-t3-app

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 23:20:41

跨库关联查询的技术债:业务拆分后数据冗余同步与 Canal 监听实战

跨库关联查询的技术债&#xff1a;业务拆分后数据冗余同步与 Canal 监听实战在单体架构&#xff08;Monolith&#xff09;时代&#xff0c;处理复杂的前端展示需求极其轻松&#xff1a;一条包含四五个 LEFT JOIN 的 SQL 语句&#xff0c;就能把订单表、用户资料表、商家信息表与…

作者头像 李华
网站建设 2026/9/20 23:20:19

深圳GEO优化服务商哪家靠谱?生成式引擎优化选型指南

1. 先搞清楚“靠谱”到底在问什么“深圳GEO优化服务商哪家靠谱”这个问题&#xff0c;我在过去大半年里被问过不下二十次。问的人有做跨境电商的、有做本地生活服务的、也有做SaaS工具的&#xff0c;行业五花八门&#xff0c;但焦虑点出奇地一致&#xff1a;钱花出去了&#xf…

作者头像 李华
网站建设 2026/9/20 23:16:15

HR SOP手册怎么写?从流程拆解到落地实操指南

简介&#xff1a;这是一份面向酒店/企业人力资源部门及行政管理人员的标准操作手册&#xff0c;涵盖行政办公与人力管理两大板块。资源以doc文档形式提供&#xff0c;共1个文件&#xff0c;压缩包大小约402KB&#xff0c;内容按TY-EO-SOP和TY-HR-SOP系列编号组织&#xff0c;系…

作者头像 李华