前阵子我把一个攒了很久的想法落成了代码,项目代号就叫 t3code。这个名字没什么玄机,纯粹因为它整条链路都跑在 T3 Stack 上——TypeScript + Next.js + Tailwind CSS + tRPC + Prisma 这一套组合。为什么愿意把这个代号和整套方案拿出来细讲?因为我在搭建过程中踩过不少文档里没写清楚的坑,也确认了这套技术栈在小团队协作里的实际体验:端到端的类型安全确实能实打实提升开发效率,而不是营销号吹出来的概念。这篇内容适合正在考虑用 T3 Stack 起步、或者已经初始化了项目但还没理清各模块关系的朋友,也适合想了解 tRPC 和 Prisma 在生产环境怎么配合使用的开发者。我会从初始化讲起,一路聊到数据库建模、认证中间件、部署优化,最后分享一些只有真跑过项目才会知道的细节。
1. 项目定位:我为什么把一切都押在 T3 这套组合上
1.1 T3 Stack 到底解决了什么问题
先说清楚一件事:T3 Stack 不是一个框架,它是一套工具的共识。核心是把 TypeScript 的类型系统从头用到尾,让前端、后端、数据库三层的类型尽量互通,减少上下文切换时的心智负担。t3code 这个项目我选择了全套接入:Next.js 负责页面和 API 路由,tRPC 负责端到端类型安全的 API 调用,Prisma 负责数据库访问,Tailwind 管样式,NextAuth 处理登录鉴权。这个组合最舒服的地方在于,你在写数据库查询的时候,IDE 已经知道返回的字段长什么样,在组件里调用后端方法的时候,参数的校验错误会在编译期就蹦出来,根本拖不到浏览器控制台。
我见过不少团队在 REST 和 GraphQL 之间反复横跳,最后都用回了一种老办法:前后端各写一套类型定义,Api 接口靠手写文档维护。这种方式不是不能干,只是每次字段变动都要同步好几个文件,时间一长必然有一边忘记更新,线上的报错就成了家常便饭。T3 这套组合的核心价值,就是用编译器的力量强制类型一致,把这类问题从"靠自觉"变成"靠机制"。
1.2 t3code 适合什么场景,不适合什么场景
以我实际跑下来的体感,这套组合最适合的是中后台系统、内部工具、个人全栈项目,以及需要快速迭代的 MVP。因为信息密度高、复用性强,你不需要为每个接口单独写 controller,不需要维护 OpenAPI 文档,一个 router 文件就是一份自解释的 API 契约。团队哪怕只有一个后端和一个前端,沟通成本也能明显降下来。
但如果你的项目需要被第三方大规模调用 API,或者需要非常严格的 API 版本管理,那纯 tRPC 就不太够用,通常得在外面再包一层 REST 或 GraphQL 网关。还有一个我不太推荐用 T3 的场景是极其复杂的实时协作类应用,tRPC 的 subscription 目前还是走 WebSocket 或 SSE,生态和稳定性比专门做实时同步的方案差一些。所以选型的时候别只看"火不火",要看你自己的业务模型是不是适合这套类型安全优先的架构。
1.3 初始化前我对项目骨架的预想
正式动工前,我把 t3code 需要的核心模块列了个清单:内容管理后台、面向用户的前台页面、一个简单的权限体系、数据统计看板。这些模块都有共同特点——表单多、列表多、操作路径固定,非常适合用 tRPC 的 query 和 mutation 把数据流收敛起来。同时我也提前确定了一个原则:所有路由都放在服务端组件里调用,客户端组件只负责交互状态。这样既能发挥 Next.js 的 RSC 优势,又能让 tRPC 的调用路径更清晰。这些预想会直接影响后续 Initialization 时勾选哪些选项,所以不建议上来就无脑默认全选。
2. 初始化细节:create-t3-app 每个选项背后的取舍
2.1 创建项目与脚手架选项解析
初始化 t3code 我用的自然是官方脚手架,一条命令搞定:
npx create-t3-app@latest t3code这条命令会引导你选择是否启用 Next.js、Tailwind CSS、tRPC、Prisma、NextAuth、ESLint、Prettier。很多人以为这里只是多选题,实际上每个选项都会影响后续生成的项目结构和依赖树。以下是我根据 t3code 实际需求做的选择,以及每项的理由:
| 选项 | 选择 | 理由 |
|---|---|---|
| Next.js | 是 | App Router 模式下体验 RSC 优势,路由和渲染逻辑统一 |
| Tailwind CSS | 是 | 快速搭 UI,尤其在迭代原型阶段效率非常高 |
| tRPC | 是 | 这是类型安全的核心,没有不选的道理 |
| Prisma | 是 | 数据库访问层,搭配 TypeScript 和 tRPC 非常自然 |
| NextAuth | 是 | 后续要做登录和权限,基础认证不必自己造轮子 |
| ESLint | 是 | 多人协作时统一代码风格,降低 review 成本 |
| Prettier | 是 | 格式化交给工具,专注业务代码 |
这里有一个值得展开说的点:如果你确定项目不需要登录,完全可以不勾 NextAuth,后续手动加 provider 也不复杂。但如果你有这个需求却想省事,建议从一开始就勾上,因为 generate 出来的[...nextauth].ts文件结构是官方维护的,比自己从零拼稳定得多。
2.2 生成后的目录结构与职责划分
初始化完成之后,src/server和src/trpc两个目录会自动生成,很多人第一次看会觉得多此一举,其实这是 T3 特意设计的边界:src/server/db.ts统一导出 Prisma Client,src/server/api/routers/放各业务域的 router,src/server/api/trpc.ts里做 procedure 的基类封装。只要所有子 router 都基于这个基类创建,就能统一控制鉴权、日志、错误处理。后面扩展业务的时候,只需要在root.ts注册新 router,三行代码搞定,不需要四处散落初始化逻辑。
目录结构看似只是文件分类的问题,实际上决定了你在团队里怎么组织代码 review。我见过很多项目,前期没分好层,到后期只能靠"约定"去约束,一旦新人加入就乱了。t3code 从一开始就按这个结构维护,每个人新增功能时都知道"router 放这边、组件放那边、schema 放这边",这种秩序感在中期迭代时特别值钱。
3. tRPC 全链路类型安全:从数据库到组件的完整链路
3.1 procedure 和 router 的基础写法
tRPC 里两个最基本的概念是 router 和 procedure。router 相当于一个 API 命名空间,procedure 则是具体可调用的端点。实际操作中,我会把每一个业务动作拆成单独 procedure,比如"获取文章列表"、 "创建文章"、"更新用户昵称"。
下面是一个最简单的 query 示例:
import { protectedProcedure } from "~/server/api/trpc"; export const postRouter = { list: protectedProcedure .input(z.object({ page: z.number().min(1).default(1) })) .query(async ({ ctx, input }) => { const posts = await ctx.db.post.findMany({ skip: (input.page - 1) * 10, take: 10, orderBy: { createdAt: "desc" }, }); return { posts }; }), };在这个 procedure 里,zod schema 被写在 input 的位置,然后在 query 回调里能拿到类型完整的input和ctx。ctx里通常放着 Prisma Client 和当前 session,只要在后端配置好了createContext,前端调用的地方就能从返回的 queried data 拿到类型。整个过程完全不需要手动声明字段类型,IDE 也会自动提示,这就是类型安全真正落地后的体感。
3.2 客户端调用与 React Query 的集成
tRPC 的客户端侧封装在@trpc/next和@trpc/react-query两个包里。框架会自动提供api.post.list.useQuery()之类的 hooks,底层则依赖 TanStack Query 管理请求状态。这意味着你天然拥有了 loading 状态、缓存、失效刷新这些能力,不需要再额外引入请求状态管理库。
在 t3code 的文章列表页,我实际是这样用的:
"use client"; import { api } from "~/trpc/react"; export function PostList() { const [page, setPage] = useState(1); const { data, isPending } = api.post.list.useQuery({ page }); if (isPending) return <div>加载中...</div>; return ( <div> {data?.posts.map((post) => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </article> ))} </div> ); }这里值得注意的一点是,useQuery接受的对象其实就是上面 zod schema 定义好的input,如果你在前端传了额外字段,TypeScript 会在编译期直接提示错误。我实际体验中,"编译期报错"这件事比什么文档都有用,团队新人甚至不用专门学习接口协议,看着类型提示基本就能猜出后端要什么。
3.3 mutation 的乐观更新和错误回滚
写操作是 tRPC 里最容易绕晕的部分,因为一套优雅的方案往往涉及 mutation、缓存更新、错误处理三条链路。我在 t3code 里做"创建文章"的时候,用的是 React Query 的 onMutate 做乐观更新:
api.post.create.useMutation({ onMutate: async (newPost) => { await utils.post.list.cancel(); const prev = utils.post.list.getData(); utils.post.list.setData(utils.post.list.getInfiniteData(), (old) => old ? [{ ...newPost, id: "temp" }, ...old] : [newPost] ); return { prev }; }, onError: (err, newPost, context) => { utils.post.list.setData(utils.post.list.getInfiniteData(), context?.prev); }, onSettled: () => { utils.post.list.invalidate(); }, });乐观更新的核心是给用户即时反馈,但代价是如果后端返回失败,你必须有能力把 UI 回滚到之前的状态。这里onError里做的就是利用 context 中保存的上一份数据恢复列表。这种写法其实不复杂,但要理解 React Query 缓存和 tRPC 之间的协作关系,第一次上手的人多半会卡在invalidate的时机上。我的经验是:如果拿不准是否要做乐观更新,先别做,直接等请求完成再刷新数据也完全可以。等业务稳定了再优化交互,比一上来就冒险折腾缓存回滚要稳妥得多。
3.4 context 设计与鉴权注入
tRPC 的 context 是在每次请求时创建的,我通常会把数据库 session 放进去,让每个 procedure 都能直接访问当前登录用户。默认的createContext大概是:
export const createTRPCContext = async ({ headers }: { headers: Headers }) => { const session = await getServerAuthSession(); return { db, session, }; };这个过程有一个容易被忽略的顺序问题:如果用getServerAuthSession()去拿 session,性能开销会比较大,因为它每次都会向 NextAuth 的数据库 session 表发起查询。在 t3code 里,我通过配置 JWT 作为 session strategy 来缓解,把用户信息编码进 JWT,避免每个 tRPC 请求都查库。实测下来,在冷启动之后,单个请求的鉴权开销能低一个数量级。不过这种做法的代价是用户被踢下线后的 session 状态会有一段延迟,具体的取舍看业务容忍度。
4. Prisma 数据建模:从 schema 到真实查询的坑与解法
4.1 t3code 的基础模型设计
数据库层面,t3code 核心模型有两个:User 和 Post。为了演示关联,我故意设计成一篇 post 可以包含多个 tag,而 tag 是独立表,形成多对多关系。Prisma schema 里是这样写的:
model User { id String @id @default(cuid()) name String? email String @unique posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique posts Post[] }为什么 id 用cuid()而不是自增数字?因为在分布式部署、第三方 API 对接或者数据导入导出时,cuid 避免了数字型 id 的冲突问题,而且 Prisma 对它支持得很好。不过你要注意,cuid 类型是字符串,在关系数据库里会占用更多空间,索引效率略低于 bigint,但对于这个量级的业务完全不是瓶颈。
4.2 include、select 与 N+1 查询问题
Prisma 有个甜蜜陷阱:include太方便了,容易让人写出深层嵌套查询。一个典型的反面案例是在文章列表页用include: { author: true },而在另一个页面又需要author的 posts 列表。这样其实触发了大量关联查询,尤其列表 N 条数据时,应用层不需要的额外数据会拖慢响应。
我处理这个问题的原则是:查询时只 select 需要的字段,能避免 include 就避免 include。比如文章列表页只需要用户昵称,就只 selectauthor: { select: { name: true } },不要连 email 和所有令牌字段都拉回来。与此同时,配合 Prisma 的relationLoadStrategy或者直接分两次查询,能有效避免 N+1。真实项目里,让我印象最深的是一次列表页接口因为 include 了 tags 和 author 的完整信息,页面加载时间从 200ms 涨到了 1.2s,最后通过减字段和加索引修复回到 150ms 左右。这类优化做起来不复杂,关键是得有排查的意识和耐心。
4.3 迁移流程与团队协作注意事项
schema 改了之后,需要执行npx prisma migrate dev --name xxx,生成迁移文件并同步数据库。团队协作时最忌两个人同时对同一个 schema 改出冲突迁移,然后在 dev 分支互相覆盖。我在 t3code 项目里定了个规矩:每个功能分支只改自己的业务域的表,merge 前先migrate dev,并且把迁移文件当作代码一样走代码 review。这样能最大程度避免迁移文件冲突。
另外一个容易被忽略的事:production 环境的数据库迁移不要在跑业务请求的时候执行。如果一定要在线迁移,务必先备份数据,并且尽量选择服务低峰期。Prisma 的 migrate deploy 默认会直接执行,如果中途出错,数据一致性会受到挑战。稳妥的做法是先在 staging 环境完整跑一遍,再把同样的迁移文件应用到 production。
5. NextAuth 认证集成:登录流程与中间件保护
5.1 配置 Provider 和 Session 策略
t3code 里我用 NextAuth 做谷歌登录和服务端鉴权。因为只是内部工具,没有复杂的企业 SSO 需求,所以配置起来不麻烦:
export const authOptions: NextAuthOptions = { providers: [ GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }), ], session: { strategy: "jwt", }, callbacks: { jwt({ token, user }) { return token; }, session({ session, token }) { return session; }, }, };这里需要解释一下 session strategy 的选择。如果选database模式,NextAuth 会在自己的 Session 表里存 session 记录,每次请求都查一次库;而jwt模式是把用户信息编码到 JWT 里,请求时只需解码,省掉一次数据库查询。我在 t3code 里选的是 jwt,原因很简单:当前用户量不大,安全诉求主要靠 JWT 签名保障,不需要额外的服务端会话存储。代价是用户主动退出到 JWT 失效之间会有一段时间的滞后,但对内部工具来说完全可以接受。
5.2 中间件保护路由表的组织方式
有了认证配置,接下来就要保护页面。T3 的默认结构里会有一个middleware.ts文件,里面用数组定义了需要登录才能访问的路由和默认跳转地址:
export const config = { matcher: ["/dashboard/:path*", "/admin/:path*", "/settings/:path*"], }; export default withAuth(async (req) => { // 这里可以做自定义逻辑,比如按角色区分访问权限 });我建议把需要保护的路径统一放在一个约定字符串里,而不是直接散落在各处。t3code 里我维护了一个protectedRoutes.ts的常量文件,把所有保护路由定义成数组,中间件读取这个数组。这样权限变更时,只需要改一处配置,并且不容易漏掉某个路径。
5.3 服务端组件里如何读取服务端 Session
在 Next.js 的 App Router 下,服务端组件可以用getServerAuthSession拿 session:
import { getServerAuthSession } from "~/server/auth"; export default async function DashboardPage() { const session = await getServerAuthSession(); if (!session?.user) { redirect("/api/auth/signin"); } return <div>Welcome {session.user.name}</div>; }这里有个细节:如果页面同时用了中间件和页面内校验,会不会重复?我的习惯是中间件负责路由级拦截,页面内再用 session 判断做细粒度控制。这样即使中间件配置漏了,页面本身仍有兜底,安全性是双保险的。
6. 部署上线:Vercel 之外的生产环境优化方案
6.1 Vercel 部署的体验与限制
t3code 最开始是直接推到 Vercel 部署的。过程非常顺滑,连接 GitHub 仓库,设置环境变量,push 代码就自动构建上线。Vercel 对 Next.js 的支持确实没话说,尤其是 Edge Middleware 和增量静态再生成。但使用中我也遇到了实际限制:Serverless Function 的执行时长受平台限制,某些计算量大的接口偶尔会超时;同时冷启动的问题在低流量场景下能明显感知到。
如果项目是个人项目或者访问量不大,Vercel 是最省心的选择,完全不用管运维。但如果访问量上来了,或者接口响应时间不稳定,建议考虑自托管方案。
6.2 Docker + 反向代理自托管的完整思路
第二种方案是用 Docker 把 Next.js 应用容器化,然后用 Nginx 或 Caddy 做反向代理。我帮朋友的项目做过这套,具体的 Dockerfile 核心内容大致如下:
FROM node:20-alpine AS base WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine AS runner WORKDIR /app COPY --from=base /app/.next ./.next COPY --from=base /app/node_modules ./node_modules COPY --from=base /app/package.json ./package.json EXPOSE 3000 CMD ["npm", "run", "start"]这类自托管方案需要额外考虑的是数据库的访问方式和环境变量管理。t3code 的数据库如果也在同一台机器上,建议用 Docker Compose 把 Postgres 和 Next.js 应用编排在一起,用内网地址连接,避免暴露公网端口。
6.3 构建体积与冷启动的优化手段
无论哪种部署方式,Next.js 应用都有一个共同敌人:最终的 JavaScript 包体积。T3 项目默认已经做了一定程度的自动代码分割,但某些第三方库(比如图表库、富文本编辑器)可能会整体打进某个 chunk。我排查 t3code 首页加载时间时发现,一个图表库的单独 chunk 占了 300KB,按需加载后立竿见影降到了 40KB。
另外如果使用 Vercel,可以配置output: "standalone"来减小部署包的体积,配合正确的构建选项,冷启动时间能得到改善。自托管时,还可以用 PM2 常驻进程,保持 Node 进程不退出,避免每次请求都经历完整的进程冷启动。我实测下来,PM2 常驻模式下 API 响应时间会比 serverless 冷启动快 3~5 倍,但代价是需要自己的服务器能力。
6.4 数据库备份与迁移的常规操作
最后提醒一句,不管部署在哪里,数据库备份都不能省。t3code 用的 Postgres,我写了一个简单的 cron 任务,每天凌晨备份一次并保留最近七天的备份文件:
0 2 * * * pg_dump -U t3code -d t3code_db | gzip > /backup/t3code_$(date +%Y%m%d).sql.gz这种方案虽然朴素,但真实生产环境里比花哨的自动备份平台更可靠。定期做恢复演练也很重要,别等出问题了才发现备份文件已经损坏。
7. 上线后的性能调优与监控心得
部署上线只是开始,真正的工程师功夫都在上线后。t3code 上线一周后,我通过日志发现两个问题:一是某些热门文章列表接口的数据库查询没有走索引,导致响应变慢;二是 tRPC 的错误日志太薄,缺乏现场信息,排错困难。
针对第一个问题,我在 Prisma schema 里给常用查询字段加了@@index,比如 Post 表的published字段和authorId字段组合索引:
model Post { // ... @@index([authorId, published]) }实际效果是,这个查询从全表扫描变成了索引范围扫描,接口耗时从 800ms 降到 60ms。很多新手容易忽略 Prisma 的索引声明,觉得客户端查询够快就不管,但数据量一上来差距就非常明显。
针对第二个问题,我改造了 tRPC 的错误处理中间件,把 requestId、路由地址、参数摘要都统一打到结构化日志里。之后排查线上报错时,可以通过 requestId 把日志和前端网络请求关联起来,定位速度大幅提升。这种"普通人看着不起眼、关键时刻救命"的小改动,我建议每个上生产环境的项目都尽早做。
T3 Stack 这套组合并不神秘,也不需要多高深的技术背景才能驾驭。只要你愿意按它的规则组织代码,就会发现自己少写了很多收尾性的类型定义和胶水逻辑。t3code 从初始化到上线跑了大半个月,性能问题、数据库建模问题、认证问题都遇到过,但最终都能在文档加推理的范围内解决。希望这篇实战记录能帮你少走几步弯路,尤其是那些初始化之后一头雾水的时刻,照着这个流程一步步推下去,基本能把项目稳定落地。