去年年底我接手了一个遗留系统前端靠手写的接口类型定义后端改了返回值前端直接崩查了半天发现是某个接口悄悄把data从数组改成了对象。那段时间我一直在琢磨怎么从根上治这种问题后来干脆搭了一个代号叫t3code的全栈工程样板核心思路就是让 TypeScript 的类型从前端一路贯通到数据库接口不再是“约定”而是编译期就会报错的硬约束。这篇文章就把这个项目从选型、搭建到踩坑的完整过程拿出来聊聊适合那些受够了“前后端类型各写一份”的人也适合想了解 T3 Stack 到底怎么落地的小白。1. t3code 的前世今生T3 Stack 到底解决了什么痛苦先说清楚一件事t3code 不是某个现成的开源框架我当时只是用这个代号来称呼自己这套全栈工程模板。它背后的技术组合有个统称叫 T3 Stack三个 T 分别指 TypeScript、Tailwind CSS 和 tRPC。很多人第一次听会以为这东西要代替 Next.js 或者 NestJS其实不是它更像一套“怎么组织全栈代码”的路线图。1.1 三个 T 分别承担什么角色TypeScript没什么好说的现代前端的底线。但 T3 Stack 里的 TypeScript 不是“用了就是”而是要把它当成整个项目的第一公民所有跨端数据都要有类型。Tailwind CSS这是个样式方案。你没看错T3 这个缩写里的第二个 T 是样式工具因为创始人觉得样式不该成为类型系统的阻碍。用 Tailwind 可以少写很多 CSS 文件组件和样式内聚后文我会讲到实际开发里它怎么配合。tRPC这是最关键的一环。它让你不需要写 REST 路由、不需要定义 GraphQL schema直接在服务端写函数前端像调用本地方法一样调用它。类型自动推导零代码生成。这三个东西组合起来最大的收益是一个字段从数据库表格到后端函数参数再到前端 UI全程类型可追踪。我实测下来几乎所有因为“接口字段写错”导致的线上 bug 都消失了因为写错的那一刻 TypeScript 编译就直接失败。1.2 为什么说这是一套“闭环”思维传统前后端分离的思路是后端定义接口文档前端对着文档写类型再手动封装请求函数。问题在于文档会过期人手会疲惫最后类型经常变成any或者unknown。t3code 的思路是反过来的我不维护接口文档我只维护服务端函数的真实签名。前端用的类型不是抄来的而是从函数typeof推导过来的。这个闭环一旦建立你改后端字段名前端在同一仓库里编译立刻报错根本不给“跑起来才坏”的机会。注意这套方案最适合全栈项目或者前后端同仓库的场景。如果是大厂那种前后端完全分开、不同团队维护的架构tRPC 的收益会被削弱因为跨越仓库边界的类型传递需要额外工程手段下一篇我可以专门讲怎么处理。2. 工程骨架搭建为什么是 Next.js tRPC Prisma 的组合技术选型的时候我确实纠结过。REST 是老本行GraphQL 我也在生产上用过两年但最终 t3code 选的是 Next.js tRPC Prisma。下面这个对比表是我当时整理的真实感受。方案类型安全程度学习成本请求效率适合场景REST 手写类型低全靠自觉低中简单项目、前后端分离团队GraphQL中需要额外维护 schema高高字段裁剪方便复杂聚合查询、多客户端tRPC高编译期强制中中无字段裁剪全栈同仓库、追求开发效率这个表里最重要的差异是第一列。GraphQL 的类型安全是用 schema 换来的你得写一堆 resolver、dataloader、fragment写多了真的累。tRPC 则是把你已有的 TypeScript 函数类型直接复用可以说是不付额外代价地拿到了全链路类型。2.1 初始化一条命令搭好底座我用的脚手架是create-t3-app它会把 Next.js、Tailwind、Prisma、NextAuth、tRPC 一次性配好免去自己拼积木的痛苦。npx create-t3-applatest t3code注意初始化过程中它会问你选哪些模块。我的建议是App Router 选上这是 Next.js 当前主力但 tRPC 相关的调用方式和 Pages Router 略不同后面我会提到。Prisma 必选没有 ORM 的话你的数据库访问又回到手写 SQL 的时代类型安全断了一截。NextAuth 看需求。如果你做的是纯公开内容站可以先不选省掉一层复杂度。Tailwind 选上样式统一性收益很大。2.2 目录结构里先看懂这几层脚手架生成的结构里真正决定 t3code 命运的目录是src/server和src/trpc。src/server/api/routers放你所有业务函数tRPC 叫 router比如postRouter、userRouter。src/server/api/trpc.ts定义 tRPC 实例、context、公共中间件。src/trpc/react.tsx前端客户端把后端类型绑定过来。src/server/db.ts初始化 Prisma Client。我见过不少新手一上来就把所有业务逻辑往页面组件里堆结果类型推导乱成一团。t3code 的规矩是页面是 UI 层只负责调用api.xxx业务逻辑全部收在routers里。这样出问题的时候定位非常快。2.3 为什么强调“编译期安全”而不是“运行时安全”很多人会觉得“我 postman 测过没问题”就等于安全。但运行时安全是点状的你只测了你测过的路径编译期安全是面状的代码写出来的那一刻所有可能的参数类型都被约束住了。t3code 的整套设计都在追求这种面状安全。比如后端有个函数export const getPost protectedProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) { return ctx.db.post.findUnique({ where: { id: input.id } }); });前端调用时如果传了一个number而不是stringIDE 里直接标红。这一步是在敲键盘的当下完成的而不是等请求发出去才收到 400。3. 拆开 tRPC 的“魔法”router、procedure 和上下文tRPC 刚接触时会觉得像魔术其实原理并不复杂。你写的每个 procedure 都可以理解成一种特殊封装的 API 函数tRPC 会帮你自动生成 client 端的调用函数并且保留完整的类型信息。3.1 最小可运行例子一个 hello world 级别的 router创建一个src/server/api/routers/hello.tsimport { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const helloRouter createTRPCRouter({ world: publicProcedure .input(z.object({ name: z.string().default(world) })) .query(({ input }) { return { greeting: hello, ${input.name} }; }), });然后在主 router 里挂载export const appRouter createTRPCRouter({ hello: helloRouter, });前端这样调用const data await api.hello.world.query({ name: t3code });你不需要写请求路径不需要处理序列化data.greeting是 string 类型编译期就知道。这是我当时第一次觉得“工程还能这么省事”的瞬间。3.2 context 和 middleware把数据库和登录态注入进去procedure 里我经常用ctx.db、ctx.session这些不是魔法变量是 tRPC 的 context 提供的。你可以在src/server/api/trpc.ts里配置。export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { db, session, headers: opts.headers, }; };这样每个 procedure 都能访问到 session 和数据库实例。接下来用 middleware 做鉴权就非常清爽export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: ctx.session } }); });把publicProcedure换成protectedProcedure这个接口就变成必须登录才能访问。整个权限判断不再散落在各个页面里而是收敛在接口定义这一层。3.3 常见误区在 Server Component 里直接调用 tRPCNext.js 的 App Router 有个 Server Component 的概念一开始我觉得既然服务端可以直接查数据库那在 Server Component 里直接用 Prisma 不就行了为什么还要走 tRPC答案是可以但会破坏类型闭环。如果你在 Server Component 里直接查数据库、把数据当 props 传给 Client Component那你又要手工定义一份“页面 props 类型”回到了各写一份的老路。t3code 的建议是跨网络边界的调用才走 tRPCServer Component 内部可以直接用 Prisma但前提是这些 Server Component 不外发数据给客户端组件。注意如果你想在 Server Component 里调用 tRPC 且不想引入额外复杂设计建议直接把相应查询函数拆到 server 层工具里两边都引用同一个函数不要一个逻辑写两遍。4. 从 User 表到权限校验完整链路怎么走很多模板项目只把“类型闭环”停留在 demo 层级到了真实业务马上就乱。t3code 当时测试的题目是用户注册、登录、查询自己的资料、管理员可以删帖。这四件事覆盖了数据库建模、会话、角色鉴权三个核心环节。4.1 Prisma schema 设计先想清楚关系再写代码项目里最核心的两张表用户表和帖子表。帖子属于用户管理员可以删任何人的帖子。Prisma 里这样表达model User { id String id default(cuid()) name String? email String unique role Role default(USER) posts Post[] } enum Role { USER ADMIN } model Post { id String id default(cuid()) title String content String authorId String author User relation(fields: [authorId], references: [id]) createdAt DateTime default(now()) }写 schema 的时候有个细节枚举类型Role直接放在 Prisma 里定义比在应用层用字符串强得多因为数据库层面就会校验类型也会推导到前端。4.2 NextAuth 接入与 Session 固化NextAuth 在 t3code 里的作用是管理登录会话。典型配置是 Credentials 模式或者接 GitHub、Google 的 OAuth看业务需要。关键是让 NextAuth 的回调把user.role注入 sessioncallbacks: { session({ session, user }) { if (session.user) { session.user.role user.role; } return session; }, }注意这里有个常见问题默认 session 类型里没有role字段TypeScript 会直接报错。你需要扩展类型声明declare module next-auth { interface Session { user: { id: string; name?: string | null; email?: string | null; role: Role; }; } }这个步骤很多人会漏漏了的结果就是类型上和实际运行时不一致正好违背了 t3code 的初衷。4.3 用 tRPC middleware 做角色鉴权有了 session 里的role管理员接口的判断就很简单了。先在trpc.ts定义 adminProcedureexport const adminProcedure protectedProcedure.use(({ ctx, next }) { if (ctx.session.user.role ! ADMIN) { throw new TRPCError({ code: FORBIDDEN }); } return next({ ctx }); });然后在删除帖子的 router 里用它export const postRouter createTRPCRouter({ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { await ctx.db.post.delete({ where: { id: input.id } }); return { success: true }; }), });前端调用api.post.delete时如果登录用户不是管理员请求返回 FORBIDDENUI 上再做一层提示。整个链路下来权限控制不再是“页面隐藏按钮”这种表面功夫而是后端接口级别的硬限制。5. 调试实录三个让我通宵的坑t3code 开发到中期的时候项目一上生产就连续踩了三个大坑。每个都花了我几个小时甚至通宵单独拿出来说因为我相信很多人都会遇到。5.1 Prisma 在 Serverless 环境下的连接数爆炸先说现象部署到 Vercel 之后数据库连接数一会儿就满了报错信息是Too many connections。查了一会儿才反应过来Serverless 环境下每个请求都会新建一个函数实例每个实例都会初始化 Prisma Client连接池全被吃掉了。解决方法是把 Prisma Client 实例缓存到全局const globalForDb globalThis as unknown as { prisma?: PrismaClient }; export const db globalForDb.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForDb.prisma db;这个写法和 Next.js 官方文档一致原理是开发环境热更新会重复创建实例生产环境则要保证请求之间复用连接池。注意如果你是自建 Node 服务而不是 Serverless这个坑可能不出现但最好也做同样处理避免长时间运行后连接数缓慢泄漏。5.2 tRPC queryKey 序列化导致的无限重渲染然后是前端的问题。页面加载后一直重复请求同一个查询浏览器 Network 面板里一片雪花。最后定位到是 React Query 的 queryKey 问题。tRPC 内部会让 React Query 根据输入参数生成 queryKey如果输入是一个对象并且对象里的字段顺序不稳定那每次生成的 key 都可能不一样React Query 认为这是新查询自然无限触发。解决方式有两种保证输入对象的字段顺序稳定比如先用z.object做严格结构校验。给查询配置staleTime和gcTime减少不必要的重取。const utils api.useUtils(); await utils.post.list.invalidate();在数据变更后手动invalidate相关查询比期望它自动更新更可控。这个坑让我重新理解了 tRPC React Query 的缓存机制它确实是“魔法”但底层还是朴素的查询 key 匹配。5.3 Edge Runtime 与 Prisma 不兼容最后一个坑是关于 Next.js 中间件和 route handler 的。我在某个中间件里加了 Prisma 调用部署后直接报错提示 Prisma 只能在 Node.js runtime 运行。原因是 Next.js 中间件默认跑在 Edge Runtime 上而 Prisma 依赖 Node.js 的原生模块Edge 环境不支持。解决方案是把涉及 Prisma 的代码放到 Node runtime 的 Route Handler 或者 tRPC 底层中间件里只做轻量逻辑。export const config { runtime: nodejs, };如果你确实需要在边缘层访问数据库那得换prisma/extension-accelerate之类的方案让查询走 HTTP 到 Prisma 的缓存层。当时我评估后觉得复杂度不值得直接把中间件里的数据库逻辑挪走了。这三个坑总结起来就一句话框架越好用越要注意底层运行时的边界。tRPC 和 Prisma 帮你生成了大量代码但运行环境的差异是帮不了你的只能靠经验积累。6. 生产环境部署与响应优化配置细节和实测数据t3code 从开发到上线部署和优化其实占了不少时间。如果你只是本地跑着玩可以跳过这节如果要上真实业务下面这些配置能帮你少走很多弯路。6.1 部署目标与最小配置我的部署目标是 Vercel因为 Next.js 对它支持最好环境变量管理也方便。你需要配置DATABASE_URL指向你的 Postgres 数据库。NEXTAUTH_SECRETNextAuth 的加密密钥生成方式openssl rand -base64 32。NEXTAUTH_URL生产环境的域名。Vercel 上要注意 Prisma 的迁移问题。我的做法是本地执行prisma migrate deploy之后再发布不依赖 Serverless 函数去干预数据库结构。如果团队多人协作建议在 CI 里加一个迁移 job避免有人忘了跑迁移就上线。6.2 响应时间优化缓存、并发与数据裁剪上线初期我测了几组数据首页接口响应在 200ms 到 400ms 之间数据库查询占了大头。做了三件事之后降到了 80ms 左右。第一列表查询加PRISMA的查询缓存也就是数据库中间层加速。我当时的场景是读多写少用 Prisma Accelerate 或者直接给查询加cacheStrategy效果立竿见影。import { unstable_cache } from next/cache; export const getPublicPosts unstable_cache( async () db.post.findMany({ take: 20 }), [public-posts], { revalidate: 60 } );第二前端给查询设置合理的staleTime。读操作默认 60 秒内不再重新请求用户在页面间跳转时体验会流畅很多。第三注意 N1 查询问题。Prisma 的include虽然方便但用多了一不小心就会产生多条 SQL。发布前用queryLog抓了一轮凡是循环里带findUnique的地方都改成了findMany 内存映射。6.3 后续演进monorepo 与跨端复用t3code 跑顺之后我下一步的规划是把它拆成 monorepo原因是想复用同一套类型定义到移动端。tRPC 官方提供了tRPC client的独立包意味着 React Native 也可以直接调用同一套 router。结构上可以分成apps/webNext.js 前端。apps/mobileExpo 应用。packages/apitRPC router 和类型。packages/dbPrisma schema 和 Client 实例。这个拆分的好处是packages/api里的 router 可以被两端引用数据库字段改动后所有端一起编译报错。坏处是初期工作量会增加如果你是个人项目或者小团队建议先保持单体等确实有多个端再拆。7. 最后分享一点个人体会t3code 这个项目做下来我最深的感受是类型安全不是银弹但它是性价比极高的投入。你多写几个zodschema、多建几个 tRPCprocedure的功夫换来的是上线之后少出现的“字段对不上”这类破问题。我也遇到过有人问我t3code 这类架构是不是要抛弃 REST 了我的回答是工具要服务于项目阶段和团队习惯。如果你是独立开发全栈网站或者团队不大、大家都用 TypeScript那我强烈建议试试 tRPC。如果你在全球团队维护一个面向第三方开放的平台REST 文档仍然是必需的公共契约tRPC 只能补充内网效率。关键是认清楚自己的边界。目前我还在继续迭代这套模板下一步想加的是多租户的数据隔离层以及更完善的测试策略。后面如果有了新进展我会再写一篇续篇。各位如果也在折腾 T3 Stack欢迎直接复制我的实践路径去跑一遍有问题评论区见。