如果你是一个独立开发者,或者正在经营一支五到十人的小团队,做全栈项目时一定经历过这样的场景:后端写一套接口,前端写一套请求,两边对着 Postman 截图来回确认字段名;改一个字段类型,前端编译报错、后端却安然无恙;为了一个列表页,前后端联调耗掉一下午。t3code 这个名字,不同的人可能有不同的联想,但在我的语境里,它就是围绕 T3 Stack 这套全栈组合写出来的工程代码。它的核心理念说穿了只有四个字:类型直通。TypeScript 的类型从数据库表一路传到浏览器组件,中间不再需要手写任何 DTO、接口声明或者 mock 数据。这篇文章我打算用实际操作过的经验,把这套组合完整讲透,包括它到底好在哪、每个部分怎么协同、一个完整项目怎么落地,以及我在生产环境里踩过的那些坑。适合谁读呢?正在做技术选型的全栈工程师、想从传统 REST API 切到更轻方案的团队,以及被前后端联调折磨到想换技术栈的人。
1. 整体设计思路:T3 选型背后的取舍逻辑
1.1 从 REST 到 tRPC:少写几百行胶水代码
传统的全栈开发思路,大多是 Next.js 前端 + Express 或 NestJS 后端,前后端各占一个仓库,中间靠 REST API 通信。这种方式不是不能用,但维护成本随着团队规模增长呈指数上升。每新增一个接口,你要做的事情包括但不限于:在后端定义路由、设计请求和响应结构、补充文档;在前端新建 api 目录、写 fetch 函数、定义接口类型、处理 loading 和 error 状态。这些工作里没有多少技术含量,却是联调时最容易出幺蛾子的地方,字段名拼错、大小写不一致、缺一个可选参数,都能让一次联调变成找茬游戏。
tRPC 的思路完全不一样。它不定义 URL,不写文档,而是让前端像调用本地函数一样调用后端函数。后端写好一个 router 和对应的 procedure,前端通过类型引用直接获得完整的入参和返回值类型提示。你可以把它类比成两家公司之间走正式合同流程,和两个人在同一个办公室直接喊一声的区别。tRPC 不是要替代 HTTP 或者网关,它只是让内部服务之间的函数调用透明化,让开发者把精力集中在真正有业务价值的部分。
1.2 为什么是 Next.js,而不是 Vite 加 Express
第一次接触 T3 Stack 的人,经常会问一个问题:既然要类型安全,为什么不用 Vite 做前端、Express 做后端,然后把类型定义放到 shared 包里?
我的答案是:类型定义放在 shared 包只能是单向共享,而且离不开 monorepo 工具链;一旦前后端变成两个进程,类型的安全性就完全靠人工同步。Next.js 解决了这个问题靠的是一体化进程:App Router 既能渲染页面,又能通过 Route Handler 挂载 tRPC 服务端。整个项目是一个进程、一个仓库、一套 TypeScript 编译上下文。前端组件、API 路由、数据库查询全部写在一起,类型系统天然无缝。Vite 的生态确实丰富,但那个场景适合前后端已经明确分离、需要单独构建部署的团队。对于中小型全栈项目,Next.js 明显是更省事的选择。
1.3 极简主义:少即是多
T3 Stack 还有一个很重要的精神内核:工具链要尽量精简,不为炫技引入复杂度。T3 的创始人 Theo 在社区里反复强调过,不该盲目上微服务、消息队列、Kubernetes,这些基础设施是给有明确规模瓶颈的系统准备的。t3code 这类项目的默认组合是 Next.js + tRPC + Tailwind + Prisma + NextAuth,每一个组件都能在各自领域内解决一个明确问题。这套组合的哲学不是“功能最多”,而是“手感最好”,尽量减少让开发者从一个上下文跳转到另一个上下文的次数。
2. 核心细节解析:t3code 工程的五大核心组件
2.1 TypeScript:真正的粘合剂
很多人以为 TypeScript 只是给 JavaScript 加了一些类型注解,但在 T3 Stack 里,TypeScript 是整个架构的钢筋骨架。从 Prisma schema 定义数据库结构之后,使用prisma generate就能生成带有完整字面量类型的 Prisma Client;tRPC 的router在运行时会剥离类型,但编译期间,前端通过api.link.getAll.useQuery()拿到的返回类型,就是后端 Prisma 查询返回的真实类型。这一条链路是打通全栈类型安全的关键,如果某个工程没有开启strict模式,或者大量使用any,那这套组合的价值立刻减半。
2.2 tRPC 的 router 与 procedure
tRPC 的核心概念只有两个:router和procedure。router是接口的容器,类似于传统 REST 里的 Controller;procedure是具体的处理函数,分成query和mutation两种,分别对应读操作和写操作。实际写起来,你可以在一个 router 里定义十几个 procedure,每个 procedure 用 zod 声明入参校验规则,tRPC 会自动完成校验,并把校验后的干净数据传给处理函数。
我举个例子,一个简单的链接收藏功能:
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const linkRouter = createTRPCRouter({ getAll: publicProcedure.query(({ ctx }) => { return ctx.prisma.link.findMany({ orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), url: z.string().url(), description: z.string().optional(), }) ) .mutation(({ ctx, input }) => { return ctx.prisma.link.create({ data: { title: input.title, url: input.url, description: input.description, userId: ctx.session.user.id, }, }); }), });这段代码同时完成了路由定义、入参校验、鉴权判断、数据库写入。前端不需要单独再维护任何类型,也不需要 mock 数据。这也是 t3code 项目里最典型的代码形态。
2.3 Prisma:数据库访问层的类型保障
Prisma 的作用是让你用 schema 文件描述数据库结构,然后生成一个类型安全的数据库客户端。它比直接写 SQL 或使用 TypeORM 的优势在于:schema 即文档、迁移即版本管理、查询结果自动带类型。
一个典型的 Prisma schema 片段:
model Link { id String @id @default(cuid()) title String url String description String? userId String? user User? @relation(fields: [userId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }这里我选择cuid()而不是自增数字,因为自增主键在分布式场景下容易碰撞,而且会暴露业务体量。cuid 和 UUID 类似,但更短、更适合做 URL 参数。Prisma 生成的 Client 类型与 schema 严格对应,改 schema 之后,后续代码里的类型错误会第一时间在编辑器里暴露。
2.4 Tailwind:样式层的效率方案
样式方案里,CSS Modules 需要在每个组件旁边维护一个同名.module.css文件,styled-components 需要时刻思考 props 与样式的绑定。Tailwind 的做法更激进:直接在 JSX 里写工具类。这样做的好处是,你不需要频繁切换文件上下文,改一个按钮样式只需要在组件属性里加几个类名,也不需要担心样式作用域和覆盖问题。
需要注意的是,Tailwind 的 JIT 编译只会扫描配置里指定的文件后缀,如果你新增了自定义组件路径,一定要在tailwind.config.ts的content数组里补上,否则样式会莫名失效。
2.5 NextAuth:会话与鉴权的一体化方案
NextAuth 解决了账号体系的最后一公里:你只需要配置一个authOptions,里面声明 providers、secret、数据库适配器,就可以在 tRPC 的 context 里通过getServerSession拿到当前用户。tRPC 的protectedProcedure就是基于这个 session 做中间件拦截的。
export const protectedProcedure = publicProcedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new Error("UNAUTHORIZED"); } return next({ ctx: { session: ctx.session, }, }); });收到UNAUTHORIZED错误时,可以推断出当前请求是未登录状态,前端可以据此跳转到登录页。
3. 实操:用 t3code 从零构建一个完整全栈应用
3.1 初始化命令与脚手架选项
T3 Stack 官方提供了一个脚手架工具create-t3-app,一条命令就能生成完整的项目结构。在终端执行:
npx create-t3-app@latest my-t3-app交互式命令行会询问你要启用哪些模块。我的习惯是全选:Next.js、Tailwind CSS、tRPC、Prisma、NextAuth。如果你不需要登录,完全可以去掉 NextAuth,但保留它并不会增加多少负担。生成后的项目结构大致如下:
my-t3-app/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── post.ts │ │ │ │ └── root.ts │ │ │ ├── trpc.ts │ │ │ └── context.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── react.tsx │ └── app/ │ ├── api/ │ │ └── trpc/ │ │ └── [trpc]/ │ │ └── route.ts │ ├── _trpc/ │ │ └── Provider.tsx │ └── page.tsx ├── package.json ├── tailwind.config.ts └── tsconfig.json看到这个结构你会直观感受到,脚手架已经把目录约定、Provider 注入、API 路由挂载全部配置好了,你只需要往里面填业务代码。
3.2 数据库设计与迁移
不管做什么应用,我建议先想清楚数据模型,再写页面。这样 tRPC 的 procedure 能围绕真实数据操作来定义,而不是凭空造接口。我们继续用链接收藏功能做例子。在prisma/schema.prisma里补充用户与链接的关系,执行迁移:
npx prisma migrate dev --name add_link_model npx prisma generate这两步分别用于生成 SQL 迁移文件和更新 Prisma Client 的类型。prisma generate是必须的,因为它会读取 schema 并生成新的 TypeScript 类型,供 tRPC router 使用。
3.3 tRPC 路由设计:从公共接口到受保护接口
在src/server/api/routers/link.ts里,我把接口分成两类:所有访客都能调的getAll,以及必须登录才能调的create和deleteOne。加入删除操作后,要注意一个小细节:用deleteMany并带上userId条件,避免普通用户删掉别人的数据。
deleteOne: protectedProcedure .input(z.object({ id: z.string() })) .mutation(({ ctx, input }) => { return ctx.prisma.link.deleteMany({ where: { id: input.id, userId: ctx.session.user.id, }, }); }),可能有人会问,直接删别人的也不行吗?理论上是。但deleteOne找不到记录不会报错,这个设计更多是防御性编程,防止未来某个组件不小心把别人的 id 传进来。
3.4 页面调用:useQuery 与 useMutation 的实际用法
前端调用 tRPC 接口用的不是 fetch,也不是 axios,而是生成好的 React Hooks。以列表页为例:
"use client"; import { api } from "~/trpc/react"; export function LinkList() { const { data: links, isLoading } = api.link.getAll.useQuery(); if (isLoading) { return <div>加载中...</div>; } return ( <ul> {links?.map((link) => ( <li key={link.id}> <a href={link.url} target="_blank" rel="noreferrer"> {link.title} </a> </li> ))} </ul> ); }这里不用写 useEffect、不用手动管理 state,useQuery已经帮你处理了缓存、重试和请求状态。写变更操作时,关键是onSuccess里刷新列表数据:
const utils = api.useUtils(); const createLink = api.link.create.useMutation({ onSuccess: async () => { await utils.link.getAll.invalidate(); }, });只要调用invalidate,列表的useQuery会自动重新拉取,不需要手动刷新页面。
3.5 部署:从本地到生产环境的完整步骤
部署 t3code 项目,我首选 Vercel,因为 Next.js 和 Vercel 的适配度最高。具体步骤是:把代码推到 Git 仓库,在 Vercel 里导入项目,设置环境变量,点击部署。
环境变量必须包含:
DATABASE_URL=你的数据库连接字符串 NEXTAUTH_SECRET=至少32位的随机字符串 NEXTAUTH_URL=https://你的域名如果你的 NextAuth 版本较新,可能还需要AUTH_SECRET或AUTH_URL,具体看脚手架生成的版本。构建过程中,prisma generate会自动执行,但不建议在生产环境直接跑migrate dev,我都是在本地执行迁移,然后把上线后的机器环境变量指向同一套数据库。
4. 常见问题与排查技巧实录
4.1 tRPC 接口报 500 错误:先看服务端日志
tRPC 有一个设计经常会“坑”到新手:浏览器端看不到详细的错误堆栈,只显示500 Internal Server Error。这是因为 tRPC 默认不会把内部错误信息返回给前端,防止业务逻辑泄露。排查的第一步永远是看终端,Next.js 开发模式下日志会直接打印出 Prisma 查询错误、zod 解析错误和 tRPC procedure 内部抛出的异常。
常见的 500 原因包括:Prisma 查询出错(例如字段名拼错)、数据库连接失败、session 读取异常。如果你看到UNAUTHORIZED报错,则是 protectedProcedure 的中间件拦截了未登录请求。
4.2 React Query 缓存导致“数据不刷新”
这是我踩过最多次的坑。当用户新增一条记录,列表页如果不调用invalidate,页面会一直显示旧数据,因为 React Query 默认缓存了查询结果。常规的做法是:
const createLink = api.link.create.useMutation({ onSuccess: () => { utils.link.getAll.invalidate(); }, });还有一种更精细的控制方式,直接更新本地缓存而不重新请求网络。比如删除一条记录后,你可以用setData在本地过滤掉那条记录,减少一次网络往返。这两个方案各有适用场景,交互频率低用invalidate,交互频率高用悲观更新或滚动更新。
4.3 SSR 场景下的数据库连接与内存泄漏
在服务端组件或getServerProps里直接调用 Prisma Client,存在一个很隐蔽的问题:每次请求都创建一个新的 PrismaClient 实例,会导致数据库连接池被大量未释放的连接占满。解决方案是使用全局单例:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"], }); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = prisma; }这个模式的核心是:开发环境开启node --watch之类的热重载时,每次代码变更会重新创建实例,但旧实例没有被垃圾回收;把实例挂在globalThis上,就能在重载期间复用同一个连接。生产环境则只需要一个全局实例。
4.4 NextAuth 会话在 tRPC 中间件里的配置要点
NextAuth 的 session 在 tRPC context 里读取时,要特别注意你用的是 JWT 策略还是数据库策略。默认配置下,session 数据被加密存储在 cookie 里,getServerSession会把它解密并返回。如果你不小心在客户端组件里调用getSession,拿到的是过期或不一致的缓存,导致鉴权判断出错。解决方法是:服务端一律用getServerSession(authOptions),客户端只用useSession。
另外,NEXTAUTH_SECRET一定不要在生产环境缺失。如果这个值未设置,NextAuth 在不同实例之间会生成不同的签名密钥,导致用户登录后刷新域名时 session 立即失效。
4.5 依赖版本升级的坑
T3 Stack 的脚手架和依赖迭代速度很快。以 Next.js 为例,从 Pages Router 迁移到 App Router 时,很多老的 tRPC 示例代码还在用pages/api/trpc/[trpc].ts,而新版脚手架已经改成了app/api/trpc/[trpc]/route.ts。tRPC 本身也有大版本变化,v10 用trpc.createTRPCProxyClient,v11 则是createTRPCClient加httpBatchLink。
我给你的建议是:不要把升级当作例行公事。升级前先看 changelog,特别是 breaking changes;升级后跑一遍完整的开发流程,尤其是登录、注册、数据列表这三个最常见的场景。如果你没有精力跟进升级,锁住版本号,或者直接用 create-t3-app 重新初始化再迁移业务代码,反而更省事。
4.6 问题排查速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 接口 500,无详细信息 | tRPC 默认隐藏错误堆栈 | 查看服务端日志,或临时在开发环境关闭生产模式 |
| 页面数据一直是旧数据 | mutation 后没有 invalidate | 调用utils.xxx.invalidate() |
| 登录状态丢失 | NEXTAUTH_SECRET 未设置或不稳定 | 配置一个固定的长随机字符串作为 secret |
| Prisma 连接卡死 | 每次请求创建新 client | 使用全局单例模式 |
| Tailwind 样式不生效 | content 扫描路径不对 | 修改tailwind.config.ts的 content 数组 |
| 莫名的类型报错 | tsconfig没开启 strict | 在 tsconfig 中设置"strict": true |
5. 经验心得:t3code 适合什么场景,边界又在哪里
5.1 什么项目我推荐用它,什么项目我不推荐
用 T3 Stack 做中后台管理面板、内容型网站、内部工具、社区类应用,都是非常趁手的组合。它的优势在于:产品迭代速度极快、前后端一体化、类型安全让多人协作的底噪很小。但如果你要做的是一个面向全球开发者的开放 API,让第三方通过 REST 或 GraphQL 对接,那我建议还是用传统方案,因为你无法要求外部调用者和你的 TypeScript 类型保持同步,公共 API 的契约需要更稳定的接口文档和版本管理。
另外,如果你的团队前后端人数都在五人以上、已经各自有成熟的工程体系,T3 Stack 的全栈一体化模型不一定适合。两套独立部署的架构,加上微服务拆分,解决的是组织协作问题和技术演进问题,这不是类型安全能替代的。
5.2 项目结构上的三个实用建议
第一个建议:按业务模块组织 router,而不是按文件类型。routers/user.ts、routers/post.ts、routers/link.ts,每一块业务内部的查询和变更放在同一个文件里,比把 query 全部堆到一个文件好维护得多。第二个建议:把 zod schema 单独抽成 shared 文件,因为有些校验逻辑前端和服务端都要用,比如用户名规则、URL 格式。第三个建议:环境变量校验用 zod。脚手架默认已经配置了env.mjs,你只需要在数组里补齐变量名,应用启动时如果缺少某个环境变量,会直接报错,而不是运行到一半才爆出运行时错误。
5.3 生态扩展:文件上传、定时任务、支付回调
t3code 并不意味着只能做简单的 CRUD。文件上传可以用 tRPC 生成预签名 URL,然后前端直传对象存储;定时任务可以在 Next.js 的 Route Handler 里写 cron 接口,让外部调度器定时触发;支付回调则可以用 Route Handler 接收第三方 webhook,再用 tRPC 的 server caller 在服务器端直接调用业务 procedure。也就是说,第一层是 tRPC 面向浏览器,第二层是 Route Handler 面向外部系统,两者可以共享同一个 Prisma Client 和同一套类型,工程结构的扩展性其实比想象中好。
结尾
我自己完整体验过 t3code 这类项目之后,最大的感受是“联调”这个词几乎从日常词汇里消失了。以前最怕领导问“这个接口什么时候好”,现在前后端是同一份代码、同一个类型,写好 router 前端马上就能拿到提示,剩下的精力全部花在业务逻辑上。最后再多说一句:t3code 不是银弹,如果你的系统需要向第三方开放 API、需要严格的接口版本管理、或者你的团队本来就是两个独立工种,那传统 REST 和 GraphQL 依然是更合适的选择。选型的本质是匹配场景。如果你决定试一试,建议从小工具或内部系统入手,先体验一次“改完数据库类型立刻在页面提示”的快感,再决定要不要把整个团队的业务都搬上来。