T3 Stack 实战:t3code 全栈项目中的类型安全与工程实践

发布时间:2026/10/26 8:55:07
T3 Stack 实战:t3code 全栈项目中的类型安全与工程实践

t3code 这个代号,算是我今年亲手从零搭起来的一个全栈项目。说白了,它不是一个 Murmur 出来的新框架,也不是什么花哨工具链,而是一套以 TypeScript 类型安全为轴心的完整开发模板:前端用 Next.js,后端逻辑收进 tRPC 路由,数据库走 Prisma,样式用 Tailwind,认证交给 NextAuth。这一套组合在圈子里常被叫 T3 Stack,而 t3code 就是我在这个技术选型下沉淀出来的项目骨架和踩坑实录。这篇文章想把我在搭建过程中真正有价值的决策、踩过的坑、以及最后留下的工程实践写清楚,给那些准备拿起 T3 Stack 做全栈项目的人一个可以参考的真实样本,也聊聊为什么“类型安全”这三个字能省掉那么多破事。

这个项目适合谁看?如果你已经在用 TypeScript 写 React,又对 Next.js 的 API Routes 感到似懂非懂,或者你想知道数据库、API、前端类型到底能不能共用一份,那这篇文章应该能给你一个很具体的答案。

1. 为什么 t3code 会把项目压在 T3 Stack 上

1.1 先理清“类型安全全栈”到底省了什么

我先说结论:类型安全全栈不是一个锦上添花的特性,它会直接改变你写代码的顺序和沟通成本。传统的前后端分离项目里,前端要依赖后端开放的 REST 文档或者一个手写的类型声明文件,接口一变,前端大部分情况是不知道的——等编译报错已经是在联调时了。T3 Stack 的核心思路,是把类型本身当作前后端之间的接口协议:数据库字段类型由 Prisma Schema 定义,tRPC 路由的输入输出类型由 TypeScript 自动推导,前端组件 import 一个被推导好的 hook,直接就能拿到带类型的返回值。

我在 t3code 里第一次明显感觉到这个优势,是写一个用户列表页面。后端有一个getAllUsers的 query,返回结构里带有profile关联表的数据。我在 Prisma 里给Profile模型加了个字段,前端页面代码完全没动,再看浏览器,页面上的字段已经自动被 TypeScript 识别到了。没有联调群,没有“后端接口改了,前端记得跟上”这种需求,因为两者本来就在同一个类型系统里。

1.2 tRPC 和 API Routes 的取舍:像函数调用一样调后端

有人会问,Next.js 自带 API Routes,为什么还要再加一层 tRPC?我的答案是:API Routes 是 HTTP 层面的解决方案,而 tRPC 是类型层面的解决方案。API Routes 要求你手动管理请求路径、校验参数、序列化响应、处理错误,每个环节都是手写的,也因此每个环节都可能跟类型脱节。tRPC 则把这一层包装成类型安全的函数调用,你写的是api.user.getAll.query(),但它在浏览器环境会自动通过 HTTP 请求把参数发到服务端路由,再把返回值序列化回来,全程类型都是推导的,不需要你维护任何路由字符串。

当然这不是说 REST 就一无是处。如果你的项目有公共 API、有大量第三方调用,REST 和 OpenAPI 可能更合适。但对于 t3code 这种前后端同仓库、同语言的内部业务系统,tRPC 的性价比非常高。它省掉的不只是写接口文档的时间,更省掉了“前端猜后端字段含义”这种沟通摩擦。

1.3 三个关键依赖的边界:Next.js 只是宿主,tRPC 是骨,Prisma 是丹田

很多初学者会把这几个概念混在一起,我建议把它们拆开理解。

  • Next.js 在这里只承担框架宿主角色,负责页面渲染、路由处理和 Server Component 的调度。
  • tRPC 是连接层,它不负责渲染,也不负责数据库,只负责“让函数调用变为远程调用”这件事。
  • Prisma 才是数据世界真正的源头,你的模型字段、枚举、关系都在这里定义。
  • Tailwind 是纯表现层,它跟业务逻辑没有耦合,爱用不用。

我在 t3code 的架构设计里,非常刻意地让这三者保持单方向依赖:Prisma Schema 是真理之源,tRPC Router 消费 Prisma 的输出,Next.js 页面消费 tRPC 的输出。方向一旦反过来,代码就会开始乱。比如有人会把 tRPC 调用写到 Prisma 的 seed 脚本里,我看了就脑仁疼——那个脚本应该直接用 Prisma Client,没必要经过 tRPC 这层 HTTP 逻辑。

