最近一段时间,我把手头几个业务项目沉淀成了一个开源导向的代码实践集,取名叫 t3code。这个名字里“T3”指的是 T3 Stack,也就是由 Next.js、tRPC、Tailwind CSS 和 TypeScript 组合起来的一套全栈开发技术栈,t3code 则是我在这个技术栈下积累的工程代码、封装模板和踩坑记录。这篇文章想把 t3code 背后的整体设计、技术选型逻辑、关键实现细节和部署经验一次性讲透,适合正在考虑使用 T3 Stack 做全栈项目、或者已经在用但想进一步优化工程结构的开发者参考。
我从一个真实业务项目开始逐步拆出这套模板,前后改了不下五版,过程中踩过不少坑,包括 tRPC 类型在不同版本的兼容问题、Prisma 多环境迁移失败、NextAuth 会话与 tRPC context 衔接异常等。这些经验放在官方文档里往往只有一句“请参考配置”,实际跑起来却有很多细节,我希望用这篇文章把它们说清楚。
1. T3 Stack 的项目背景与整体设计思路
1.1 为什么选 T3 Stack 而不是自己拼一套
早几年做全栈项目,最痛苦的事情是前后端类型不能共用。接口文档写一套、前端类型定义一套、后端校验又一套,三套东西稍微不同步,线上就会出各种莫名其妙的问题。后来试过 GraphQL,类型确实统一了,但引入的服务端运行时依赖和缓存处理机制对团队来说有点重。
T3 Stack 的核心逻辑很简单:让 TypeScript 贯穿整个应用链路。从数据库到 API 到页面组件,数据的形状始终保持一致。tRPC 不需要像 REST 那样手动维护 OpenAPI 文档,也不用像 GraphQL 那样定义一套独立的 Schema,它直接在服务端函数和客户端调用之间建立类型关联。
这个设计对中小团队特别友好。一个三人后端团队,用 tRPC 之后基本不需要单独维护接口文档,前端拿到后端导出的类型直接开写。我自己的体感是,同体量功能,比之前用 REST 方案大概节省了三分之一左右的联调时间,而且改接口数据结构的时候可以立刻通过类型错误发现哪些页面调用了旧字段。
还有个容易被忽略的好处:T3 Stack 默认集成 Prisma,数据库层的类型也能一路传导到页面。比如数据库表加了一个字段,运行迁移后 Prisma 的类型就更新了,页面里用到这个字段的地方全部自动获得提示,这比手写 ORM 映射或者用 any 到处兜底要舒服得多。
1.2 t3code 的功能边界与模块划分
t3code 不是一个大而全的业务系统,而是一个可复用的工程底座。我给它划了四个主要模块:
- 用户认证模块:基于 NextAuth 集成 GitHub 和邮箱登录,会话状态通过 tRPC context 注入。
- 资源管理模块:一个典型的 CRUD 业务,包含列表、详情、创建、编辑、软删除和分页查询。
- 文件上传模块:使用 S3 兼容对象存储,包含预签名上传和回调处理。
- 系统监控模块:记录请求日志、慢查询和错误追踪,为后续部署提供可观测性。
选择这四个模块而不是一开始就铺一个后台管理系统,是因为它们基本覆盖了绝大多数业务开发的共性路径。认证、CRUD、文件上传、日志监控是任何系统都绕不开的,把它们做成模板,后面接到具体业务时可以直接复用。
目录结构上采用按模块分包而不是按技术层分包的做法。也就是说,不是把所有的 API 路由放在一个大文件夹里,而是每个业务模块文件夹内部各自维护 prisma 相关的业务方法、tRPC 路由和页面组件。这样做的原因很简单,按技术层分包在项目小的时候看起来清爽,一旦业务复杂起来,改一个功能往往需要在 pages、server、lib 等多个目录来回跳,心智负担很重。
2. 工程初始化与技术选型落地
2.1 脚手架初始化时最容易忽略的三个版本坑
第一次用 create-t3-app 初始化项目时,我以为直接敲一下命令就能得到一个可以立刻开发的环境,结果装完依赖启动项目就撞上了版本兼容问题。后面复盘发现,大部分问题都出在初始化时没有注意版本锁定。
先说初始化命令。新版本 create-t3-app 已经支持直接传入选项的无人值守安装,但如果你是第一次用,建议还是走一遍交互式问答,看清楚每个选项的含义:
npm create t3-app@latest t3code -- --no-git交互式问答里会让你选择是否启用 Next.js、tRPC、Prisma、Tailwind CSS、NextAuth、ESLint 和 Prettier。这里要留意的是,如果你选择了 NextAuth,脚手架会自动加入一个示例路由并配置 session 相关的表结构,这个默认行为对后面理解代码是有帮助的,但如果你不需要登录功能,可以在下一步删掉示例代码,不要保留一个空转的认证模块。
我踩过的第一个坑是 Node.js 版本。T3 Stack 的 Next.js 版本升级很快,后面几个版本要求 Node.js 18.17 以上。我本地刚好是 18.12,npm install 的时候没有报错,一到 next dev 就频繁出现 Webpack 相关的运行时错误。后面把 Node.js 升到 20 LTS,问题才消失。
第二个坑是包管理器。脚手架默认会根据你运行命令时使用的包管理器来生成 lockfile,但如果你在 CI 里用了不同的包管理器,很可能会出现 node_modules 里某些二进制依赖编译失败的问题。建议在项目根目录放一个packageManager字段,固定 pnpm 或 npm 的版本,避免团队环境差异。
第三个坑是 Tailwind 的版本。T3 Stack 在 2024 年后默认启用 Tailwind CSS v4,它的配置方式和 v3 差别比较大。v4 不再需要tailwind.config.ts里的 content 数组,直接用 CSS 文件里的@import "tailwindcss";导入即可,但如果你参照老的教程去改 config 文件,反而会把 postcss 插件配置弄乱。t3code 目前锁定的是 v3.4 版本,因为组件库和插件对 v4 的适配还不完全。
2.2 核心配置逐项拆解
初始化完成后,我习惯先把几个核心配置文件的每一项都过一遍,确定它们的作用,而不是直接开写代码。
next.config.mjs里最重要的配置是transpilePackages。如果你的项目打算以 monorepo 方式引入内部包,比如一个共享 UI 组件库,需要在这里声明包名,否则 Next.js 不会转译 node_modules 里的 TS 文件。
tsconfig.json里值得注意的路径别名。脚手架默认设置了@/作为根目录别名,比如@/server/api/routers/post。这个配置看起来简单,但对代码可读性提升很大,我不建议改成相对路径,因为当文件层级很深时,相对路径的../../../会让人完全失去对项目结构的感知。
src/env.mjs是 T3 Stack 比较特色的一部分,它用 zod 定义环境变量的 schema,应用启动时会校验环境变量是否齐全。比如数据库连接字符串和 NextAuth secret,少了任何一个都会直接启动失败,而不是跑到一半才报错。这个机制对避免部署时漏配环境变量非常有帮助。
还有一个很多人忽略的文件是docker相关的配置,但脚手架默认不生成 Dockerfile,需要自己加。我后面会在部署章节专门讲这个。
3. 数据层与 API 层的核心实现
3.1 Prisma 数据建模与迁移注意事项
Prisma 在 t3code 里承担数据库访问层的职责。先看一个实际的数据模型,以资源管理模块为例:
model Resource { id String @id @default(cuid()) title String description String? status Status @default(ACTIVE) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? ownerId String owner User @relation(fields: [ownerId], references: [id]) @@index([ownerId, status]) @@index([deletedAt]) } enum Status { ACTIVE ARCHIVED DELETED }我建议所有业务表都加上deletedAt字段做软删除,而不是直接物理删除行。这个习惯在业务初期看起来是多余的设计,一旦上线数据量变大、或者需要恢复误删数据,软删除能省掉很多麻烦。
另一个建议是慎用自增主键。自增 ID 会把业务行数暴露给外部,而且做数据迁移时容易出现主键冲突。t3code 统一使用 cuid 作为主键,虽然存储空间比自增整数大一些,但在分布式场景和分库分表场景下不存在冲突风险。
迁移命令方面,第一次建表的流程是:
npx prisma migrate dev --name init这里说一个容易踩的坑:prisma migrate dev会在本地开发环境自动应用迁移,但如果你的数据库是云上的共享数据库,建议把操作顺序改成分步执行。先执行npx prisma migrate dev --create-only生成 SQL 文件,人工检查一遍 SQL 的正确性,再用npx prisma migrate deploy应用到库。这么做能防止自动生成的迁移包含不符合预期的操作,比如误删列。
Prisma 的另一个大坑是连接池配置。如果你的后端服务运行在 Serverless 环境,每一个请求实例都会创建一个数据库连接,很容易把数据库连接数打满。这时需要用 Prisma Accelerate 或 PgBouncer 这类连接池代理。我在 t3code 里使用的是内置的连接池配置,通过prisma初始化时显式声明连接池大小,避免默认配置在并发高时吃光连接。
3.2 tRPC 路由设计与输入校验
tRPC 的核心思想是过程化调用,把后端函数暴露给前端直接调用,同时享受完整的类型推导。t3code 中把 tRPC 路由拆成多个子路由,每个业务模块维护自己的路由文件。
一个典型的路由文件长这样:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const resourceRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ page: z.number().min(1).default(1), pageSize: z.number().min(1).max(100).default(20), keyword: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const where = { deletedAt: null, ...(input.keyword ? { OR: [ { title: { contains: input.keyword } }, { description: { contains: input.keyword } }, ], } : {}), }; const [items, total] = await Promise.all([ ctx.db.resource.findMany({ where, skip: (input.page - 1) * input.pageSize, take: input.pageSize, orderBy: { createdAt: "desc" }, }), ctx.db.resource.count({ where }), ]); return { items, total, page: input.page, pageSize: input.pageSize }; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), description: z.string().max(500).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.resource.create({ data: { ...input, ownerId: ctx.session.user.id, }, }); }), });这里有两个关键点。第一个是publicProcedure和protectedProcedure的区别。protectedProcedure会先检查当前请求是否携带有效会话,如果未登录则直接抛出UNAUTHORIZED错误,不需要每个 API 里反复写 session 判断。第二个是使用 zod 做输入校验,前端传什么格式、后端接什么格式,全部由 zod schema 统一约束。实测下来这个方法非常可靠,我在项目里已经基本告别了体手写参数类型判断。
输入校验的错误提示也值得优化。默认情况下,zod 的错误信息是英文,并且字段名是直接用代码里的 key,比如title。如果你想把错误信息改成中文,可以在 zod schema 里配置自定义错误映射,或者在前端统一拦截错误并翻译。我本来想写一个完整的错误码映射表,但后来发现直接使用TRPCClientError里的shape字段来自定义了错误信息更简单。
3.3 前端调用与类型安全的实际体验
前端调用 tRPC 接口的时候,一个很直观的感受是:不用再定义 interface 来描述 API 返回的数据结构。比如页面里要展示资源列表,直接用api.resource.list.useQuery({ page: 1, pageSize: 20 })拿到的数据就是完全类型推导的结果。
const { data, isLoading } = api.resource.list.useQuery({ page: 1, pageSize: 20, }); return ( <div> {data?.items.map((item) => ( <ResourceCard key={item.id} resource={item} /> ))} </div> );如果你改动了一个后端返回字段的名字,保存文件后前端调用处会立刻出现红色波浪线。这个特性在多人协作时尤其有价值,因为团队成员之间不需要反复口头同步接口结构。
但这里也有一个需要警惕的问题:tRPC 的 useQuery 默认会在组件挂载时自动执行,如果你没理解它的缓存机制,很容易写出无效的重新请求。比如一个页面里有两个组件同时调用了api.resource.list,实际上只会发一个请求,这是 tRPC 内置的请求级缓存帮我们做掉了。但如果你在查询参数里放了一个每次渲染都会变化的新对象,缓存就失效了,每次渲染都会重新发请求。我自己的解决办法是尽量把查询参数写成稳定的常量,或者拆成独立的 hook。
4. 部署链路与实践优化
4.1 Docker 化部署的完整流程
t3code 的部署方式我选择的是 Docker Compose 起多个服务:Next.js 应用、PostgreSQL 数据库,以及一个反向代理服务。Dockerfile 是顺着 Next.js 官方示例改的,但做了两处重要调整。
第一处是多阶段构建。先用node:20-alpine作为编译阶段镜像,安装依赖并执行构建,再把构建产物拷贝到运行阶段镜像。运行阶段使用node:20-alpine和gcompat依赖,因为 Prisma 的二进制在 Alpine 环境下需要额外的兼容库。
第二处是迁移执行方式。我一开始把prisma migrate deploy放在 Dockerfile 的 CMD 里和启动命令一起执行,后来发现这样每次重启应用都会尝试跑一遍迁移,如果数据库已经是最新状态还好,一旦迁移和启动并发执行,会出现竞争条件。现在我把迁移单独拆成一个 one-off 容器,在 docker-compose.yml 里用restart: "no"指定,应用容器启动前先跑一次迁移容器,这样步骤清晰很多。
Docker Compose 的环境变量管理也做了一个优化。本地开发时使用.env文件直接注入,生产环境则通过 Docker Secrets 或者云服务商的环境变量服务注入,不在 docker-compose.yml 里明文写任何数据库密码。
4.2 渲染模式选择与缓存策略
Next.js 同时支持静态生成、服务端渲染和客户端渲染,但用 T3 Stack 时要注意一点:如果页面里使用了 tRPC 的 useQuery,该页面会强制进入客户端渲染模式,因为浏览器的 fetch 逻辑无法在服务端执行完。
我刚开始没有意识到这个问题,做了几个静态生成的页面,部署后发现 HTML 里没有任何数据,所有内容都是浏览器端动态加载出来的。这在首屏体验上影响很大,尤其对 SEO 敏感的业务。
解决思路是区分页面类型。营销页、文档页、博客列表这种内容基本不变或变化不频繁的页面,使用 Next.js 的静态生成,完全不依赖 tRPC。需要个性化数据的页面,比如登录后的仪表盘,才用 tRPC 的客户端查询。
还有一种折中方案是使用 Next.js 的serverClient在服务端预取 tRPC 数据,并把dehydrate状态传给客户端。这个方案适合想要 tRPC 类型安全又希望做 SSR 的场景。t3code 在仪表盘页面用了这个方案,实际效果是首屏数据直接包含在 HTML 里,页面速度明显提升。
缓存方面,tRPC 在客户端有默认的缓存策略,但服务端没有统一的缓存层。如果某个接口的数据全局性很强,比如系统配置、用户公共资料,可以在 tRPC 路由内部做手动缓存。我用的方法是把高频读接口的返回值放到 Node.js 的内存缓存里,设置 30 秒过期,实测数据库压力下降明显。
4.3 监控与日志的可观测性实践
应用上线后,排查问题不能只靠 ssh 到服务器看日志。t3code 集成了一个轻量的监控链路:用 pino 作为日志框架,结构化输出 JSON 日志,然后把日志统一转发到日志服务。
这里我想强调一个经验:日志一定要带请求上下文。如果你只输出一条错误信息,根本不知道是哪个用户、哪次请求触发的。我写的 logger 中间件会自动把 session 的用户 ID 和 tRPC 的请求路径附加到日志字段里,这样排查问题时可以根据用户反馈直接定位到具体请求。
除了日志,慢查询和错误追踪也很重要。Prisma 的 query 事件可以打印所有 SQL 执行时间,我把执行时间超过 500ms 的查询单独标记为slow query,并记录对应的路由。通过这个手段,我发现了很多 N+1 查询问题,比如列表接口里循环查询数据库,优化后接口响应时间从平均 900ms 降到了 150ms 左右。
5. 常见问题与避坑实录
5.1 tRPC 类型在严格模式下的兼容问题
tRPC 对 TypeScript 的泛型推导要求很高,特别是使用 strict 模式时,某些写法会导致类型推导失败。最典型的一个错误是直接使用ctx.session.user访问用户信息时,session 类型推导为Session | null,需要做空值断言或提前判断。
我在 t3code 里写了一个getServerAuthSession帮助函数,统一处理 session 为空的情况:
export async function getUserSession(ctx: TRPCContext) { const session = await ctx.session; if (!session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return session.user; }然后所有 protectedProcedure 的内部实现都通过这个函数获取用户,不需要每个路由重复类型判断。
另一个常见的类型问题是 zod schema 与 Prisma 类型不对齐。比如前端传date字符串,后端 schema 里用z.string()接收,传给 Prisma 时需要转成Date对象。这种转换逻辑建议抽成工具函数,避免在路由里散落一堆类型断言。
5.2 NextAuth 会话与 tRPC context 的关联问题
NextAuth 的 session 依赖getServerSession才能正确读取 cookie。在 tRPC 的 createContext 里,我一开始直接调用getServerSession然后塞进 context,结果发现在某些页面里 session 能读到,在某些页面里读不到。
排查后确认是 Next.js 的缓存问题。如果应用有多个中间件或开启了路由缓存,getServerSession在边缘执行环境下拿不到正确的请求对象。解决办法是把 tRPC 的 createContext 放在 Next.js 专用的 helper 文件里,并显式传入req和res,也就是使用createContextInner配合直接解析请求的方式。
如果你更习惯用 NextAuth v5,注意它和 v4 的 API 有不少差异。t3code 目前锁定的是 NextAuth v4,因为很多中后台模板和教程仍基于 v4,迁移到 v5 的成本我评估下来不值。
5.3 Prisma 多环境迁移与数据一致性
多环境部署时,最怕的是本地开发库和生产库的迁移记录不一致。我碰到过一次比较严重的情况:本地执行了prisma migrate dev后自动重置了数据库,导致测试数据全部丢失。
从那以后,我给本地开发库做了定期备份,然后把所有数据库操作规范成两条铁律:一是migrate dev只用于本地新建表或修改表结构,绝对不用于生产;二是所有环境共用同一套迁移文件,不能让某个分支单独生成迁移。
如果你已经遇到了迁移不一致的问题,最简单粗暴的解决办法是清空_prisma_migrations表,然后用prisma migrate deploy重新应用一遍所有迁移。需要注意的是,这要求你的迁移文件本身必须是完备的,能够从空库一步步恢复到最终结构。
我整理过一个快速的排查速查表,方便遇到问题时按图索骥:
| 问题现象 | 可能原因 | 处理方法 |
|---|---|---|
| 页面首屏无数据 | tRPC 查询在客户端执行,渲染模式不对 | 使用服务端预取或改用 SSR |
| 数据库连接数打满 | 连接池配置过小或没有复用 | 调整 Prisma 连接池,引入 PgBouncer |
| session 在部分页面读不到 | NextAuth 执行环境错误 | 显式传递 req/res 创建 context |
| 迁移执行失败 | 数据库版本与 Prisma 不兼容 | 升级数据库或锁定 Prisma 版本 |
| 类型推导为 any | zod schema 与后端返回不一致 | 统一使用 zod 校验,避免手写类型 |
5.4 部署后 Cold Start 明显的处理经验
如果你把 T3 Stack 应用部署到 Serverless 平台,比如 Vercel 或云函数,很可能会遇到冷启动时间较长的现象。这个问题的根源在于 Next.js 应用的启动过程需要加载大量依赖,尤其是 Prisma 查询引擎的初始化。
我实测过,在本地机器上冷启动 t3code 大约需要 1.5 秒,但在 Serverless 平台上冷启动可能需要 2-4 秒,对于用户体验来说差异很明显。
目前我能想到的缓解手段是:确保 Prisma 的生成产物在构建阶段包含原生查询引擎,不要让它在运行时联网下载;在应用入口增加一个 warmup 逻辑,通过定时任务周期性请求一次健康检查接口,让实例保持活跃。但说实话这个事很难根治,如果你的业务对响应延迟很敏感,我建议不要部署到 Serverless,直接用一台固定规格的云服务器跑 Docker 更省心。
6. 我踩过坑之后总结的 T3 Stack 使用习惯
项目做了几个版本下来,我对 T3 Stack 最大的感想是:它擅长的事情是收拢模式,而不是给你无限自由。刚开始接触时可能会觉得它约束比较多,什么都要按照脚手架生成的结构去组织,但当你连续做完两三个项目后,会发现这种约束其实是减少无效决策的手段。
现在我做新项目时大约有七个固定习惯:一是固定 Node.js 版本并在 package.json 里声明;二是每改动一次 Prisma schema 就检查一次生成的迁移文件;三是 all routes 都写 zod 输入校验,哪怕只有一层页面;四是页面组件里少用 any,尤其是在 tRPC 返回类型上;五是日志全部结构化输出,带着 traceId;六是部署前强制跑一遍代码扫描和类型检查;七是遇到问题先看 tRPC 错误码再猜原因。
按照这套 T3 Stack 的上手路径,我的建议是先跑通一个最小的 CRUD 流程,理解 tRPC 路由和 Prisma 之间的数据流转,然后再加上 NextAuth 登录,最后再考虑优化缓存和部署链路。不要一上来就尝试把所有的最佳实践都塞进项目,那样反而会被复杂的配置淹没。
如果你也正在摸索 T3 Stack,或者已经做完了一个小项目但感觉还不够顺手,可以直接拿 t3code 当参考,它的拆解思路和每一步的取舍我在上面已经写得比较细了。之后如果有新的帕点,我再单独写文章来做复盘。