T3 Stack全栈项目实践:从脚手架到部署的避坑指南 开头先交代一下背景。我最近把手上一个偏内部工具性质的全栈项目推倒重写代号就叫t3code。说白了就是基于 T3 StackNext.js TypeScript Tailwind CSS tRPC数据库层用 Prisma鉴权用 NextAuth搭的一套完整业务系统。这一版从脚手架到上线前后花了两周期间踩了不少文档里不会明写的坑也沉淀了一些我觉得值得记录的实践经验。如果你正准备用这套技术栈做真实项目或者已经在用但被类型、鉴权、部署这些环节卡过这篇文章应该能给你省下不少时间。1. 整体设计与思路拆解为什么是 T3 Stack1.1 T3 Stack 到底解决什么问题先说一个容易混淆的点T3 Stack 并不是某个单一框架而是一套技术栈的组合约定。它强调“类型安全优先”把前后端共享类型这件事做到了极致。传统的全栈开发往往是这样的前端写接口请求后端写路由和数据库查询中间靠接口文档或者 Postman 同步。一旦某个人改了字段名、改了返回结构另一侧很容易在运行时才炸运气差点还会带着线上数据一起炸。T3 Stack 的组合方式很不一样Next.js负责页面、路由、服务端渲染和部署TypeScript从底层保证类型系统贯穿始终tRPC让前端调用后端接口的时候类型直接自动推导Prisma让数据库表结构成为类型源头之一Tailwind CSS负责样式省去写一堆 CSS 文件的精力NextAuth处理登录鉴权。这套组合最大价值在于你改一个 Prisma 字段前端调用的返回类型会立刻跟着变化写错一个参数名IDE 里直接标红。类型错误在编译期就拦住而不是等用户点按钮的时候才报错。1.2 我为什么在 t3code 里选择这套组合t3code 这个项目是一个带数据录入、审核、统计报表的后台系统用户角色分管理员、编辑、访客三种。这类系统的特点是表单多、状态多、权限判断多但页面并发量不高逻辑复杂度集中在业务规则上。选型的时候我考虑过两条更传统的路一是 Next.js REST API Redux Toolkit二是 Next.js GraphQL。前者类型同步全靠手写 interface改一个字段要同步改三处很容易漏后者引入 schema 定义、codegen、缓存策略对这个体量的项目来说太重了。最终选择 T3 Stack核心就三点理由类型从数据库到前端一条链路拉通产品经理改需求的时候我心里不慌tRPC 省去了接口文档维护成本改一处类型全链路生效这套技术栈的社区模板很成熟十几分钟就能搭出一个可运行的基础骨架省下的时间可以用来打磨业务逻辑。当然它也有代价。tRPC 不直接暴露 HTTP 路由没法像 REST 那样给第三方直接调用NextAuth 默认的 Session 策略对纯 API 客户端不太友好。这些限制后面我会细说。2. 初始化与数据建模开好头等于成功一半2.1 脚手架初始化与依赖陷阱T3 Stack 官方提供的脚手架命令很简洁npm create t3-applatest执行后交互式选择需要包含的模块我勾选了 NextAuth、Prisma、Tailwind、tRPC。这里有个小建议如果不是特别确定用不到建议把 NextAuth 和 Prisma 都勾上。后面想手动补进来不是不行但涉及依赖、初始化文件、环境变量一堆事麻烦程度远高于一开始就带上。脚手架装完目录结构里会生成src/server/api、src/server/db、src/trpc等关键目录这些都是 tRPC 和 Prisma 的黏合层。新手容易犯的错是自己在pages/api底下重新写路由结果绕过了 tRPC 的上下文注入导致鉴权失效。这里补充一个安装细节。脚手架默认使用Next.js 13以上的 App Router 还是 Pages Router取决于创建时的选项。t3code 选择的是 App Router因为新项目没有历史包袱而且服务端组件在某些页面场景下能明显减少前端 JS 体积。不过要注意App Router 下 tRPC Server 端调用和客户端调用写法上有差异后面实现部分我会给一个可用的写法。2.2 Prisma 数据模型设计的关键考量数据建模我从来不急着一上来就建几十个表。t3code 先用三个核心模型跑通链路model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? role Role default(VIEWER) accounts Account[] sessions Session[] posts Post[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String status Status default(DRAFT) authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) createdAt DateTime default(now()) updatedAt DateTime updatedAt } enum Role { ADMIN EDITOR VIEWER } enum Status { DRAFT REVIEWING PUBLISHED }有几个设计要点值得展开。第一ID 字段不要用自增数字。内部系统还好一旦涉及数据同步、迁移或对外暴露自增 ID 很容易被枚举或撞库。cuid 虽然字符串长度长一点但查询索引性能在万级数据量下完全不是问题。第二枚举类型的复用要克制。Prisma 的 enum 在数据库层是数据库枚举在客户端是联合类型但如果你把一个枚举同时用在多个语义不同的字段上后期调整会很难受。比如 status 的 DRAFT / PUBLISHED 和审核流程的 PENDING / APPROVED看起来像同一类实际含义完全不同宁可拆开定义。第三onDelete: Cascade要配合业务规则想清楚。删除用户的时候级联删除他的文章看起来顺理成章。但如果未来文章会有评论、点赞、日志等关联数据级联删除的风险就很大。t3code 里我把用户删除做成了软删除加一个deletedAt字段避免物理删除导致历史数据断裂。模型定完执行迁移npx prisma migrate dev --name init npx prisma generate这里强调一个经验每次改完 schema第一时间跑prisma generate。因为 tRPC 和 Prisma 的类型联动极其敏感你改了 schema 不重新生成类型IDE 里会出现各种诡异的类型报错排查起来会以为是自己代码写错了其实只是生成文件过期了。3. 核心链路实现tRPC Prisma NextAuth 的串通3.1 先搭鉴权再谈业务t3code 的开发顺序我是刻意安排的先做登录再做业务接口。原因很简单业务接口一旦做了后面再加鉴权要回改所有 router 的上下文工作量巨大。脚手架生成的src/server/auth.ts里有 NextAuth 的默认配置。我增加的重点是角色判断写入 sessionimport { getServerSession } from next-auth; import { authOptions } from ~/server/auth; export const getServerAuthSession () getServerSession(authOptions);在 tRPC 的createTRPCContext里把 session 注入进去export const createTRPCContext (opts: { headers: Headers }) { const session getServerAuthSession(); return { session, ...opts, }; };然后在protectedProcedure里做鉴权校验const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session } }); });这里有一个非常容易踩的坑NextAuth 的默认 session 策略是 JWT不是数据库 session。如果你在新项目里同时用了 Prisma 的 Account / Session 表却没有配置session: { strategy: database }那 Session 表永远不会写入数据有些教程里说的“查 session 表校验登录”根本不成立。t3code 这批直接走 JWT省掉 session 表查询对内部工具系统性能也更友好。角色权限我加了一个简单的adminProcedure或editorProcedure会在实际业务里按需定义。记住一个原则权限校验放在 procedure 层不要在具体 router 方法里复制粘贴同样的判断代码。否则一旦角色规则变化你要改的地方会多到崩溃。3.2 tRPC Router 的组织方式t3code 的 router 不是所有方法塞在一个文件里而是按领域拆成多个子 routerexport const postRouter router({ create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string() })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, status: DRAFT, }, }); }), listByStatus: protectedProcedure .input(z.object({ status: z.enum([DRAFT, REVIEWING, PUBLISHED]) })) .query(async ({ ctx, input }) { return ctx.db.post.findMany({ where: { status: input.status }, orderBy: { createdAt: desc }, include: { author: { select: { name: true } } }, }); }), });然后在根 router 里聚合export const appRouter router({ post: postRouter, user: userRouter, });聚合之后前端调用路径就变成api.post.listByStatus和api.post.create。类型是自动推导的不需要额外写 d.ts 文件。这里我想分享一个个人习惯为 return 的数据显式定义输出类型避免把整个 Prisma 对象直接抛给前端。特别是关联关系多的场景Prisma 返回的对象里可能带一些你不想暴露的字段比如邮箱、密码哈希。写个select或者pick把返回结构收窄既是安全考虑也是性能考虑。tRPC 的 input 解析我统一用 Zod。用 Zod 还有一个额外收益前端类型可以从后端 schema 反推校验逻辑只写一遍。比如编辑文章时前端需要校验标题不能为空、正文长度不能超限制这些规则只写在后端z.object里前端直接用z.infer得到类型再调用 Zod 的客户端校验方法即可。3.3 数据库查询的常见误区用 tRPC 写业务接口时最容易犯的问题是查询完数据还要在 Node 层做二次处理比如过滤掉某个字段、合并两个查询结果、把时间格式转换一下。Prisma 里这些操作大部分可以用select、include、where、orderBy一次性搞定不要在代码里写filter。另一个性能坑循环查询。如果你写的代码像下面这样说明应该改用 Prisma 的include或者单独批量查询for (const id of ids) { const data await db.post.findUnique({ where: { id } }); // do something }这个习惯在开发期看不出问题数据量一上来接口直接几秒钟。Prisma 的findMany支持where: { id: { in: ids } }一条查询解决所有数据获取再把结果映射回你需要的数据结构。4. 前端页面与交互服务端渲染和客户端行为的平衡4.1 Tailwind 布局与组件组织t3code 的前端样式完全基于 Tailwind。说实话早期我挺排斥 Tailwind 的总觉得 HTML 里写一堆flex p-4 text-sm很像回到了 Table 布局时代。实际用了两个项目之后我改变态度的原因有两个一是不用再给 class 命名省掉整天纠结profile-card-wrapper还是profileWrapper的时间二是重构简单删掉一个组件不会残留一堆无用的 CSS。在项目里我维护了一份小的设计规范用 Tailwind 的配置扩展实现const config { theme: { extend: { colors: { brand: #2563eb, danger: #dc2626, surface: #f8fafc, }, maxWidth: { app: 1200px, }, }, }, };命名规范上我自己定了三条普通业务组件放src/components页面布局组件放src/components/layout与后端数据强绑定的组件放src/components/features。组件层级不要太深三层以内。4.2 什么时候用服务端组件什么时候用客户端组件App Router 下最需要想清楚的问题是这个页面需要交互状态吗如果答案是不需要那就用服务端组件直接查询 tRPC 数据再渲染省掉客户端 JS。t3code 的做法是列表页、详情页用服务端组件表单、弹窗、分页切换用客户端组件。服务端组件里调用 tRPC 并不是直接调用api.post.list那个函数而是通过 tRPC Server 端 caller 来完成import { createCaller } from ~/server/api/root; import { createTRPCContext } from ~/server/api/trpc; const ctx await createTRPCContext({ headers: await headers() }); const caller createCaller(ctx); const posts await caller.post.listByStatus({ status: PUBLISHED });这样类型依然全程有保障。不过要注意服务端 call 一次是同步流程不能在 query 之外再使用useEffect之类的东西数据就是纯静态的。需要实时刷新的场景还是得拆成客户端组件。4.3 表单处理与乐观更新t3code 里有一个表单很典型新建文章并保存草稿。这类表单如果用传统做法提交后等接口返回、再刷新列表体验上总是慢半拍尤其内网工具访问慢的时候用户会反复点击提交按钮。我用tanstack/react-query的 mutation 做乐观更新。流程是提交时先更新本地缓存里的列表数据渲染出新的一条草稿记录同时后台请求仍在进行如果请求失败再回滚缓存并提示错误。具体代码大致如下const utils api.useUtils(); const createPost api.post.create.useMutation({ onMutate: async () { // 可选缓存某个查询参数写死 }, onSettled: async () { await utils.post.listByStatus.invalidate(); }, });真正要注意的不是代码怎么写而是乐观更新要区分表单数据来源。如果表单里已经有上传文件或者图片预览乐观更新时只能更新纯文本字段二进制资源还得等服务端返回真实 URL 后再替换。否则会出现界面显示图片上传成功刷新后图片丢失的错觉问题。5. 部署上线与性能调优从 dev 到 prod 的最后一公里5.1 Vercel 部署流程与数据库迁移t3code 部署在 Vercel 上这里最大的注意点是Vercel Serverless 函数是无状态的。Prisma 客户端如果每次请求都新建连接到了生产环境并发稍高就会把数据库连接池打满。解决办法是全局复用 Prisma 实例。脚手架生成的src/server/db.ts里其实已经处理过const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;如果你是自己从零搭的一定要记得这个套路。不然跑几分钟就会在数据库侧看到 “Too many connections” 的报错。部署前还要考虑数据库迁移。我的流程是先在本地跑prisma migrate dev确认迁移 SQL 没问题然后 push 到仓库。上线前再到生产环境执行prisma migrate deploy。Vercel 官方也支持 build 时运行 migrate但对多人协作项目来说生产环境的迁移最好由人来执行而不是自动跑减少“代码新但库结构旧”的可能性。5.2 环境变量与多环境配置T3 项目的环境变量使用env.mjs统一解析。脚手架会生成一个类似这样的文件export const env createEnv({ server: { DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(1), }, client: { NEXT_PUBLIC_API_URL: z.string().optional(), }, });这个做法的优势是构建时如果缺了某个核心环境变量会立刻在编译阶段报错而不是等到运行期白屏。这个能力很值得继续用但要注意密钥类变量绝对不能加NEXT_PUBLIC_前缀。Next.js 会把NEXT_PUBLIC_开头的变量打包进浏览器端 JS曾经有项目把管理后台的 API Key 写成NEXT_PUBLIC_API_KEY结果任何人浏览器里 F12 都能看到。5.3 缓存策略与响应速度优化内部工具系统对首屏速度要求不高但数据加载流畅度还是要保证。t3code 里做了两级缓存第一层是 React Query 的客户端缓存。列表查询默认staleTime设置 30 秒避免用户切来切去时每次都重新请求接口。第二层是服务端 HTTP 缓存。对非登录态或者低实时性要求的数据在 tRPC 路由外面可以包一层缓存响应头export const dynamic force-static;不过这两层都要谨慎。带用户私有数据的接口千万别开公共缓存否则会出现 A 用户看到 B 用户数据的严重事故。t3code 的做法是私有接口一律no-store公开只读数据才做缓存。6. 常见问题与排查技巧实录6.1 高频报错与解决对照表我把这半个月里实际遇到的高频问题整理成了速查表方便你碰到时直接定位现象原因解决办法前端调用 api 报 UNAUTHORIZEDtRPC 上下文里没取到 session检查 NextAuth 配置和 HTTP header 是否传递了 cookiePrisma 查询字段在类型里不存在修改 schema 后没重新 generate执行npx prisma generate部署后接口 500 报数据库连接失败Prisma 客户端未复用确保全局单例初始化调用 tRPC 时 input 类型不对Zod schema 与前端传入结构不一致保证两端用同一个 zod schemaNextAuth 登录回调不跳转配置了 redirect 但 session 没更新检查 JWT 策略和 session maxAge样式偶尔出现闪烁Tailwind 与 SSR 类名顺序问题避免动态拼接 className改用完整类名这里着重说下第一个问题。App Router 下如果从第三方 API 或内部服务发起请求cookies 不会自动带上必须显式传Cookieheader。createTRPCContext里通过headers()读取请求头的方式没有错但如果你用了代理层或者 CDN小心 Cookie 头被剥离。6.2 我自己反复踩过的三个坑第一个坑是tRPC 的中间件顺序。我在早期版本的protectedProcedure基础上叠了一层adminProcedure写法是const adminProcedure protectedProcedure.use(({ ctx, next }) { if (ctx.session.user.role ! ADMIN) { throw new TRPCError({ code: FORBIDDEN }); } return next(); });看起来没问题但拿到ctx.session之前必须先经过protectedProcedure如果我把这个顺序反过来admin 中间件里拿到的ctx.session可能为空。所以中间件之间的依赖顺序必须保证前置校验先执行。第二个坑是Prisma 与 SQLite 的兼容性差异。开发期我用 SQLite 图方便线上用 PostgreSQL。有些本地跑得很好的查询在 PostgreSQL 上会报错尤其是groupBy和orderBy混用的情况两边的行为不一样。建议从第一天就用和线上一致的数据库切换成本比想象中高。第三个坑是NextAuth 的useSession在服务端组件里拿不到数据。这是 App Router 的老问题。服务端组件里必须用getServerAuthSession而客户端组件里用useSession。如果你在服务端组件里强行用 hookNext.js 会很干脆地抛错并不会给你任何运行机会。6.3 调试技巧与日志观察说实话t3code 当初最爽的调试手段是tRPC 的类型报错。很多逻辑错误根本不用跑到浏览器里验证IDE 里直接标红。配合 VS Code 的JSDoc类型提示几乎每个函数的入参和返回结构都清清楚楚。遇到真正运行期的问题我一般先看 Vercel 的 Function Logs。如果日志里出现了TRPCError它的code字段能直接告诉你问题类别UNAUTHORIZED是登录态问题FORBIDDEN是权限不足NOT_FOUND是数据不存在。这个设计比 REST API 的 500 错误码要友好太多。如果日志不够详细可以在 tRPC 的 router 外面包一层全局onError中间件把input也打印出来export const t initTRPC.context().create({ errorFormatter({ shape, error }) { console.error(tRPC error:, error.message); return shape; }, });不过生产环境千万别打印完整 input可能有敏感字段打印个 summary 或者只打印 path 就够了。7. 从 t3code 里提炼的可复用经验实践完这个项目我觉得最值得拎出来说的并不是某个具体 API 的用法而是三件更上层的事情。第一类型安全的价值前期不明显后期真的太爽了。项目早期改动多字段频繁调整tRPC Prisma 的好处不是“代码能通过编译”而是“你根本不关心它编译不过的接口有多少个因为编译器替你兜住了”。第二技术栈精简比功能多更重要。T3 Stack 看似涉及六个工具但每个工具职责清晰单一你不需要同时掌握 Redux、Axios、React Query、Formik 这一大堆东西。学习曲线比传统全栈路线反而平缓很多。第三全栈项目真正的瓶颈永远在业务边界。技术方案再优雅也得先想清楚哪些操作允许哪些角色做哪些数据需要保留历史版本哪些列表需要分页。把这些业务规则写清楚再用 tRPC 的 procedure 去表达你会发现自己写的每一行代码都是在收敛业务复杂度而不是制造新的复杂度。t3code 这个项目目前已经跑在生产环境支撑着每天几百次的数据查询和几十次写操作。后续我打算把审核流、报表导出、数据看板这些模块逐步迭代进来还会继续用这套技术栈做另一个面向外部用户的产品。到时候再分享一期从内部工具向 SaaS 产品演进的过程。