2. 脚手架跑通之后,目录里真正值得警惕的东西

2.1 create-t3-app 到底生成了什么,先别急着删

用npm create t3-app@latest t3code初始化项目非常快,几分钟就能跑起来。但跑起来之后最容易犯的错是:看多了网上那些“精简目录”的教程,手痒把默认结构改了。我建议你在项目初期至少保留这几个文件:

  • src/server/db.ts:Prisma Client 的单例封装,开发环境避免热更新创建太多连接。
  • src/server/api/trpc.ts:tRPC 的初始化文件和上下文类型定义,几乎所有路由都依赖它。
  • src/trpc/server.ts/src/trpc/react.tsx:服务端调用器和客户端 React Hooks 的桥接层。

这些文件不是说不能改,而是你会频繁用到它们,不如先保持默认,等理解透了再优化。我在 t3code 的前两周就是这么干的,只新增业务文件,不重构脚手架,把精力放在熟悉结构上。

这里还要提一个细节:默认模板会生成app/api/trpc/[trpc]/route.ts,这是 tRPC HTTP 网关在 Next.js App Router 下的标准入口。初次看这个文件的人可能会觉得它很“魔法”,但展开看它其实就是把收到的 HTTP 请求交给fetchRequestHandler,再传一个createContext函数进去而已。理解它之后,你调试 tRPC 的报错会方便很多。

2.2 App Router 还是 Pages Router:t3code 的选择与理由

create-t3-app 在很长一段时间里默认使用 Next.js App Router,我直接沿用,没有切回 Pages Router。原因不复杂:App Router 的 Server Component 可以让我在服务端直接调用 tRPC Procedure,避免很多组件变成 Client Component,页面也更容易拿搜索引擎的分数。但我也必须诚实提醒,App Router 的学习曲线明显更陡,如果你是第一次接触,建议先花半天把一个 React Server Component 和 Client Component 的边界理清楚,再来用 tRPC。

在 App Router 下,t3code 的做法是:页面主体逻辑尽量放 Server Component,只有需要交互的局部区域(比如搜索框、表格操作按钮、筛选器)用"use client"。数据获取统一走服务端调用 tRPC 的createCaller,而不是在客户端useQuery。这样首屏 HTML 里已经有数据,浏览器端不需要先发一堆请求再渲染骨架屏,体验好很多。

2.3 补齐 tRPC 客户端 Context,否则浏览器里全是 404

这里我要说一个特别容易踩、但又特别隐蔽的坑。如果你在非 Server Component 环境调用api.user.getAll.useQuery(),而项目里没有在根布局或者TRPCReactProvider里挂载 Context Provider,浏览器控制台只会给你一片 404。原因很简单:tRPC 客户端的 hooks 需要 Provider 才能拿到正确的 URL 和请求方法,Provider 没挂,请求路径不会自动注册到 Next.js 的 route handler 上。

t3code 的写法是在app/layout.tsx里用TRPCReactProvider包住整个页面,Provider 内部再包一层headers()读取服务端返回的 cookies,确保服务端渲染和客户端渲染两边拿到的请求头一致。这个细节如果不做,部分页面在 SSR 下可能正常,一旦客户端导航就出问题,Debug 起来非常费劲,因为报错不一定指向 Provider。

3. Prisma 从 SQLite 迁到 PostgreSQL 时我踩过的三个数据坑

3.1 第一个坑:SQLite 习惯写进 schema,Postgres 直接给你颜色

t3code 开发早期为了零配置,我直接用 SQLite 当数据库。本地跑得很爽,直到我决定把数据库换成 PostgreSQL,准备上生产环境的时候,才发现 schema 里有好几个字段是为 SQLite 量身定做的。

最典型的是String @default("")这种写法。SQLite 对空串处理很佛系,但 PostgreSQL 里有些字段类型会在空串和空值之间产生语义冲突,比如Decimal类型存储空串会直接报错。另一个坑是枚举类型:SQLite 不原生支持enum,Prisma 会退化成字符串检查,一换到 PostgreSQL,模型定义就必须显式声明枚举,旧数据里如果有枚举外的值,迁移会失败。

