开头先交代一下背景。我最近把手上一个偏内部工具性质的全栈项目推倒重写,代号就叫t3code。说白了就是基于 T3 Stack(Next.js + TypeScript + Tailwind CSS + tRPC,数据库层用 Prisma,鉴权用 NextAuth)搭的一套完整业务系统。这一版从脚手架到上线前后花了两周,期间踩了不少文档里不会明写的坑,也沉淀了一些我觉得值得记录的实践经验。如果你正准备用这套技术栈做真实项目,或者已经在用但被类型、鉴权、部署这些环节卡过,这篇文章应该能给你省下不少时间。
1. 整体设计与思路拆解:为什么是 T3 Stack
1.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-app@latest执行后交互式选择需要包含的模块,我勾选了 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 的默认配置。我增加的重点是角色判断写入 session:
import { 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 不是所有方法塞在一个文件里,而是按领域拆成多个子 router:
export 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 报 UNAUTHORIZED | tRPC 上下文里没取到 session | 检查 NextAuth 配置和 HTTP header 是否传递了 cookie |
| Prisma 查询字段在类型里不存在 | 修改 schema 后没重新 generate | 执行npx prisma generate |
| 部署后接口 500 报数据库连接失败 | Prisma 客户端未复用 | 确保全局单例初始化 |
| 调用 tRPC 时 input 类型不对 | Zod schema 与前端传入结构不一致 | 保证两端用同一个 zod schema |
| NextAuth 登录回调不跳转 | 配置了 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。如果你在服务端组件里强行用 hook,Next.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 产品演进的过程。