1. 为什么我给项目起名"t3code"——我对前后端类型割裂的耐心已经用完
先说说这次实践的起因。我手上有个内部协作工具要从零重写,功能谈不上复杂,无非是任务清单、成员备注、状态流转这些接口,但最大的麻烦在于:这类工具通常会有二三十个琐碎接口,每个接口的字段、枚举、嵌套结构都不同,前端调起来简直是在开盲盒。以往我总是陷入同一个循环——后端写一个接口,把返回结构口述或者写到注释里,前端照着调,等到字段一改、枚举一变、某次重构把字段从一个对象挪到另一个对象,前端代码静默接受一切,直到运行期间某个属性出现undefined,才有人意识到"这里好像对不上了"。
后来我在技术社区反复看到T3 Stack这个词,当时第一反应是"又一个组合拳框架",因为前端圈每隔一阵就会冒出一个新组合。但深入了解后发现,它真正戳中我的点在于:类型不再是前后端各自维护的一套"相似但不同"的定义,而是从数据库到后端路由再到前端hooks全程共用的同一份类型。T3 Stack说白了就是一条技术线的组合:Next.js作为全栈框架载体,TypeScript贯穿始终,Tailwind CSS负责样式,tRPC负责让前后端接口像"本地函数调用"一样类型安全。很多人说T3就是TypeScript+Tailwind+tRPC三个首字母,再多一个Next.js当底座。
本着"实践出真知"的思路,我把重写项目当作试验田,代号就叫t3code。整个过程做下来,最大的感受不是"新工具真酷",而是"原来前后端联调可以这么安静"——没有打开Network面板核对字段的步骤,没有"这个字段到底是后端拼的还是前端拼的"的争论,大多数类型错误在编辑器里就直接标红了。这篇不是官方案例复刻,我会把从脚手架搭建、数据模型设计、tRPC路由编写到生产部署踩坑的完整链路走一遍,重点写那些"动手必踩、文档不写"的点。如果你正在用Next.js写全栈应用,或者早就烦透了维护接口文档,这篇应该能帮你少走弯路;如果你还没接触过T3 Stack,我尽量把每个概念背后的"为什么"也讲透。
2. 脚手架选择背后的逻辑:为什么我用create-t3-app而不是自由拼装
2.1 create-t3-app到底帮你选了哪些东西
搭建项目的时候我直接用官方脚手架,一条命令就够了:
npm create t3-app@latest交互式界面会让你选择包管理器、是否开启TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth等模块。为什么不自力更生自己拼?因为T3 Stack这几个工具之间的"接线"部分才是最麻烦的——tRPC要和Next.js的App Router集成,Prisma要挂到tRPC的context上,NextAuth的session要同步进tRPC的中间件,这些装配代码散落在各种文档里,自己拼很容易缺胳膊少腿。脚手架真正的价值不是省几个命令,而是它把这些工具之间的glue code全部生成好了。
我第一次跑完脚手架后直接打开生成的项目目录,看到src/server/api/trpc.ts、src/server/db.ts、src/trpc/server.ts这些文件里已经写好了初始化逻辑,立刻明白了"为什么社区推荐用它起步"——这些文件就是整套架构的骨架,后面所有业务代码都长在这些骨架上。
2.2 项目结构里藏着的关键文件
填完交互式选项后,生成的目录大致长这样:
src/ app/ api/trpc/[trpc]/route.ts layout.tsx page.tsx server/ api/ root.ts routers/ trpc.ts db.ts trpc/ server.ts react.ts prisma/ schema.prisma .env这里每个文件都有明确分工。src/server/db.ts负责初始化Prisma客户端,src/server/api/trpc.ts创建tRPC的router实例和公共procedure,src/server/api/root.ts把各个业务router汇总成appRouter,src/app/api/trpc/[trpc]/route.ts把tRPC挂到Next.js的API路由上,src/trpc/react.ts封装给前端用的hooks,src/trpc/server.ts提供服务端调用tRPC的方法。
我在实际操作前一度困惑:"tRPC不是RPC吗?它到底走不走HTTP?"答案是走,但它只需要一个通配路由来接收所有tRPC请求——Next.js这边只用那个[trpc]/route.ts文件就够了。这其实是不少人第一次搞懂tRPC时容易卡住的点:你不需要给每个业务接口单独写一个API route,tRPC会把所有procedure的调用统一收敛到一个入口,按路径分发到对应的router和procedure上。这个设计让它比起"每个接口一个handler"的传统REST写法少了一大堆样板代码。
2.3 我对App Router和Pages Router的取舍
create-t3-app在生成时会让选择使用哪种路由模式,我选的是App Router。如果你现在刚开始新项目,我也建议直接选App Router。原因主要有三个。
第一,App Router是Next.js当前主推的方向,新功能例如Server Component、Server Actions等都是围绕它设计的,选它意味着很长一段时间内不需要考虑迁移。第二,tRPC在App Router下的集成方式很干净,服务端调用可以在Server Component里直接await api.task.list(),不必像Pages Router那样刻意区分ssr: false。第三,App Router的layout机制对全站布局、权限控制、主题样式管理都更自然,尤其是嵌套布局,在对接NextAuth的session传递时心智负担小很多。
当然Pages Router也不是不能用,如果你维护的是老项目,就没必要为了tRPC强行改造路由模式。我的建议是:新项目上App Router,老项目继续用Pages Router,tRPC对两种模式都有官方适配。
2.4 脚手架生成后我有几件事是立刻动手改的
第一件事是填.env里的数据库连接串。脚手架默认给的是postgresql://postgres:password@localhost:5432/t3code这种占位符,不改成真实连接串的话后面所有涉及数据库的命令都会报错。
第二件事是确认Prisma的provider和数据库类型一致。我在schema.prisma里用的PostgreSQL,然后在.env里把DATABASE_URL填成对应的Postgres连接串。这一步看着简单,但很容易出现"开发环境用SQLite、生产环境用Postgres"导致的迁移文件混乱。
第三件事是跑一次数据库同步再跑一次开发服务器,验证整条链路是通的:
npx prisma db push npm run devdb push会把当前schema直接推给数据库,适合开发早期;等schema稳定后建议换成正式的migrate dev生成迁移历史。跑到这里如果能打开页面看到默认的Next.js首页,且没有报tRPC相关的connection错误,说明整套脚手架已经盘活了。
3. tRPC的核心机制:为什么"一次定义、两端共享"能做到
3.1 从REST到RPC的思路转变
说到tRPC,绕不开那个灵魂拷问:"它和传统REST到底有什么本质区别?"REST的思维模式是围绕资源设计URL,一个资源配一组GET/POST/PUT/DELETE操作,前端通过URL语义去猜测后端能力。tRPC的思维模式则更接近"远程函数调用"——你把后端要暴露的能力写成一个个函数,前端像调用本地函数一样直接调用。
这个转变带来的直接好处是"不需要再发明一套URL规范"。"获取任务列表"就是task.list,"创建任务"就是task.create,路径就是函数名的集合,参数就是函数的入参,返回就是函数返回值的类型。这套RPC模型在内部工具、管理后台这类前后端完全由同一团队维护的项目里极其高效,因为根本不需要对外暴露稳定的API契约,内部接口可以随时改函数名、调参数结构,类型检查器会立刻告诉你哪些调用点要跟着改。
3.2 router和procedure的写法人话版
tRPC里最核心的两个概念是router和procedure。router就是一组相关procedure的集合,可以类比成一个控制器;procedure就是一个个具体的接口实现,分两类,query用于读数据,mutation用于写数据。我写的任务router长这样:
// src/server/api/routers/task.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const taskRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.task.findMany({ orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1).max(200), priority: z.enum(["low", "medium", "high"]).default("medium"), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: input }); }), toggleDone: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const task = await ctx.db.task.findUnique({ where: { id: input.id } }); if (!task) { throw new Error("task not found"); } return ctx.db.task.update({ where: { id: input.id }, data: { done: !task.done }, }); }), });这段代码里,ctx.db是挂在tRPC context上的Prisma客户端实例。这个context在src/server/api/trpc.ts里初始化,创建tRPC实例时会传入一个包含数据库连接和session信息的上下文对象,于是每个procedure都能通过ctx安全地拿到数据库操作能力。
3.3 zod输入校验为什么不是"可选加分项"
注意create这个procedure里的.input(...),括号里不是普通ts类型,而是zod schema。这是tRPC最大的一个细节:客户端请求进来时,tRPC会先用zod对输入参数做运行时校验,校验通过才会进入procedure的业务逻辑。很多刚接触的人会问:"我已经有TypeScript了,为什么还需要zod?"因为TypeScript的类型只在编译期存在,编译完就没了,运行时收到的参数到底是什么,ts完全管不到。而zod是在运行时真正执行的校验器,保证了进入函数体的一定是符合预期的数据。
这个设计直接让"信任边界"变得清晰:procedure的入参在函数体内不需要再写一遍if (!title || title.length === 0)这种防御代码,因为zod已经挡在门口了。我在后续写业务时发现这个习惯会反向塑造代码风格——越来越倾向于把入参的约束、默认值、枚举值全写进zod schema,函数体反而变得特别干净。
3.4 类型到底是怎么"流动"的
tRPC的类型共享原理是全文最值得理解的部分。服务端定义好appRouter后,客户端通过TypeScript的类型推断机制拿到它的结构,不需要手动导出任何接口类型。
拿我的项目举例,根router定义在src/server/api/root.ts:
import { taskRouter } from "~/server/api/routers/task"; import { createTRPCRouter } from "~/server/api/trpc"; export const appRouter = createTRPCRouter({ task: taskRouter, }); export type AppRouter = typeof appRouter;客户端那边建立hooks时把AppRouter当作泛型参数传入,于是前端所有api.task.list、api.task.create这些调用的入参和返回值类型就全部自动推断了。整个过程没有"导出interface""发布npm包共享类型""手写d.ts"这些额外动作,完全是靠TypeScript的结构类型系统把服务端router的结构照搬到前端。
这种共享的粒度很有讲究,我在实际操作中验证过:如果后端把某个procedure的返回值从{ id: string; title: string }改成{ id: string; title: string; priority: string },前端对应的.useQuery()调用处会立刻在编辑器里报类型错误,提示返回值结构变了。这意味着"接口变更"从运行期问题提前到了编译期问题,联调效率自然就上来了。
4. 从Prisma Schema到React页面:一个任务管理模块的完整落地
4.1 数据模型怎么定义
第2章的router代码里ctx.db.task已经暗示了数据模型的存在。我用Prisma定义了一个非常典型的任务实体:
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Task { id String @id @default(cuid()) title String priority Priority @default(MEDIUM) done Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum Priority { LOW MEDIUM HIGH }这个模型的设计逻辑很直接:id用cuid而不是自增id,是为了避免暴露业务量信息;title必填;priority用枚举而不是字符串,是为了在数据层就杜绝脏值;done带默认值减少业务代码的赋值遗漏;createdAt和updatedAt自动维护。定义好后执行迁移:
npx prisma migrate dev --name initmigrate dev除了把表建到数据库,还会自动执行prisma generate生成Prisma Client,于是ctx.db.task就有了完整的类型提示。这一步是后面所有类型安全的基础,因为数据库字段的拼写错误都会在这一层先暴露出来。
4.2 路由挂载与根router联动
写完taskRouter后,要把挂回根router,前端才能通过api.task访问到它。这一步我在根router文件里加上一行就完了:
export const appRouter = createTRPCRouter({ task: taskRouter, });也就是说,前端访问路径是api.task.list,对应的是appRouter下的task子router里的listprocedure。命名空间的组织在这套架构里天然支持嵌套,业务模块多了以后可以按域划分:api.user.profile、api.task.comment等,层次感很清晰。
4.3 React Query的整合:页面里的调用方式
落地到页面时,tRPC和React Query是深度绑定的。生成的src/trpc/react.ts本质上就是@tanstack/react-query加tRPC的封装。页面里调用方式如下:
"use client"; import { api } from "~/trpc/react"; export function TaskList() { const utils = api.useUtils(); const { data: tasks, isLoading } = api.task.list.useQuery(); const createTask = api.task.create.useMutation({ onSuccess: () => utils.task.list.invalidate(), }); const toggleDone = api.task.toggleDone.useMutation({ onSuccess: () => utils.task.list.invalidate(), }); if (isLoading) return <div>加载中...</div>; return ( <ul> {tasks?.map((task) => ( <li key={task.id}> <span>{task.title}</span> <button onClick={() => toggleDone.mutate({ id: task.id })}> {task.done ? "已完成" : "标记完成"} </button> </li> ))} </ul> ); }这里最关键的三个API值得仔细理解。
api.task.list.useQuery()返回{ data, isLoading },数据就是服务端listprocedure的返回值,类型完全一致。
api.task.create.useMutation()返回一个mutate函数,调用时传的对象必须符合zod校验的输入结构。
useUtils()拿到的utils.task.list.invalidate()是缓存失效函数,它的作用是:当创建或更新操作成功后,让React Query认为任务列表缓存过期,自动重新拉取列表数据。这个模式覆盖了我90%的数据刷新需求,省去了手动管理状态同步的麻烦。
4.4 全链路类型追踪的一个完整实例
为了直观看到类型是怎么一路贯穿的,我把整条链路的代码串在一起看一遍。
服务端create的输入类型来自zod:
.input(z.object({ title: z.string().min(1).max(200), priority: z.enum(["low", "medium", "high"]).default("medium"), }))前端调用时,createTask.mutate的参数类型会被推断成:
{ title: string; priority?: "low" | "medium" | "high" }注意三点:字段名、可选性、枚举值完全对应;priority自动被识别为可选,因为zod给了默认值;title被识别为必选。如果我试图传一个{ tiitle: "xxx" }过去,错误在编辑器中立即出现,根本没机会跑到运行期。
返回值的类型也是同样的逻辑。api.task.list.useQuery()拿到Task[]数组,数组元素的字段就是Prisma模型里定义的那些。如果某天在Prisma里把priority从Priority枚举改成纯字符串,所有页面里枚举相关的判断逻辑都会在编译期标红,提醒你处理兼容。
这种连贯的类型体验,就是"t3code"这个项目整套架构的核心回报。它意味着你改模型时,编译器帮你划定了所有受影响的范围。
5. 生产环境必踩的坑:鉴权、部署、性能都是要自己做主的环节
5.1 用NextAuth给tRPC路由加一道闸
开发环境里把CRUD跑通只是开始,生产环境第一件事就是鉴权。我的做法是用NextAuth配合tRPC的middleware机制实现"未登录不能操作"。
脚手架自带的src/server/api/trpc.ts里已经有一个publicProcedure,它没有任何权限校验。我在它旁边又定义了一个protectedProcedure:
import { TRPCError } from "@trpc/server"; import { createTRPCContext } from "~/server/api/trpc"; const enforceUserIsLoggedIn = tRPC.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session } }); }); export const protectedProcedure = tRPC.procedure.use(enforceUserIsLoggedIn);然后在需要保护的router里,把publicProcedure换成protectedProcedure即可。这里有个容易踩的坑需要专门说:写中间件时必须返回next({ ctx }),如果你在中间件里直接返回一个正常对象而不调用next,下游procedure根本不会执行;如果直接返回next()而不合并context,下游就收不到session。搞清了这两个差异,tRPC的鉴权链路就通了大半。
5.2 部署到Vercel时我踩过的构建顺序问题
本地一切正常,推到Vercel上就翻车,是T3 Stack新手最容易遇到的状况。我在第一次部署时遇到两个错误,都值得记录。
第一个错误是构建阶段找不到Prisma Client。根因是prisma generate在构建流程里没有执行。Vercel部署时会在构建阶段跑一遍依赖安装和项目构建,但如果不显式执行prisma generate,Prisma Client的代码就不会生成,任何用到ctx.db的代码都会因模块缺失而编译失败。解决方案是在package.json里加一个postinstall脚本:
"scripts": { "postinstall": "prisma generate" }这样每次安装依赖后都会自动生成Prisma Client,构建就有了保障。
第二个错误是数据库结构没有同步。本地开发用migrate dev随时改schema,但Vercel部署不会自动执行迁移。生产环境的schema变更需要手动跑prisma migrate deploy,这个命令会只执行未应用的迁移文件,比直接db push安全得多。正确的流程是:本地开发用migrate dev写迁移,代码合并前把迁移文件一起提交,部署时在构建或发布阶段单独跑prisma migrate deploy把结构同步到生产数据库。
还要提醒的是,DATABASE_URL这类环境变量必须配置在Vercel的环境变量面板里,而且要用生产数据库的地址,别把本地Postgres的连接串粘上去了。
5.3 React Query缓存与SSR的边界怎么把握
在页面组件里我用的是纯客户端调用的useQuery,这意味着数据会在浏览器端请求并缓存。对于管理后台、内部工具这类实时性要求不高但操作频发的场景,这种模式没问题,甚至在体验上更好——切换页面回来不用重新发请求。
但如果你做的是公开的、对首屏渲染有要求的页面,就需要考虑SSR或者Server Component方案。tRPC的服务端调用方式也很直接,在Server Component里可以直接await api.task.list(),数据在服务端获取并渲染成HTML返回。我实际写下来发现一个判断标准:数据是否跟当前用户的session强相关。敏感数据走服务端渲染更安全,普通列表数据用客户端请求+缓存会更灵活。
有几个常见的性能误配置我也在这里一并说了。不要在客户端useQuery里去改staleTime为超大值后又在同样页面用服务端渲染,两套数据源会打架;不要把所有列表请求都开缓存而不做invalidate,改完数据列表不刷新会让人以为"接口挂了";不要在每次render时创建新的mutation对象,React Query内部的稳定化处理会让它正常,但如果你在useEffect里手动触发mutate,要去读loading状态而不是手动管理isFetching。
5.4 我踩过的三个典型错误汇总
第一个是tRPC context里的session类型问题。NextAuth的session类型默认是普普通通的Session,但你自定义了session.user.role这种字段时,TypeScript不会直接认账。解决方式是在项目里声明一个类型增强文件,把session的类型补进去。
第二个是"App Router + tRPC + Server Component"的传参问题。在Server Component里调用tRPC后直接把这个数据作为props传给Client Component没有问题,但如果你试图在Client Component里再次await api.task.list(),是不行的——Client Component不支持async函数。这个限制不是tRPC的问题,是Next.js对Client Component的约束。我一开始没搞懂,写出来让还在这条路上摸索的人少花点时间。
第三个是zod schema在input里用了date类型时的坑。zod的z.date()在客户端序列化到服务端时,日期会被转成字符串还是保持Date对象,不同配置下结果不同。我后来统一在输入层用z.string().datetime()收字符串,在procedure内部再转成new Date(),绕开序列化边界的不确定性。
6. 什么时候我会主动放弃这套方案
讲了这么多好处,必须说点反话。t3code这套组合不是万能模板,在我做项目决策时,有三类场景我会果断放弃它。
第一类是纯展示型网站或落地页。没有用户交互、没有数据持久化、没有内部权限,只是几个静态页面的话,上Next.js+T3 Stack纯粹是给简单任务增加复杂度。这种场景一个Vite+Tailwind就能解决,连服务端都不需要。
第二类是"前后端团队完全分离"的组织结构。tRPC最大的优势在于前后端共享类型,但前提是前后端代码在同一个仓库、同一次部署中。如果前端团队和后端团队各自维护独立代码库,后端已经有一套对外REST API或GraphQL服务,那前端硬接tRPC反而会破坏边界,这时候老老实实走OpenAPI规范生成客户端类型才是正解。tRPC适合"一个人或一个小团队既写后端又写前端"的场景,它的边界是代码仓库边界,不是团队边界。
第三类是接口需要开放给第三方使用。tRPC的接口契约是TypeScript类型,而外部系统不一定用TypeScript,也不一定愿意接入你的router结构。公开API还是用REST或GraphQL这种语言无关的规范更合适。
所以我现在的判断标准很简单:项目是内部闭环、全栈同仓、类型敏感度高的,用t3code这套流;项目是开放生态、多语言、强契约的,回到传统API方案流。
最后分享一个我实际用下来的经验。如果真的想用T3 Stack跑一个正式项目,不要从零开始理解所有概念再动手,最好是直接拿create-t3-app生成一个项目,照着默认结构把一个最小的CRUD跑通,再逐步加鉴权、加业务、加部署。等你把第一个模块完整落地,再回头看"类型是怎么流动的"这个问题,会比读十篇文章都清楚。我在t3code项目里踩过的坑,基本都写在上面了,希望你走得比我顺。