我的建议是,从第一天就用 PostgreSQL 或者 MySQL 来开发,哪怕本地跑 Docker 也要用生产同款数据库。现在 Docker 起一个 Postgres 只要一分钟,不要在“反正后面会换”上面省时间。

3.2 第二个坑:BigInt 与 Date 在 tRPC 传输层的序列化问题

Prisma 里有几个字段类型会在 JSON 序列化时产生意外。最典型的是BigInt。PostgreSQL 的某些计数 ID 用了BigInt,Prisma 返回的是一个 JavaScript BigInt 对象,可 JSON.stringify 根本不支持 BigInt,默认直接抛TypeError: Do not know how to serialize a BigInt。这个问题在没有 tRPC 的时候也存在,但因为 tRPC 会自动做序列化,报错信息会出现在运行时,更隐蔽。

t3code 的处理方式,是给这些字段定义明确的数据传输对象(DTO),在 tRPC Router 的返回处就把BigInt转成字符串或者 Number。另外一个常见问题是 Date 对象:Prisma 返回的 Date 在 tRPC 里会变成 ISO 字符串,前端做时间格式化的时候如果直接new Date(value)还能用,但如果拿它做value instanceof Date的判断就会出问题。所以我的原则是:跨 tRPC 边界的日期统一用字符串传递,显示格式统一在展示层处理,绝不把 Date 对象直接往 client 丢。

3.3 第三个坑:connection pool 和事务隔离级别,少一次压测都不知道

本地 SQLite 是一次连接,一个文件,几乎没有并发问题。但 PostgreSQL 在 Serverless 环境下的连接数是非常稀缺的资源,尤其是部署到 Vercel 或 Netlify 这类无服务器平台时,Prisma Client 如果不做连接池管理,每个 Lambda 实例可能都会去抢数据库连接,稍微有点流量连接数就爆了。

t3code 后面部署到一台小云主机上用常驻 Node 进程跑,遇到的情况是:Prisma 默认的事务隔离级别在并发写多的时候会产生大量重试异常,看起来像是“偶发报错”,但压测一上就原形毕露。我建议你在写涉及多表更新的 mutation 时,提前想清楚这个事务的隔离级别,并且使用 Prisma 的连接池配置,比如?connection_limit=5&pool_timeout=2这类参数。平时看起来无关紧要,到了并发场景就是能不能扛住压测的分水岭。

4. tRPC 路由不是简单拆文件:中间件、上下文与错误码设计

4.1 从 createTRPCRouter 到隐式依赖

先说说 tRPC 路由的组织方式。很多人第一次接触 tRPC,会以为它就是把 API Routes 换个写法,路由文件拆得多了自然就有结构。但实际上 tRPC 有一个更重要的隐式依赖:路由会携带一个共享的 Context。这个 Context 是你每次请求的“环境快照”,里面可以放数据库实例、当前登录用户、请求头信息等。所有 Procedure 都能访问这份 Context,所以路由设计得好不好,很大程度取决于 Context 设计得好不好。

t3code 的 Context 里我放了三样东西:Prisma Client 实例、session对象(可能为 null)、以及一个表示“当前请求是否来自客户端”的isClient布尔值。中间件再根据 Context 里的 session 状态决定放行还是拒绝。如果想把 Context 设计好,一个习惯是永远不要在生产路由里直接读文件或者请求体,所有依赖都通过 Context 注入,后续测试会轻松很多。

4.2 一个全局 middleware 能省掉多少 if

这里的核心是避免在每一个 mutation 里写一大段“如果是管理员就怎么样”的逻辑。t3code 里我套了两层中间件:第一层是全局的,只做基本日志和耗时记录;第二层是“需要登录”的中间件,挂在所有需要身份校验的路由组上。

这个中间件的写法非常直白:

const protectedProcedure = tRPC.procedure.use(async ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "登录后才能执行这个操作", }); } return next({ ctx: { ...ctx, user: ctx.session.user, }, }); });

之后任何需要登录的接口都直接用protectedProcedure,不需要在业务代码里再判断session。如果你有十几个查询都要做登录校验,这一个中间件就能删掉十几遍重复逻辑。更关键的是,后面加权限系统,也只需要在中间件里多套一层,业务代码一行都不用改。

4.3 错误码与错误信息的结构约定

