如果你最近在开发者社区里逛得比较多,大概率会看到 t3code 这个标签频繁出现。我第一次看到它的时候,第一反应是:这又是谁发明的缩写组合?后来真正把整套流程跑通才发现,t3code 并不是某个新框架,它背后的 T3 Stack 是实实在在解决了一类痛点的“选型方案”。很多独立开发者和全栈工程师把它当成快速搭建类型安全应用的首选,核心原因是它让数据库、接口、前端页面的类型一路打通,联调成本和心智负担都比传统 REST API 方案低不少。
这篇文章我会结合我实际跑过的项目,把 t3code 是什么、为什么值得用、怎么从零搭起来、CRUD 怎么做、认证权限怎么接、上线之后怎么优化,以及我踩过的那些坑,一次性说清楚。如果你正准备做一个全栈 TypeScript 项目,或者已经开工但还在纠结技术选型,这篇内容会比较适合你。
1. 先弄清楚 t3code 到底是什么
1.1 T3 不是框架,是一套组合选型
t3code 这个关键词,核心指向就是 T3 Stack,也就是社区里常说的“The Typesafe Trinity”。它最早由知名开发者 Theo 在 t3.gg 上提出,核心是三个以字母 T 开头的技术:TypeScript、tRPC、Tailwind CSS。不过在实际使用里,大家约定俗成地会把 Next.js、Prisma、NextAuth.js 也一起算进来,形成一套完整全栈方案。
需要明确的一点是,T3 并不是什么新语言或新框架,它是一套“选型收敛”的组合。你不是从零设计架构,而是直接采用已经被大量项目验证过的工具链,把精力集中在业务实现上。常规组合包括这几个:
- Next.js:React 框架,负责页面渲染、路由、服务端逻辑。
- TypeScript:给 JavaScript 加上静态类型,把一大部分运行期错误提前到编译期。
- Tailwind CSS:原子化样式方案,写 UI 的时候基本不用频繁切换 CSS 文件。
- tRPC:端到端类型安全的 API 层,前端可以像调用本地函数一样调用后端逻辑。
- Prisma:数据库 ORM,通过 schema 文件管理表结构和查询。
- NextAuth.js:认证方案,处理登录、Session、OAuth 集成。
我记得我最早跑通 t3code 那个示例项目时,最大感受不是“某个新框架真酷”,而是“这一套组合怎么这么顺”。尤其当我从传统的 Express + REST API 项目切过来之后,那种前后端类型断裂导致的低级错误几乎消失。这也是 T3 的核心理念:用类型安全来简化沟通成本。
1.2 哪些人最容易受益
独立开发者是 t3code 最典型的受益群体。一个人做项目,最花钱花时间的不是写代码,而是做技术决策。今天选数据库,明天选 ORM,后天考虑 API 风格,每一步都可能踩坑。T3 的好处是帮你把答案定死:Next.js 做界面、tRPC 做 API、Prisma 做数据层、NextAuth 做认证,你不再需要反复横跳。
第二类受益者是偏前端、想往全栈延伸的同学。因为 tRPC 的前端调用方式极其友好,几乎和调用本地函数一致,后端代码里的接口输入输出类型会自动映射到前端,不需要手动维护接口文档。这一点对习惯前端思路的开发者来说,学习曲线非常平缓。
第三类是小团队。小团队最怕的就是接口文档不同步和前后端联调扯皮。t3code 把类型链拉通之后,很多错误在编译期就被发现了,线上出现“字段名对不上”这类问题的概率大大降低。
提示:T3 选型适合大多数中小型应用,但如果你需要开放 API 给大量第三方客户端,或者要支撑复杂的微服务拆分,tRPC 这种“前后端深度耦合”的方式就不一定合适。选型还是要看场景,不要为了类型安全硬套。
2. 为什么这套组合能显著降低联调成本
2.1 类型安全从数据库一路延伸到按钮
传统全栈项目里,数据库表结构、后端接口、前端类型通常是三套独立的东西。你改了一个字段名,要先改数据库迁移,再改接口返回,再改前端类型定义,中间漏掉任何一环,运行时就会炸。而 t3code 的思路是让类型从 Prisma schema 开始,一路推断到前端按钮上的数据。
我做一个生活化的类比:数据库像一个仓库,Prisma 是仓管员,tRPC 是送货路线,前端页面是门店。如果仓库里的货架编码和送货单对不上,传统方案靠人肉检查,而 T3 的路线是仓管员把货架编码直接打在送货单上,门店按单收货就行。只要编码源头没变,整条链路就不会错。
实际操作中,你在 Prisma schema 里定义一个 Post 模型,tRPC 路由的 query 返回类型会自动推断出 Post 的全部字段,前端 useQuery 拿到的数据也自动具备完整类型。改字段名时,只要你改动 Prisma schema 并重新生成类型,编辑器会立刻用红线标出所有引用了旧字段的地方。这种“改一处,全局报错提醒”的体验,是纯 JavaScript 时代无法想象的。
2.2 接口文档成本被大幅压缩
很多团队维护 REST API,最大的隐性成本不是写代码,而是维护接口文档。后端改了参数,文档没有同步更新,前端只能靠抓包猜字段。t3code 对这件事的改变是结构性的:tRPC 的 API 定义本身就是“活文档”,前端类型来自后端代码,文档永远不会滞后。
我实际感受最深的是一个好几个星期的项目里,我几乎没有发过“这个字段类型是什么”的消息。以前的联调和现在有什么本质区别?以前是前端等后端给接口,后端等前端理解文档;现在是前后端共享同一套类型,直接改代码就完了。这个感受在外面写一次完整的 CRUD 之后会特别明显。
当然,tRPC 的代价是把前后端绑定在一起。如果你的项目需要面向多端服务,或者是开放平台给第三方开发者,那还是老老实实走 REST 或 GraphQL 比较合适。这一点做了取舍之后你会很清醒。
3. 从零搭建一个 t3code 项目
3.1 用 create-t3-app 一键初始化
官方脚手架做得很成熟,我的建议是不要自己手动拼装,直接使用命令:
npx create-t3-app@latest my-t3-app这条命令会帮你创建 Next.js + TypeScript + tRPC + Prisma + NextAuth.js + Tailwind CSS 的完整项目骨架。交互式终端会让你选择启用哪些模块。我第一次跑了之后,默认勾选的这些模块基本上都保留,因为后面的 CRUD、认证都会用到。
有人会问,为什么用 npx 而不是全局安装 create-t3-app?最关键的原因是 npx 每次都会拉取最新版本,避免你本地装了一个老旧版本导致生成的模板和文档不一致。实际踩坑中,我遇过同事用了半年前的脚手架,生成的项目结构和新版 tRPC 对不上,最后只能重新初始化。
3.2 读懂生成出来的项目结构
初始化完成后,目录通常是这样的:
my-t3-app/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── components/ │ ├── pages/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc].ts │ │ └── index.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── styles/ │ └── utils/ ├── .env └── package.json第一次看到这种结构可能有点懵,其实它的分工很清晰。prisma 目录放数据模型和迁移文件。server/api/trpc.ts 是 tRPC 实例创建的核心文件,routers 目录下放各个业务模块的路由。server/auth.ts 是 NextAuth 配置。pages/api/trpc/[trpc].ts 是把 tRPC 服务挂到 Next.js API 路由上的入口,这个文件通常保持默认即可。
如果你想新增一个业务模块,比如“标签系统”,一般流程是先在 prisma/schema.prisma 里加 Tag 模型,再在 routers 目录下新建 tag.ts 定义 query 和 mutation,然后在根 router 里注册。这一步没有黑魔法,纯粹是路径约定。
3.3 环境变量和数据库准备
项目根目录下有个 .env 文件,默认会生成一行 DATABASE_URL。如果你本地用 SQLite,可以写成这样:
DATABASE_URL="file:./db.sqlite" NEXTAUTH_SECRET="your-strong-secret" NEXTAUTH_URL="http://localhost:3000"这里有个选择逻辑要说一下。开发阶段用 SQLite 零配置最方便,不需要装数据库服务。但是一旦要部署到生产环境,SQLite 在 Serverless 平台的表现并不好,建议尽早切到 PostgreSQL 或 MySQL。我自己的做法是本地开发用 SQLite,提交代码前就把 schema 的 provider 切换成 postgresql,然后再部署。
还有一个很容易忽略的点:NEXTAUTH_SECRET 不要用弱字符串。可以执行下面命令生成一个随机值:
openssl rand -base64 32这个秘密值一旦丢失,线上用户的 Session 会全部无效。我见过有人把 SECRET 写在代码注释里传到 GitHub,这个错误非常危险。
4. 核心实操:用 tRPC 和 Prisma 做一个完整 CRUD
4.1 先设计数据模型
动手写路由之前,我习惯先把 Prisma schema 设计好,因为 tRPC 的类型是由它推断出来的。
假设我们要做一个简单的博客应用。定义一个 Post 模型:
model Post { id String @id @default(cuid()) title String content String? published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }id 字段我通常不会用自增整数,而是用 cuid。原因是实际项目中分布式部署和数据库迁移时有主键冲突风险,cuid 是客户端生成的,不依赖数据库自增状态,风险会小很多。如果你后续要做“无限滚动列表”或者给外部系统对接 ID,这种稳定且不可预测的主键也更合适。
4.2 编写 tRPC 路由
在 src/server/api/routers/post.ts 中,定义一个包含 list 和 create 的路由:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(50).default(20), }) ) .query(async ({ ctx, input }) => { const where = { authorId: ctx.session.user.id }; const [posts, total] = await Promise.all([ ctx.db.post.findMany({ where, skip: (input.page - 1) * input.pageSize, take: input.pageSize, orderBy: { createdAt: "desc" }, }), ctx.db.post.count({ where }), ]); return { posts, total, page: input.page, pageSize: input.pageSize }; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), });这里有几个点要重点解释。protectedProcedure 和 publicProcedure 的区别在于,前者要求用户必须已经登录,否则直接返回 UNAUTHORIZED。写 route 的时候不要贪方便全部用 publicProcedure,再在逻辑里手动判断登录态,那是给自己埋雷。
zod 的 input 校验是 tRPC 的一大优势。前端传进来的参数会被自动解析、校验、类型化。我给 pageSize 设置 max(50) 是为了防止有人一次拉几万条数据拖垮数据库,这种防御看起来简单,但能避免线上很多问题。
分页参数用 (page - 1) * pageSize 计算 skip,这是最常见的 offset 分页。很多教学示例会省略 count 这个查询,导致前端只能拿到一页数据,不知道总共有多少。我这里用 Promise.all 把列表查询和 count 查询并行发出,性能比串行好一些。
4.3 前端调用
前端调用发表在页面组件里写:
import { api } from "~/utils/api"; function PostList() { const listQuery = api.post.list.useQuery({ page: 1, pageSize: 20 }); const createMutation = api.post.create.useMutation({ onSuccess: () => { listQuery.refetch(); }, }); return ( <div> {listQuery.data?.posts.map((post) => ( <div key={post.id}>{post.title}</div> ))} <button onClick={() => createMutation.mutate({ title: "新文章", content: "Hello t3code", }) } > 新建 </button> </div> ); }这里我一开始容易犯一个错误:mutation 成功后手动调用 refetch。其实 tRPC + React Query 会维护 query 缓存,你可以配置 invalidate 让列表自动刷新。不过手动 refetch 并不坏,只是要注意别在 onSuccess 里写过多副作用,否则容易造成竞态问题。
如果你有相互依赖的数据,比如创建文章成功后需要同时刷新“文章列表”和“个人统计”,建议用 api.useUtils() 里面的 invalidate:
const utils = api.useUtils(); const createMutation = api.post.create.useMutation({ onSuccess: async () => { await utils.post.list.invalidate(); }, });4.4 关联查询和 N+1 问题
实际操作中,如果列表页要展示作者名,很多人会直接在 findMany 里写 include: { author: true }。这在数据量小的时候没问题,但一旦列表变长,会产生明显的 N+1 查询问题,也就是每查一条文章就要查一次作者,数据库压力会成倍增加。
在 Prisma 和 tRPC 的组合里,我建议用 select 只取需要的字段,尽量避开 include 的隐式联查。比如:
ctx.db.post.findMany({ where, select: { id: true, title: true, createdAt: true, author: { select: { name: true }, }, }, });这样做的好处有两个:返回给前端的数据量更小,数据库执行的查询也更轻。t3code 项目跑久了你就会发现,性能优化很多时候不是靠换数据库,而是靠少查多余字段。
5. 认证与权限:安全细节不能只看表面
5.1 NextAuth 接入外部登录
T3 脚手架默认集成了 NextAuth,你只需要在 src/server/auth.ts 里配置 Provider。我用 GitHub Provider 做过示例:
import NextAuth from "next-auth"; import GitHubProvider from "next-auth/providers/github"; export const authOptions = { providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID ?? "", clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "", }), ], callbacks: { async session({ session, token }) { if (session.user) { session.user.id = token.sub ?? ""; } return session; }, }, }; export default NextAuth(authOptions);为什么要在 session 回调里手动加 user.id?因为在某些 Provider 配置下,session.user 里不一定会带 id,而我们在 tRPC 的 protectedProcedure 里经常要拿 session.user.id 作为数据归属判断。这一步缺失是最常见的“登录看起来没问题,但写数据时用户 ID 是 undefined”的根源。
5.2 权限校验必须落在服务端路由里
很多前端开发第一次做博客应用时,习惯用“按钮是否显示删除”来控制权限。这种方案只能防普通用户,真正安全的后端必须能在 API 层面拒绝非授权操作。tRPC 里最典型的做法是在 delete mutation 里校验资源归属。
场景是我们只允许作者本人删除自己的文章:
delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id }, select: { authorId: true }, }); if (!post || post.authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "你没有权限操作这篇文章", }); } return ctx.db.post.delete({ where: { id: input.id }, }); }),这里有个细节:我先查了一次 authorId,再执行删除,而不是直接 deleteMany({ where: { id: input.id, authorId: ctx.session.user.id } })。两种方式都能防止越权删除,但先查后删可以更精确地判断是“资源不存在”还是“权限不足”,返回给前端的错误信息会更友好。
5.3 密钥和 Session 安全
认证相关最容易被忽视的是密钥管理。NEXTAUTH_SECRET 一定要用随机字符串,并且绝对不要写进前端代码或提交到 Git。如果你使用 GitHub 登录,还需要在 GitHub 后台配置回调地址,本地开发和生产环境要用不同的 clientId。
生产环境中 Session 丢失的常见原因包括:NEXTAUTH_URL 配置不对、SECRET 不一致、部署平台没有配置环境变量。排查时我一般先看浏览器 Cookie 是否存在,再确认 NextAuth 配置里的 secret 和部署环境变量是否一致。这个排查路径比盲目改代码高效得多。
6. 部署上线与性能优化
6.1 构建之前必做的三件事
每次构建前,我建议按这个顺序检查:
第一,环境变量是否齐全。DATABASE_URL、NEXTAUTH_SECRET、Provider 的键值都要在目标平台配置好,本地 .env 不会自动同步到服务器。
第二,Prisma 是否已经生成客户端。改了 schema 后如果没执行 npx prisma generate,构建时类型能过,但运行时会报 PrismaClient 相关的错误。
第三,类型检查是否通过。T3 项目推荐使用tsc和next build。很多小问题在本地开发时不明显,部署到 Serverless 环境后会以诡异的方式爆发。
6.2 部署到 Vercel 的注意事项
Vercel 和 Next.js 是天生的一对,t3code 项目部署上去非常顺。但生产环境部署有两个坑是新手容易踩的。
首先是数据库连接。Vercel 的 Serverless 函数是无状态的,可能会创建大量连接。如果用 PostgreSQL 直连,连接数很快被打满。解决办法是在数据库侧使用连接池,比如 Supabase 的连接池模式,或者用 Prisma 连接池。其次,SQLite 在 Vercel 上是不可靠的,因为 Serverless 文件系统是临时的,重启后数据可能丢失。所以生产环境老老实实用远程数据库,线上我见过太多因为 SQLite 导致的诡异数据丢失问题。
6.3 优化 React Query 和 tRPC
t3code 项目在客户端用 React Query 缓存数据,默认配置有时候会产生过多的请求。我喜欢在创建 tRPC 客户端的地方做一点全局优化:
httpBatchLink({ url: "/api/trpc", headers() { // 把认证信息带过去 }, });httpBatchLink 是全自动批处理,它会在同一事件循环里把多个 tRPC 查询合并成一个 HTTP 请求,大大减少请求次数。这个特性在生产环境特别有用。另外,React Query 的 staleTime 默认是 0,意味着任何组件挂载都会重新请求数据。我习惯在 QueryClient 里设置全局 staleTime 为 30 秒到 1 分钟:
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000 }, }, });这样用户在页面间切换时,数据不会马上失效,体验会顺畅很多。当然,对实时性要求高的数据要单独配置 staleTime: 0。
6.4 日志与线上监控
线上项目除了功能跑通,还要能及时发现问题。tRPC 服务端可以加中间件记录查询耗时和错误信息,最简单的写法是在 createTRPCContext 或 router 中间件里做日志输出。加上 OpenTelemetry 之后,还能把链路追踪接进监控平台。虽然前期配置有点麻烦,但如果这个项目是要长期维护的,这步不能省。
7. 常见问题与排查技巧实录
7.1 问题速查表
我把这段时间遇到过的典型问题整理成一张表格,方便你对照排查。
| 问题表现 | 可能原因 | 解决方案 |
|---|---|---|
| 运行 prisma 命令报找不到数据库驱动 | DATABASE_URL 配置不对或 schema provider 不匹配 | 检查 .env 文件,确认 provider 和数据库类型一致 |
| 修改 Prisma schema 后编辑器标红 | 没有执行 npx prisma generate | 每次改模型后都重新生成客户端类型 |
| 调用 tRPC 接口返回 401 | 用户未登录或 Session 失效 | 确认 NextAuth 配置正确,检查浏览器 Cookie |
| mutation 成功但前端列表没更新 | 没有 invalidate 相关 query 缓存 | 使用 useUtils().post.list.invalidate() 刷新 |
| 部署后登录状态一会就丢 | NEXTAUTH_SECRET 不一致 | 重新生成密钥并在所有环境保持统一 |
| 页面请求数量暴增 | 未启用 httpBatchLink | 在 tRPC 客户端配置里启用 httpBatchLink |
| 数据库连接数被打满 | Serverless 环境下直连数据库 | 使用数据库连接池或 PgBouncer 代理 |
| 类型错误导致构建失败 | schema 或路由类型冲突 | 先跑 tsc --noEmit 定位类型问题再修复 |
7.2 我踩过的坑
我最早跑 t3code 时,犯过一个很蠢的错:在本地把 DATABASE_URL 写成 localhost,但数据库服务没启动,Prisma migrate 执行后报错,我还以为 schema 写错了。后来发现只是数据库连接字符串对不上。这提醒我,所有数据库类的报错,第一件事永远检查连接和权限,而不是代码。
另一个印象深刻的问题是,React Server Components 里直接调用 tRPC 查询。RSC 默认是服务端执行的,如果我在服务端组件里试图通过 useQuery 取数据,会因为缺少客户端上下文直接报错。后来我养成了划分边界的好习惯:需要实时交互的部分用 client component,服务端需要数据时直接调用数据库函数或 tRPC 的服务端调用方法。
还有一次因为依赖升级,tRPC 从 v10 升到 v11,路由定义 API 发生了变化,构建的时候报了一堆类型错误。那次之后我养成了每次升级依赖后立刻跑一遍完整的 tsc 和 next build 的习惯,不再相信“小版本升级没影响”。
7.3 调试技巧:从报错信息反向定位
在 tRPC 项目里,错误信息通常包含 stack trace 和 error shape。当你看到某个查询报错时,不要直接打开前端代码看,先看三个地方:第一,服务端日志输出是否包含数据库错误;第二,tRPC 路由里的 zod 校验是否把入参拒绝掉了;第三,Prisma 查询返回的数据结构是否和前端类型一致。
我还有一个常用的调试手段:在路由里临时加一个 console.log,把所有输入参数和 ctx 信息打出来。这个做法虽然朴素,但在定位上下文问题时非常有效。很多“诡异问题”最后发现只是 session 里少了个 id 字段。
写在最后
我在把 t3code 这套流程完整跑通之后最大的体会是,它真正的价值并不在某一个具体技术上,而是在于让整个项目从数据库到按钮形成了一条清晰的类型链路。刚上手时你会觉得这是理所当然的,可一旦回到传统 REST API 项目里,你会发现维护接口文档、手动定义前端类型、排查联调问题这些成本全都在悄悄吃掉你的时间。如果你正准备做一个全栈项目,建议直接拿 t3code 跑一次完整的 CRUD 和认证流程,花一个周末把基础链路走通,后续开发效率的提升会非常明显。我自己在实际项目里已经离不开这种方式了,至少目前来看,它比“后端写接口、前端猜字段”的老路舒坦得多。