T3 Stack全栈实践:从Prisma到tRPC的端到端类型安全建设
这两年写前端项目我最烦的就是前后端联调时类型全靠猜——后端改个字段名前端跑起来才报错数据库模型动了API文档没跟着动整个团队全靠口口相传。今年我抽时间做了一个全栈项目代号就叫t3code意思很直白基于T3这套技术栈把类型安全从数据库一路贯穿到页面组件。项目是个面向小团队的内部知识库工具包含用户认证、文档管理、按空间隔离的权限体系和全文搜索前后端代码加起来不到一万行。如果你也在纠结全栈技术栈选型或者想看看端到端类型安全到底长什么样这篇实战记录应该能给你一个比较完整的参考。做这个项目之前我在Next.js、NestJS、Remix之间反复横跳过。最后选T3这套组合不是因为它新而是因为它把类型安全这根线串得最紧——从数据库表结构到API输入输出再到前端拿到的数据全程不靠手写接口文档。这半年踩了不少坑也改了不少自以为是的写法下面把设计思路、核心配置、实战细节和排查经验都捋一遍。1. 为什么叫T3Code全栈类型安全这件事本身的价值1.1 从写得不爽到T3Code的由来做t3code之前我上一个全栈项目还是Next.js Axios 自建Express。那会儿最难受的不是写代码而是改代码后端加一个字段前端要翻网络面板看返回值Prisma模型加一张关联表接口层和页面层的类型定义要手动同步。改一个老功能光是找类型定义就要花半天改完还不敢保证没漏。后来我接触了T3 Stack核心思路很简单——把TypeScript的泛型推导做到极致让数据库、服务端、客户端共享同一套类型系统。你写完Prisma模型数据库字段类型自动推导到tRPC路由的输入输出前端调用tRPC时拿到的数据天然带完整类型提示字段改名后在编译期就报错。t3code这个项目名其实就是T3 Code的拼写我用这套技术栈写出来的代码理论上不该有后端字段和前端对不上这种低级问题。1.2 T3到底解不解决痛点T3 Stack常被简单说成TypeScript Tailwind CSS tRPC但真正让这套组合起飞的是它把Next.js、Prisma、NextAuth这些成熟工具焊在了一起。它的核心价值我总结为三件事第一类型推导的链路完整。从数据库到API再到组件中间不打断。你不需要在API层重新定义一遍DTO不需要在前端再写一遍interface类型能从Schema自动流到页面。第二开发心智负担小。tRPC的router写法接近本地函数调用不需要学习RESTful路由命名规范也不用纠结GraphQL的field和fragment设计。对中小型项目的CRUD场景来说这是实打实的效率提升。第三退化路径清晰。T3 Stack只是默认推荐组合你完全可以只取其中一部分不想要Prisma可以换Drizzle不想要NextAuth可以自己对接session。它是脚手架不是枷锁。当然它也有代价。类型系统越强心智负担其实越往后越重——tRPC的泛型报错信息经常长得像天书服务端组件调用tRPC的方式跟客户端还不一样。这些坑后面我细说。2. 项目初始化与基础配置细节2.1 脚手架搭建和目录结构设计t3code是用create-t3-app初始化的命令很简单pnpm create t3-applatest t3code交互式选项里我勾了Next.js App Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth。数据库选了PostgreSQL。说实话如果从零手动搭这套东西光版本匹配就要搞半天脚手架帮你省掉了这一步但代价是你得接受它默认的模板结构。初始化之后的目录我做了两处调整。第一把所有业务代码按模块放在src/server/和src/app下Procurement不再用pages目录统一走App Router的app/目录第二单独建了src/env.mjs管理环境变量把数据库地址、认证密钥、站点URL这些配置集中起来方便本地和线上切换。模板自带的目录已经很清晰我保留了大框架只在components/ui里放了一堆自己写的通用小组件。2.2 TypeScript严格模式与全栈类型联动的两个关键配置T3模板默认开了strict: true这个没商量建议任何项目都别关。但有两个配置是t3code里真正影响全栈类型联动的值得单独提。第一个是moduleResolution和module的配置。App Router下我用的moduleResolution: Bundler搭配module: ESNext。它能正确识别/components/...这类路径别名也能让tRPC客户端和服务端的类型推导不被打断。如果配置不对最常见的现象是import { type RouterOutputs } from ~/server/api/root时会报模块解析错误。第二个是tsconfig里的customConditions或者叫moduleSuffixes。tRPC的server caller调用客户端组件边界时会出现服务端能跑但类型不对的情况原因就是App Router区分server-only和client-only代码TypeScript默认不会自动识别这些条件导出的包。我后来在tsconfig里加了customConditions: [t3-oss/env-core, server]这类条目才让类型在server/client两个环境里各归各位。这里提醒新手一句别小看tsconfig的报错。t3code开发初期我为了少改几行配置把strict关了半小时结果tRPC的inferred type直接崩了Prisma生成的类型也全变成any。类型安全这种雪崩效应真的宁可信不过也不要自废武功。2.3 Tailwind CSS的样式组织策略Tailwind在t3code里承担的是快速搭界面的角色。我没有用什么CSS-in-JS方案直接用Tailwind的utility class配合一点点自定义CSS。样式这块最大的体会是组件别写太长不然class成堆看着头皮发麻。我的策略是三层基础层用layer base定义全局的字体、背景色、默认边框组件层用layer components封装几个高频组件比如btn、card、input样式剩下的原子class直接写在JSX里。这样既避免了utility class地狱又没引入额外的组件库依赖。另外Tailwind和Prisma、tRPC配合时要注意HMR的坑。开发时改Prisma schema之后临时生成的.prisma/client文件类型没更新会导致页面刷新时报Property does not exist on type。解决方式就是改完schema后跑一次pnpm prisma generate或者给它配一个watch命令。反正我后来直接在package.json里加了dev: concurrently \next dev\ \prisma generate --watch\省心很多。3. 核心功能模块的实战实现3.1 数据模型设计与Prisma迁移t3code的核心数据模型围绕四个实体展开用户User、空间Space、文档Document和成员关系SpaceMember。用户和空间是多对多关系文档属于某个空间且由一个用户创建。Prisma schema里最关键的几个模型定义如下model User { id String id default(cuid()) email String unique name String? passwordHash String? role Role default(MEMBER) memberships SpaceMember[] documents Document[] createdAt DateTime default(now()) } model Space { id String id default(cuid()) name String slug String unique members SpaceMember[] documents Document[] createdAt DateTime default(now()) } model SpaceMember { id String id default(cuid()) space Space relation(fields: [spaceId], references: [id], onDelete: Cascade) spaceId String user User relation(fields: [userId], references: [id], onDelete: Cascade) userId String role SpaceRole default(EDITOR) joinedAt DateTime default(now()) unique([spaceId, userId]) } model Document { id String id default(cuid()) title String content String space Space relation(fields: [spaceId], references: [id], onDelete: Cascade) spaceId String createdBy User relation(fields: [createdById], references: [id], onDelete: Cascade) createdById String createdAt DateTime default(now()) updatedAt DateTime updatedAt }这里有几个设计决策值得说道说道。角色字段我用枚举Role和SpaceRole区分全局角色和空间内角色——全局角色管能否登录、能否建空间空间角色管空间里的读写权限。这是内部知识库工具的典型需求一个用户在公司层面是普通成员但在某个空间里可以是管理员。数据库迁移我用的Prisma Migrate。开发环境改schema后执行pnpm prisma migrate dev它会自动生成迁移文件并同步数据库。生产环境则用pnpm prisma migrate deploy这个区别一定要记住dev会提示你重置数据库deploy只是把迁移应用到已有数据库不会清数据。3.2 tRPC路由设计与类型安全的API调用tRPC是t3code的核心通信层。我建了三个路由文件authRouter.ts、spaceRouter.ts、documentRouter.ts每个文件导出对应的router最后在root.ts里合并。这里重点说documentRouter里的权限校验逻辑。每次在公司知识库里创建文档都要先确认当前用户在该空间里有编辑权限否则直接拒绝import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; import { TRPCError } from trpc/server; export const documentRouter createTRPCRouter({ create: protectedProcedure .input(z.object({ spaceId: z.string().min(1), title: z.string().min(1).max(200), content: z.string().default(), })) .mutation(async ({ ctx, input }) { const membership await ctx.db.spaceMember.findFirst({ where: { spaceId: input.spaceId, userId: ctx.session.user.id, }, }); if (!membership || membership.role VIEWER) { throw new TRPCError({ code: FORBIDDEN, message: 你在这个空间中没有编辑权限, }); } return ctx.db.document.create({ data: { title: input.title, content: input.content, spaceId: input.spaceId, createdById: ctx.session.user.id, }, }); }), });类型安全在这段代码里体现得最明显ctx.session.user.id的类型由NextAuth的session类型推导input.title由zod schema推导ctx.db.document.create的data字段由Prisma模型推导。三个来源的类型在编译期就被tRPC对齐了前端调用时根本不用猜字段类型。前端调用也非常直接import { api } from ~/utils/api; const mutation api.document.create.useMutation({ onSuccess: () { // 创建成功后的处理 }, }); mutation.mutate({ spaceId: cm..., title: 研发周报, content: # 本周进展, });这种写法对前端同学来说几乎零学习成本不用拼接URL、不用import一堆API类型定义编辑器里敲api.就有完整的路由提示。3.3 用户认证与授权接入NextAuth认证这块我用了NextAuth.js。为了少接外部服务t3code用的Credentials Provider也就是邮箱密码登录。密码用bcryptjs哈希后存passwordHash字段。NextAuth核心配置在src/server/auth.ts里几个关键点import { NextAuthOptions } from next-auth; import CredentialsProvider from next-auth/providers/credentials; import { PrismaAdapter } from next-auth/prisma-adapter; import { compare } from bcryptjs; import { db } from ~/server/db; import { env } from ~/env.mjs; export const authOptions: NextAuthOptions { adapter: PrismaAdapter(db), session: { strategy: jwt }, providers: [ CredentialsProvider({ credentials: { email: { label: 邮箱, type: email }, password: { label: 密码, type: password }, }, async authorize(credentials) { const user await db.user.findUnique({ where: { email: credentials?.email }, }); if (!user?.passwordHash) return null; const ok await compare(credentials!.password, user.passwordHash); if (!ok) return null; return { id: user.id, email: user.email, name: user.name }; }, }), ], callbacks: { jwt({ token, user }) { if (user) token.id user.id; return token; }, session({ session, token }) { if (session.user) session.user.id token.sub!; return session; }, }, };这里有两个容易踩的坑。一是session策略我选了jwt而不是database——因为Credentials Provider无法在数据库中创建session记录用database策略会报错。二是authorize函数里如果用户不存在或密码错误统一返回null这样不会泄露邮箱是否存在这类信息。权限隔离在t3code里分两层页面级用NextAuth的getServerSession判断是否登录操作级用tRPC的protectedProcedure拦截未登录请求。中间件我放在src/middleware.ts里只做粗粒度的登录拦截细粒度的空间权限全部放在tRPC路由里校验。3.4 全文搜索与异步任务的处理细节知识库工具少了搜索就是残废。刚开始我打算接一个独立的全文搜索引擎但考虑到部署运维成本最后用PostgreSQL自带的全文搜索顶上了。Prisma不支持原生的to_tsvector查询我通过$queryRaw写了一段搜索search: protectedProcedure .input(z.object({ keyword: z.string().min(1) })) .query(async ({ ctx, input }) { const results await ctx.db.$queryRaw SELECT id, title, excerpt FROM Document WHERE to_tsvector(simple, title || || content) plainto_tsquery(simple, ${input.keyword}) LIMIT 20 ; return results; }),这段代码工作得很好但有个前提数据库里得建好全文索引否则数据量一上来性能会崩。我在迁移文件里手动加了一个GIN索引t3code现在几千篇文档的搜索响应都在100ms以内完全够用。异步任务这块我遇到的实际需求是文档导出为Markdown文件并发送到指定邮箱。起初直接在API路由里同步做用户操作要等好几秒。后来改成把任务写入一张ExportTask表用一个轻量的定时器轮询处理前端轮询任务状态。虽然不够高级但对内部工具来说稳定最重要。4. 部署上线与性能优化实录4.1 数据库连接与生产环境的Prisma配置t3code部署在Vercel的Serverless环境数据库用独立部署的PostgreSQL实例。这里有个比较隐蔽的问题Serverless函数是冷启动的每个函数实例启动时都会初始化Prisma Client如果连接池配置不好数据库连接数会被瞬间打满。我在Prisma连接串里加了连接池参数DATABASE_URLpostgresql://user:passhost:5432/db?connection_limit5pool_timeout10同时在src/server/db.ts里用了全局单例模式import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error] : [error], }); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;不要小看这个全局单例。在Next.js开发模式下每个文件的更新都会触发模块重新加载如果没有这种保护可能会创建一堆Prisma Client实例连接数耗尽只是时间问题。4.2 静态资源与图片优化t3code里文档允许上传封面图最开始是直接存到服务器磁盘结果部署到无状态Serverless环境后图片根本存不住。后来改成了对象存储方案图片上传走预签名URL链路是前端请求后端获取上传凭证——前端直传对象存储——数据库只存图片路径。图片展示方面我用了Next.js自带的next/image组件配置了远程图片域名白名单。要注意的是如果你的对象存储域名不在images.remotePatterns配置里图片组件会直接报错。这个配置在next.config.mjs里images: { remotePatterns: [ { protocol: https, hostname: cdn.example.com }, ], },4.3 边缘运行时兼容性排查上线前我把middleware.ts放到Edge Runtime跑结果踩了一个大坑NextAuth的getToken在Edge环境跟Node环境行为不太一样。如果你在middleware里读取session建议直接用jose库解析JWT别依赖next-auth/jwt的默认方法否则在Edge模式下会报Secret required这类摸不着头脑的错误。我的中间件逻辑最终只做了三件事检查cookies里有没有session token、判断路径是否受保护、如果没登录就重定向到登录页。如果要做细粒度的权限判断建议移到tRPC或Server Component里做不要在Edge层过度处理。另外Prisma Client在Edge Runtime下默认不支持这是个大坑。t3code的API路由是在Node运行时跑的如果硬要在Edge Runtime里跑Prisma查询要么用Data Proxy方案要么就换运行时。我的解决方案是按路由区分重逻辑走Node运行时轻量跳转逻辑走Edge别指望一套配置打天下。5. 开发中遇到的坑与排查经验5.1 服务端组件与tRPC调用的常见误区tRPC在App Router里有两个入口客户端组件通过TRPCReactProvider调用服务端组件通过createCaller直接调。很多教程只讲了前者导致你在Server Component里写api.document.list.useQuery()时会直接报useQuery is not a function。正确做法是import服务端的callerimport { createCaller } from ~/server/api/root; import { authOptions } from ~/server/auth; import { getServerSession } from next-auth; const session await getServerSession(authOptions); const caller createCaller({ session }); const docs await caller.document.list({ spaceId });这里有个很容易忽略的细节服务端调用器的context要先构造成和HTTP请求一致的对象否则session不完整权限校验会误判。我一开始省掉了session传参结果所有调用都变成未登录状态。5.2 类型推导丢失与泛型陷阱tRPC的类型推导依赖zod的输入输出类型如果你在route里用了z.object().passthrough()或者给input加了any整条调用链路上的类型提示就断了。还有一次我用useQuery时写错了泛型参数TS直接推断成never排查了半小时才发现是方法名拼错。遇到类型丢失我的排查顺序是先看zod input是否写了as const——不需要加再看root.ts里的createTRPCRouter是不是漏了mergeRouters最后跑tsc --noEmit全量检查让编译器告诉你哪里断了。别在编辑器里瞎点类型推导问题是结构性的不可能只修一个文件解决。5.3 依赖版本升级的破坏性变更t3code开发到中期正好赶上Next.js 14到15、tRPC v10到v11的大版本切换。升级时我被坑得最惨的是tRPC的TRPCError导入路径变了导致一堆文件报错。还有Prisma 5.x把prisma/client的PrismaClient构造函数行为改了本地连接池配置失效。这种上游框架的破坏性变更没办法完全避免。我的经验是t3code这类用最新脚手架的项目千万别在开发中途频繁跨大版本升级。如果非要升先看官方升级指南的迁移清单然后改完依赖直接跑tsc和全量测试别手动一个一个文件排查。5.4 问题排查速查表下面这个表是从t3code实际开发中整理出来的高频问题省得大家再走一遍弯路。常见问题现象原因与解决思路Prisma模型改动后类型不更新页面报Property does not exist on type改schema后必须执行prisma generate建议加watchtRPC客户端类型断掉useQuery返回never检查zod schema是否显式声明了input/output类型别用隐式推断Server Component里调用tRPC失败报useQuery is not a function应该用createCaller创建服务端调用器不能直接复用客户端hookNextAuth在Edge环境解析session失败middleware里getToken返回nullEdge环境建议用jose库直接解析JWTServerless环境数据库连接爆满部署后请求大量超时给连接串加connection_limitPrisma Client做成全局单例图片组件报域名错误远程图片不显示在next.config.mjs里配置images.remotePatterns搜索查询很慢文档多了以后搜索卡顿给数据库表建GIN全文索引SQL里用to_tsvector和操作符tRPC大版本升级后报导入错误TRPCError找不到模块按官方迁移文档改import路径重点检查trpc/server子路径5.5 避坑技巧的补充最后再补一条很多人不知道的细节不要把NODE_ENV跟DATABASE_URL在.env里写死。开发环境、预览环境、生产环境应该各自有一套清爽的环境变量配置最好通过src/env.mjs统一校验。t3code在一次部署中因为把本地数据库连接串误带到生产环境导致线上数据全被dev环境覆盖还好有自动备份才保住。生产环境数据库的写入操作任何时候都要先在staging环境完整走一遍。另外生产环境部署新版本前一定记得跑一次prisma migrate deploy并且把迁移脚本放在构建流程里。如果代码已经部署了但数据库迁移没跑线上页面会因为你用了新字段而直接500。我的做法是在部署脚本里先执行迁移再执行build两条指令串起来顺序绝对别反。