tRPC 自带的错误码体系已经覆盖了常见情况,比如BAD_REQUEST、UNAUTHORIZED、FORBIDDEN、NOT_FOUND、INTERNAL_SERVER_ERROR。但这里有个容易忽略的点:默认的message是字面量英文,而前端用户根本看不到,也不应该看到。我的做法是在前端统一封装一个errorMapper函数,把 tRPC 的 error shape 映射成用户可读的中文文案,同时把code传给日志系统。

另外,不要把业务错误全抛成INTERNAL_SERVER_ERROR,否则你查日志会疯掉。比如用户传了一个不存在的 ID,就应该抛NOT_FOUND;权限不够就抛FORBIDDEN。我见过很多半路出家的项目,不管什么错误都是BAD_REQUEST,排查起来完全靠猜。错误码的语义化,是工程成熟度非常显性的信号。

5. NextAuth 接入 tRPC 受保护路由的完整链路

5.1 为什么在 App Router 下不能直接用旧版 session 写法

NextAuth 在 Pages Router 时代,最常用的拿 session 方式是getSession(),在服务端getServerSideProps里每次请求调用一次。但在 App Router 下,推荐的做法是getServerSession(),配合authOptions从请求头里恢复 session。t3code 一开始没注意这个区别,我照旧用getSession()在 Server Component 里取数据,某天突然发现部分页面在刷新时会拿到 null,而客户端导航时又是正常的。最后定位下来,是因为getSession()依赖的请求上下文在 App Router 的 Server Component 里不一定能拿到完整的 cookies 和 headers,导致 session 读取不稳定。

换成getServerSession后,这个问题就消失了。如果你用 NextAuth v5(现在是 Auth.js),那一套 API 又会变,所以我的建议是:不要凭记忆写,每次都要确认当前版本对应的官方示例,这种基础链路不值得省那两分钟。

5.2 Auth.ts 与 tRPC context 的桥接

t3code 里最关键的桥接文件,是把 tRPC 的 Context 和 NextAuth 的 Session 连起来。基本做法是在createContext里并行调用getServerSession,把返回的 session 塞给 tRPC。

这里有个实操细节:第一次请求时,Prisma Client、session 这些对象都会被初始化,如果你的选择器频繁创建 PrismaClient,热更新阶段会有大量连接残留,所以一定记得用全局单例模式。而生成了 session 之后,要记得把 session 放到 Context 里,而不是每次在路由里重新getServerSession,否则你的 query 函数会变得很啰嗦,而且没法统一做缓存。

5.3 Credentials Provider 的 JWT 坑

t3code 早期为了简单,用 NextAuth 的 Credentials Provider 实现邮箱密码登录。这个 Provider 默认不会把用户 ID 写进 session,而是把整个登录请求交给 JWT 策略。这里有个坑:如果你在signIn回调里没有做足够校验,或者jwt回调里没有把 userId 赋到 token 上,前端拿到 session 时没有 user.id,然后 tRPC 中间件就会把明明已经登录的人当成游客。

正确做法是在jwt回调里手动写入:

callbacks: { jwt({ token, user }) { if (user) { token.userId = user.id; } return token; }, session({ session, token }) { if (session.user) { session.user.id = token.userId as string; } return session; }, }

这个看着不复杂,但网上能搜到的案例大多只写 GitHub 登录,没有注意 Credentials Provider 的特殊性。如果你用第三方 OAuth 登录,可能不会触发这个场景,但一旦切到账密登录,就非常容易卡在这里。

6. 部署到生产环境后,类型安全救不了你的那些时刻

6.1 Vercel Serverless 环境下的 Prisma 连接池

坦白说,t3code 部署到 Vercel 的第一版,是在流量稍大时直接崩掉的。原因跟前面说的数据库连接池高度相关:Vercel 的 Lambda 是短生命周期进程,每个实例启动时都会初始化 Prisma Client,如果数据库连接池的上限配置得太小,一有流量波动,Prisma 就只能排队等待。而排队时间一旦超过 Lambda 超时阈值,用户就会看到 504。

我的解决方式其实很“土”:在云厂商的数据库参数里提高了最大连接数,同时在 Prisma 的连接串上显式设置了connection_limit和pool_timeout,让连接等待有上限。如果你用的是 Prisma 官方加速服务,那又是另一套配置。总之记住一点:本地跑得好,不代表生产不会死,连接池这个东西一定要在部署前想清楚。

6.2 WebSocket 适配器在无状态环境里的噩梦

