name: trpc-fullstack
description: “Build end-to-end type-safe APIs with tRPC — routers, procedures, middleware, subscriptions, and Next.js/React integration patterns.”
category: framework
risk: none
source: community
date_added: “2026-03-17”
author: suhaibjanjua
tags: [typescript, trpc, api, fullstack, nextjs, react, type-safety]
tools: [claude, cursor, gemini]
tRPC 全栈
概述
tRPC 让您无需编写模式或代码生成步骤即可构建完全类型安全的 API。您的 TypeScript 类型从服务器路由器直接流向客户端——因此每个 API 调用都能自动补全、在编译时验证,并且重构安全。在构建 TypeScript monorepo、Next.js 应用或任何服务器和客户端共享代码库的项目时,使用本技能。
何时使用本技能
- 在构建 TypeScript 全栈应用(Next.js、Remix、Express + React)且客户端和服务器共享单一仓库时使用
- 当您希望 API 调用获得端到端类型安全,而不需要 REST/GraphQL 模式开销时使用
- 在向现有 tRPC 设置添加实时功能(订阅)时使用
- 在设计 tRPC 过程上的多步中间件(认证、速率限制、租户作用域)时使用
- 在将现有 REST/GraphQL API 增量迁移到 tRPC 时使用
核心概念
路由器和过程
路由器将相关的过程(可以理解为端点)分组。过程是类型化函数——query用于读取,mutation用于写入,subscription用于实时流。
使用 Zod 进行输入验证
所有过程输入都使用 Zod 模式进行验证。验证后的类型化输入在过程处理程序中可用——无需手动解析。
上下文
context是传递给每个过程的共享状态——认证会话、数据库客户端、请求头等。它在上下文工厂中按请求构建一次。重要提示:Next.js App Router 和 Pages Router 需要单独的上下文工厂,因为 App Router 处理程序接收的是 fetchRequest,而不是 Node.jsNextApiRequest。
中间件
中间件链在过程之前运行。用于认证、日志记录和请求增强。它们可以为下游过程扩展上下文。
工作原理
步骤 1:安装并初始化
npminstall@trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod创建 tRPC 实例和可复用的构建器:
// src/server/trpc.tsimport{initTRPC,TRPCError}from'@trpc/server';import{typeContext}from'./context';import{ZodError}from'zod';constt=initTRPC.context<Context>().create({errorFormatter({shape,error}){return{...shape,data:{...shape.data,zodError:error.causeinstanceofZodError?error.cause.flatten():null,},};},});exportconstrouter=t.router;exportconstpublicProcedure=t.procedure;exportconstmiddleware=t.middleware;步骤 2:定义两个上下文工厂
Next.js App Router 处理程序接收 fetchRequest(不是 Node.jsNextApiRequest),因此上下文必须根据调用位置以不同方式构建。为每个表面定义一个工厂:
// src/server/context.tsimport{typeFetchCreateContextFnOptions}from'@trpc/server/adapters/fetch';import{auth}from'@/server/auth';// Next-Auth v5 / your auth helperimport{db}from'./db';/** * Context for the HTTP handler (App Router Route Handler). * `opts.req` is the fetch Request — auth is resolved server-side via `auth()`. */exportasyncfunctioncreateTRPCContext(opts:FetchCreateContextFnOptions){constsession=awaitauth();// server-side auth — no req/res neededreturn{session,db,headers:opts.req.headers};}/** * Context for direct server-side callers (Server Components, RSC, cron jobs). * No HTTP request is involved, so we call auth() directly from the server. */exportasyncfunctioncreateServerContext(){constsession=awaitauth();return{session,db};}exporttypeContext=Awaited<ReturnType<typeofcreateTRPCContext>>;步骤 3:构建认证中间件和受保护过程
// src/server/trpc.ts (continued)constenforceAuth=middleware(({ctx,next})=>{if(!ctx.session?.user){thrownewTRPCError({code:'UNAUTHORIZED'});}returnnext({ctx:{// Narrows type: session is non-null from heresession:{...ctx.session,user:ctx.session.user},},});});exportconstprotectedProcedure=t.procedure.use(enforceAuth);步骤 4:创建路由器
// src/server/routers/post.tsimport{z}from'zod';import{router,publicProcedure,protectedProcedure}from'../trpc';import{TRPCError}from'@trpc/server';exportconstpostRouter=router({list:publicProcedure.input(z.object({limit:z.number().min(1).max(100).default(20),cursor:z.string().optional(),})).query(async({ctx,input})=>{constposts=awaitctx.db.post.findMany({take:input.limit+1,cursor:input.cursor?{id:input.cursor}:undefined,orderBy:{createdAt:'desc'},});constnextCursor=posts.length>input.limit?posts.pop()!.id:undefined;return{posts,nextCursor};}),byId:publicProcedure.input(z.object({id:z.string()})).query(async({ctx,input})=>{constpost=awaitctx.db.post.findUnique({where:{id:input.id}});if(!post)thrownewTRPCError({code:'NOT_FOUND'});returnpost;}),create:protectedProcedure.input(z.object({title:z.string().min(1).max(200),body:z.string().min(1),})).mutation(async({ctx,input})=>{returnctx.db.post.create({data:{...input,authorId:ctx.session.user.id},});}),delete:protectedProcedure.input(z.object({id:z.string()})).mutation(async({ctx,input})=>{constpost=awaitctx.db.post.findUnique({where:{id:input.id}});if(!post)thrownewTRPCError({code:'NOT_FOUND'});if(post.authorId!==ctx.session.user.id)thrownewTRPCError({code:'FORBIDDEN'});returnctx.db.post.delete({where:{id:input.id}});}),});步骤 5:组合根路由器并导出类型
// src/server/root.tsimport{router}from'./trpc';import{postRouter}from'./routers/post';import{userRouter}from'./routers/user';exportconstappRouter=router({post:postRouter,user:userRouter,});// Export the type for the client — never import the appRouter itself on the clientexporttypeAppRouter=typeofappRouter;步骤 6:挂载 API 处理程序(Next.js App Router)
App Router 处理程序必须使用fetchRequestHandler和基于 fetch 的上下文工厂。createTRPCContext接收FetchCreateContextFnOptions(包含 fetchRequest),而不是 Pages Router 的req/res对。
// src/app/api/trpc/[trpc]/route.tsimport{fetchRequestHandler}from'@trpc/server/adapters/fetch';import{typeFetchCreateContextFnOptions}from'@trpc/server/adapters/fetch';import{appRouter}from'@/server/root';import{createTRPCContext}from'@/server/context';consthandler=(req:Request)=>fetchRequestHandler({endpoint:'/api/trpc',req,router:appRouter,// opts is FetchCreateContextFnOptions — req is the fetch RequestcreateContext:(opts:FetchCreateContextFnOptions)=>createTRPCContext(opts),});export{handlerasGET,handlerasPOST};步骤 7:设置客户端(React Query)
// src/utils/trpc.tsimport{createTRPCReact}from'@trpc/react-query';importtype{AppRouter}from'@/server/root';exportconsttrpc=createTRPCReact<AppRouter>();// src/app/providers.tsx'use client';import{QueryClient,QueryClientProvider}from'@tanstack/react-query';import{httpBatchLink}from'@trpc/client';import{useState}from'react';import{trpc}from'@/utils/trpc';exportfunctionTRPCProvider({children}:{children:React.ReactNode}){const[queryClient]=useState(()=>newQueryClient());const[trpcClient]=useState(()=>trpc.createClient({links:[httpBatchLink({url:'/api/trpc',headers:()=>({'x-trpc-source':'react'}),}),],}));return(<trpc.Provider client={trpcClient}queryClient={queryClient}><QueryClientProvider client={queryClient}>{children}</QueryClientProvider></trpc.Provider>);}示例
示例 1:在组件中获取数据
// components/PostList.tsx'use client';import{trpc}from'@/utils/trpc';exportfunctionPostList(){const{data,isLoading,error}=trpc.post.list.useQuery({limit:10});if(isLoading)return<p>Loading…</p>;if(error)return<p>Error:{error.message}</p>;return(<ul>{data?.posts.map((post)=>(<li key={post.id}>{post.title}</li>))}</ul>);}示例 2:带缓存失效的变更
'use client';import{trpc}from'@/utils/trpc';exportfunctionCreatePost(){constutils=trpc.useUtils();constcreatePost=trpc.post.create.useMutation({onSuccess:()=>{// Invalidate and refetch the post listutils.post.list.invalidate();},});consthandleSubmit=(e:React.FormEvent<HTMLFormElement>)=>{e.preventDefault();constform=e.currentTarget;constdata=newFormData(form);createPost.mutate({title:data.get('title')asstring,body:data.get('body')asstring,});form.reset();};return(<form onSubmit={handleSubmit}><input name="title"placeholder="Title"required/><textarea name="body"placeholder="Body"required/><button type="submit"disabled={createPost.isPending}>{createPost.isPending?'Creating…':'Create Post'}</button>{createPost.error&&<p>{createPost.error.message}</p>}</form>);}示例 3:服务器端调用者(服务器组件 / SSR)
使用createServerContext——专用的服务器端工厂——以便正确调用auth(),而无需合成的或空请求对象:
// app/posts/page.tsx (Next.js Server Component)import{appRouter}from'@/server/root';import{createCallerFactory}from'@trpc/server';import{createServerContext}from'@/server/context';constcreateCaller=createCallerFactory(appRouter);exportdefaultasyncfunctionPostsPage(){// Uses createServerContext — calls auth() server-side, no req/res cast neededconstcaller=createCaller(awaitcreateServerContext());const{posts}=awaitcaller.post.list({limit:20});return(<ul>{posts.map((post)=>(<li key={post.id}>{post.title}</li>))}</ul>);}示例 4:实时订阅(WebSocket)
// server/routers/notifications.tsimport{observable}from'@trpc/server/observable';import{EventEmitter}from'events';constee=newEventEmitter();exportconstnotificationRouter=router({onNew:protectedProcedure.subscription(({ctx})=>{returnobservable<{message:string;at:Date}>((emit)=>{constonNotification=(data:{message:string})=>{emit.next({message:data.message,at:newDate()});};constchannel=`user:${ctx.session.user.id}`;ee.on(channel,onNotification);return()=>ee.off(channel,onNotification);});}),});// Client usage — requires wsLink in the client configtrpc.notification.onNew.useSubscription(undefined,{onData(data){toast(data.message);},});最佳实践
- ✅只导出
AppRouter类型——永远不要在客户端导入appRouter - ✅使用单独的上下文工厂——
createTRPCContext用于 HTTP 处理程序,createServerContext用于服务器组件和调用者 - ✅使用 Zod 验证所有输入——永远不要在没有模式的情况下信任原始
input - ✅按领域拆分路由器(帖子、用户、计费)并在
root.ts中合并 - ✅在中间件中扩展上下文,而不是在每个请求中多次查询数据库
- ✅在变更后使用
utils.invalidate()保持缓存新鲜 - ❌不要用
as any强制转换上下文来消除类型错误——当认证或会话查找返回 undefined 时,不匹配会以运行时失败的形式显现 - ❌不要在服务器组件中使用
createContext({} as any)——请使用直接调用auth()的createServerContext() - ❌不要把业务逻辑放在路由处理程序中——把它放在过程或服务层中
- ❌不要全局共享 tRPC 客户端实例——按 provider 创建以避免过时的闭包
安全与注意事项
- 始终在
protectedProcedure中强制实施授权——永远不要只依赖客户端检查 - 使用 Zod 验证所有输入形状,包括分页游标和 ID,以防止通过格式错误的输入进行注入
- 避免向客户端暴露内部错误细节——使用具有公开安全
message的TRPCError,并仅在服务器端保留堆栈跟踪 - 使用中间件对公共过程进行速率限制以防止滥用
常见陷阱
问题:即使已登录,受保护过程中的认证会话也是
null
解决方案:确保createTRPCContext使用正确的服务器端认证调用(例如 Next-Auth v5 的auth()),并且在 App Router 处理程序中不要通过as any接收 Pages Router 的req/res强制转换问题:依赖认证的查询的服务器组件调用者失败
解决方案:使用createServerContext()(专用的服务器端工厂),而不是向createContext传递空对象或合成对象问题:“Type error: AppRouter is not assignable to AnyRouter”
解决方案:在客户端将AppRouter作为type导入(import type { AppRouter }),而不是导入整个模块问题:成功后变更未反映在 UI 中
解决方案:在onSuccess中调用utils.<router>.<procedure>.invalidate()以通过 React Query 触发重新获取问题:使用 App Router 时出现 “Cannot find module ‘@trpc/server/adapters/next’”
解决方案:对 App Router 使用@trpc/server/adapters/fetch和fetchRequestHandler;nextjs适配器仅用于 Pages Router问题:订阅无法连接
解决方案:订阅需要splitLink——将订阅路由到wsLink,将查询/变更路由到httpBatchLink
相关技能
@typescript-expert— tRPC 路由器内部和泛型工具中使用的深度 TypeScript 模式@react-patterns— 与trpc.*.useQuery和useMutation搭配的 React hooks 模式@test-driven-development— 使用createCallerFactory在无 HTTP 服务器的情况下编写过程单元测试@security-auditor— 审查 tRPC 中间件链以发现认证绕过和输入验证缺口
其他资源
- tRPC 官方文档
- create-t3-app — 已接线 tRPC 的生产级 Next.js 启动模板
- tRPC GitHub
- TanStack Query 文档
局限性
- 仅当任务与上述范围明确匹配时才使用本技能。
- 不要将输出视为特定环境验证、测试或专家审查的替代品。
- 如果缺少所需的输入、权限、安全边界或成功标准,请停下来询问澄清。