如果想做实时更新,很多人第一反应是上 tRPC subscription。但请注意:tRPC subscription 默认依赖 WebSocket,而 Vercel 这类 Serverless 平台对 WebSocket 的支持非常有限,如果你部署到边缘函数,WebSocket 长连接更是直接不可用。t3code 后来放弃在 Vercel 上做实时订阅,改到了常驻 Node 进程的服务器上,WebSocket 才稳定下来。

如果你一定要在 Serverless 环境用 subscription,建议了解@trpc/server的subscription适配器和平台限制。很多项目的实时功能用轮询就足够了,不一定非要长连接。千万不要因为“实时”两个字就把架构搞得复杂,轮询 + 短缓存往往在 90% 的场景里都够用。

6.3 环境变量与多环境配置:zod 校验写在最前面

环境变量是部署出错的高发区。t3code 的做法是用一个 zod schema 在启动时校验所有关键环境变量,缺哪个直接给你一个清晰的中文报错,而不是等你运行到某个接口才炸。

比如DATABASE_URL、NEXTAUTH_SECRET、S3_BUCKET这些变量,在 config 文件里统一读取并校验。这样做的另一个好处是:如果把环境变量集中到一个 TypeScript 文件,IDE 提示会非常舒服,而且新同事上手时看到这个文件就大概知道项目依赖哪些外部资源。这也是我从很多生产事故里学到的教训,说句实话,类型安全能保证代码内部不出错,但保证不了你部署环境的外部配置不出错,多一道启动时校验比什么都强。

7. t3code 下一步:实时订阅、文件上传、可观测性

7.1 用 tRPC subscription 替换定时轮询

t3code 现在的消息列表已经改成真实时更新了,因为服务端不再受限于无状态环境。tRPC subscription 的写法跟 query 非常接近,核心还是在 router 里注册subscription,然后通过发布订阅源把更新推给客户端。这里有一个值得注意的设计:不要在前端直接调用 subscription 的发布函数,所有发布动作都应该由服务端业务触发,客户端只能监听。

我在实际使用中的体会是,tRPC subscription 的代码量比 WebSocket 原生实现少得多,可读性也好,适合中小型项目。但它对连接状态的恢复处理不如成熟的消息队列,所以 websocket 断线重连时的消息补偿,还得自己在业务层处理。这部分如果做得不好,用户可能错过重要通知。

7.2 文件上传不留服务器,直接走后端签名 URL

很多初学者会把文件上传写成POST /api/upload,然后把文件写进服务器磁盘。这在本地开发没事,上了生产要么磁盘不够,要么负载均衡多个实例之间文件不同步。t3code 的做法是走 Object Storage,比如 S3 兼容存储,上传逻辑用预签名 URL 实现:

  1. 前端先调用 tRPC 的getUploadUrlmutation,传入文件名和类型。
  2. 后端返回一个短时有效的上传 URL。
  3. 前端直接用 PUT 或 POST 把文件流推到存储服务,不走应用服务器。

这个方案既减少服务器带宽压力,又能避免接口被恶意塞大文件。我之前踩过一个坑是没有限制上传大小,结果被一个同事传了个 2GB 的视频,桶直接吃到告警。后来我加上了文件大小和后缀名校验,总算没再出事。

7.3 加一层可观测性,把每次 query 的耗时暴露出来

最后我想聊一个没那么炫酷但非常重要的部分:可观测性。t3code 在中间件里给每个 tRPC procedure 记录了一条日志,包含路径、耗时、状态码、用户 ID。这样一旦线上有问题,我可以从日志里很快定位到具体是哪个查询慢,而不是等到用户反馈。

具体实现也很简单,就是给 tRPC 加一个onError处理器和一个耗时中间件。日志不一定要上分布式链路追踪,刚开始用控制台输出加一个结构化日志文件就够用。等哪天流量大了,再引入 OpenTelemetry 也不迟。这个过程中最有价值的,不是某个工具,而是“把每一次请求都当成可观测事件”的工程习惯。

t3code 这套搭建过程里,我印象最深的反而不是某个具体 API,而是类型安全带来的正反馈循环:数据库结构一变,类型跟着变,编译就报错,修复的速度远快于肉眼排查接口文档。但也正因为它把开发体验拉得很高,部署和运维阶段的坑反而容易被忽略。如果你打算用 T3 Stack 做自己的项目,我建议不要只盯着框架的花哨功能,先把 Context、中间件、错误码、连接池这些基建打扎实,很多线上问题是可以提前避免的。