先声明一句:这类技术话题的分享,我不会写成教程文档那种干巴巴的“说明书”。下面这篇关于 t3code 的文章,是我从实际开发角度出发、以 T3 Stack 全栈实践为主线写的完整经验帖。如果你正好在纠结“全栈项目怎么选型”“前后端类型怎么打通”“TypeScript 全栈到底怎么落地”,那这篇应该能帮上忙。
如果你身边有人正在用 Next.js 写接口又用 Express 写服务,两头维护类型定义,联调时天天对字段名——那 t3code 这套玩法,可能正好是你要的解法。它不是什么新框架,也不是某个公司开源的重量级产品,而是一套围绕 TypeScript 全栈把“类型安全”做到极致的代码组织方式和最佳实践集合。
这篇文章里,我会从项目定位、技术选型逻辑、端到端类型链路、从零搭建步骤、真实踩坑记录这几个维度展开,尽量把“为什么这么选”“实际跑起来什么样”“出问题了怎么查”都讲透。无论你是刚接触 T3 Stack,还是已经在用但想优化用法,都能在里面找到点东西。
1. t3code 到底是什么,它解决了什么问题
1.1 从命名说起:t3code 不是框架,而是一套编码范式
先说结论:t3code 这个名字,按我自己的理解,就是“按 T3 规则组织代码”(或叫 T3 Code Style)。T3 不是三个版本,也不是三级缓存,而是指 T3 Stack 这套技术组合里的三个核心词——TypeScript、Tailwind CSS、tRPC,还有一种说法是“Type-safe、Tailwind、tRPC”的合体,总之强调的都是 TypeScript 全栈、样式实用、接口零契约成本这三件事。
很多团队把 create-t3-app 脚手架生成的代码称为“T3 风格的代码”,t3code 就是对这个风格的具象化:一层是 Next.js 做应用框架,一层是 Prisma 做数据库模型,一层是 tRPC 做接口层,中间用 Zod 做输入校验。整套代码跑起来,从前端组件里的useQuery到后端数据库查询,所有数据的结构在编译期就被 TypeScript 盯死了。
我自己第一次接触“t3code”这个词,是在给一个内部后台管理系统做技术选型的时候。当时团队里有人提议用“Next.js 写页面 + NestJS 写接口 + PostgreSQL 存数据”,我一听就觉得哪里不对:前后端各写一套 TypeScript,但接口对接还是靠手写类型、手维护 API 文档、手对请求参数,等于把 TypeScript 的一半价值浪费掉了。
后来改用 T3 这套思路,整个后端就是一堆 tRPC router,前端直接导入类型安全的方法调用,数据库表结构变了,编译期立刻冒出几十个红波浪线,根本带不到测试环境。这套体验一旦习惯,基本就回不去了。
1.2 它的目标用户和适用场景
如果你想给自己定位一下“这玩意儿跟我啥关系”,我的建议是分三类看:
| 人群 | 推荐程度 | 原因 |
|---|---|---|
| Next.js 全栈开发者 | 强烈推荐 | 你已经在用 React + Server Components,再加 tRPC 成本极低,收益极高 |
| 想快速搭内部后台/Admin 面板的人 | 强烈推荐 | CRUD 接口不用写文档、不用维护类型,页面组件里直接调用 |
| 传统前端 + 独立后端团队 | 慎重 | 如果后端是 Java/Go,团队没有统一 TS 技术栈,t3code 的跨端类型优势就没了 |
我个人的判断是:t3code 最适合的是“一个人或一个小队全栈干一摊事”的场景。项目里没有专门的接口组、没有独立前端和独立后端,一个人从建表到出页面全部搞定,这种时候类型安全就变成了你唯一的作弊器——因为你不需要记忆“接口返回了哪些字段”,编辑器会替你把所有形状写好。
1.3 t3code 与常规前后端分离的差别
常规项目里,前端拿一个fetch('/api/user/list'),后端返回一串 JSON。前端不知道字段叫什么,后端不知道前端想要什么,于是两边各写一份类型,再靠 swagger 或手写文档对接。一旦表加了个字段,后端改了接口,前端忘了同步,运行时就直接undefined,测试环境稀里糊涂过了,生产环境用户一操作就白屏。
t3code 要解决的正是“前后端类型断裂”这一件事。流程是这样的:
- 数据库模型定义好,Prisma 生成类型;
- tRPC router 里定义 procedure,输入参数经过 Zod 校验;
- 前端从 router 里
import type { RouterOutputs },直接用推导出的类型渲染列表; - 后端字段改了,前端编译立刻报错,不会拖到运行时才发现。
简单说:常规方案是“运行时对齐”,t3code 是“编译期对齐”。后者省掉的联调时间,才是最值钱的部分。
2. 核心机制拆解:端到端类型安全是怎么贯穿全链路的
2.1 从 Prisma Schema 到 React 组件的类型链路
很多教程喜欢一上来就敲命令,但我更想先把链路讲清楚,因为只有理解了这是怎样一条“类型河流”,后面写代码才不会跑偏。
t3code 的类型源头是 Prisma 的schema.prisma文件。你在这里定义一个User模型,Prisma 会自动生成对应的 TypeScript 类型,包括User、UserCreateInput、UserWhereUniqueInput等等。这些类型不是手写的,是 Prisma 引擎从数据库结构映射出来的,所以数据库改字段,类型必然跟着变,不存在“文档忘了更新”的情况。
接着是 tRPC。在 t3code 风格里,你会在server/api/routers/user.ts里定义这样一个 procedure:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const userRouter = createTRPCRouter({ list: publicProcedure .query(async ({ ctx }) => { return ctx.db.user.findMany({ select: { id: true, name: true, email: true }, }); }), create: publicProcedure .input(z.object({ name: z.string(), email: z.string().email() })) .mutation(async ({ ctx, input }) => { return ctx.db.user.create({ data: input }); }), });注意里面的两个细节:query和mutation是 tRPC 的两个基本操作类型;create里用了.input(z.object(...)),这个 Zod schema 既在运行时做校验,又在编译期推导参数类型。也就是说,你在前端调用create时,编辑器会强制要求name: string和email: string,传一个number进去直接编译不通过。
再往下一层,前端页面里这样调用:
import { api } from "~/utils/api"; export default function UserList() { const { data, isLoading, refetch } = api.user.list.useQuery(); const createUser = api.user.create.useMutation({ onSuccess: () => refetch(), }); if (isLoading) return <div>加载中...</div>; return ( <ul> {data?.map((user) => ( <li key={user.id}>{user.name} — {user.email}</li> ))} </ul> ); }看到api.user.list.useQuery()这里了吗?它在编译期就知道返回的数据里每个字段的类型,所以user.name如果写错了,立刻报错。这就是端到端类型安全的实际体验——数据库字段、接口返回、页面渲染,三层完全同步。
2.2 tRPC 与传统 REST API 的本质区别
有人可能会问:“这不就是把 Postman 里测接口搬到了前端吗?跟 axios 调 REST 有啥区别?”
区别很大。REST API 是一个“资源路径 + 请求方法 + 响应结构”的协议,它的类型安全需要你自己维护:写 OpenAPI 文档也好,写 interface 也罢,本质上都是“手工劳动”。tRPC 则直接把服务端函数变成了前端可调用的“本地函数”,前端不需要知道 URL、不需要拼参数、不需要解析错误码,它只知道“我调了一个函数,传进去这些参数,拿回来这些结构”。
做一个类比:REST 像是你去餐厅点餐,你需要跟服务员说“菜单上第 3 道菜(/api/user/list)”,然后服务员用笔写下菜名,后厨凭记忆做,做完喊号你来端,中间任何环节都可能出差错。tRPC 则是你直接认识后厨,你跟他说“我要一份糖醋排骨,加醋”,后厨做好了直接端到你面前,整个过程类型一致、过程可控。
t3code 选择 tRPC,还有一个很现实的原因:内部 B 端系统不需要对外暴露 API,不需要给第三方开发者看文档。REST 的很多设计规范(版本号、资源嵌套、Hypermedia)在这类项目里派不上用场,反而徒增复杂度。tRPC 把接口变成“内部函数调用”,安全、快、类型稳。
2.3 Zod 在这里扮演的角色
Zod 不是 tRPC 专属,但它和 tRPC 搭配起来简直是天作之合。你只需要在.input()里写一份 Zod schema,它同时承担了:
- 运行时校验:前端传了个非法参数,请求在进入数据库之前就被拦截,返回 400 错误信息;
- 编译期类型:TypeScript 自动推导出
input的类型,前端调用时获得完整补全提示; - 文档作用:以后想写接口文档,直接导出一份 Zod schema 的 JSON 格式,不用再单独维护。
经常有人纠结“前端也要校验,后端也要校验,到底该写几份”?在 t3code 里,你只写一份,放在 tRPC router 里。前端显示的校验错误、后端执行的防御校验,都是从这一份 schema 来的。
3. 从零搭建一个 t3code 风格项目(完整实操)
3.1 初始化项目与脚手架选择
T3 Stack 官方提供create-t3-app脚手架,这是最常见的入口。执行命令:
npm create t3-app@latest my-t3code-app这里有几个交互选项需要留意:
| 选项 | 建议 | 原因 |
|---|---|---|
| 是否使用 Next.js | 选“是” | t3code 的核心基础是 Next.js,带来 Server Side Rendering 和文件路由 |
| 是否使用 tRPC | 选“是” | 不开它就不是 t3code 了 |
| 是否使用 Prisma | 选“是” | 数据库类型来源,没有 Prisma 类型链路就断了 |
| 是否使用 Tailwind CSS | 可选 | 看团队风格,如果公司已有 UI 组件库可以不开 |
| 是否使用 NextAuth.js | 看需求 | 需要登录就选,不需要就果断去掉 |
| 是否使用 ESLint / Prettier | 建议全开 | 类型安全之外的代码规范同样重要 |
初始化完成后,项目结构大概长这样:
my-t3code-app/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── post.ts │ │ │ │ └── user.ts │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ └── db.ts │ ├── pages/ │ │ ├── index.tsx │ │ └── ... │ ├── styles/ │ │ └── globals.css │ ├── utils/ │ │ └── api.ts │ └── env.mjs这个结构的核心是src/server/api/root.ts,它把所有 router 合并注入:
import { userRouter } from "./routers/user"; import { postRouter } from "./routers/post"; import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ user: userRouter, post: postRouter, }); export type AppRouter = typeof appRouter;前端src/utils/api.ts里的api就是从AppRouter类型创建的 hooks。脚手架帮你把这些都连接好了。
3.2 配置数据库模型与 Prisma 迁移
我这次用一个简化的 “文章系统” 例子来演示:用户和文章两张表。先编辑prisma/schema.prisma:
model User { id String @id @default(cuid()) email String @unique name String? createdAt DateTime @default(now()) posts Post[] } model Post { id String @id @default(cuid()) title String content String authorId String author User @relation(fields: [authorId], references: [id]) published Boolean @default(false) createdAt DateTime @default(now()) }然后执行:
npx prisma migrate dev --name init npx prisma generatemigrate dev会创建数据库表并生成迁移记录;generate会根据最新的 schema 重新生成 Prisma Client 类型。这两步做完,ctx.db.user就带上了完整的类型推断。
值得提醒的是,每次改了 schema 之后,都必须重新执行这两步,否则 Prisma Client 类型和数据库实际结构不一致,tRPC 里的.findMany()虽然编译过了,但运行时可能因为查了不存在的字段而抛错。
3.3 写一个完整的 CRUD 流程
我们来做一个“发布文章”功能。在src/server/api/routers/post.ts里写:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; const createPostSchema = z.object({ title: z.string().min(1).max(100), content: z.string().min(1), }); export const postRouter = createTRPCRouter({ listPublished: publicProcedure .query(async ({ ctx }) => { return ctx.db.post.findMany({ where: { published: true }, include: { author: { select: { name: true, email: true } } }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(createPostSchema) .mutation(async ({ ctx, input }) => { const authorId = ctx.session.user.id; return ctx.db.post.create({ data: { ...input, authorId }, }); }), });注意到protectedProcedure了吗?这是 tRPC 配合 NextAuth 之后的中文叫法是“鉴权 procedure”,它要求用户必须登录,否则直接抛 401。免去了每次在业务代码里手动判断ctx.session的重复操作。
前端页面src/pages/posts.tsx里:
import { api } from "~/utils/api"; import { useState } from "react"; export default function PostsPage() { const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const { data: posts, refetch } = api.post.listPublished.useQuery(); const createPost = api.post.create.useMutation({ onSuccess: () => refetch(), }); return ( <div> <h1>文章列表</h1> <ul> {posts?.map((post) => ( <li key={post.id}> <strong>{post.title}</strong> — {post.author?.name ?? "匿名"} </li> ))} </ul> <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="标题" /> <textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="内容" /> <button onClick={() => createPost.mutate({ title, content })} disabled={createPost.isLoading} > 发布 </button> </div> ); }这个页面的神奇之处在于:你不需要定义Post类型,不需要知道接口路径,不需要 fetch 封装,api.post.listPublished.useQuery()直接返回完整类型的数据。我实测下来,写一个简单 CRUD 页面从零开始大约只需 15 分钟,其中大部分时间是在调整 UI 样式而不是对接数据。
3.4 环境变量与鉴权配置的细节
t3code 项目里,.env文件管着一堆秘密,包括数据库连接串、NextAuth 的 secret、登录回调地址等。脚手架生成的env.mjs会做启动时校验,也就是说,如果你忘了配置DATABASE_URL,项目在你启动的那一刻就会报错,而不是等你去调接口时才炸出来。
我踩过的一个坑是:本地开发用的是 SQLite(DATABASE_URL="file:./db.sqlite"),到生产环境换成了 PostgreSQL,因为 schema 里用了@default(cuid())这类对数据库无依赖的默认值,所以切换问题不大;但如果用了@default(dbgenerated("gen_random_uuid()")),就得谨慎处理了,这种默认值依赖数据库扩展,SQLite 和 PG 行为不一致,迁移前务必查文档。
4. 常见报错与排查记录(真实踩坑合集)
4.1 tRPC 报错 “No loader registered for procedure”
这类错误通常出现在你新加了一个 procedure,但前端页面引用时写错了名字,比如在 router 里定义的是listPublished,页面上调了listAllPublic。因为 tRPC 的类型是强约束的,这种错误绝大多数在编译期就会被符号标红发现;但如果你的编辑器 TypeScript server 没热更新,有时会漏报,重启一下 TS 服务基本就好了。
4.2 Prisma 类型未更新导致的诡异报错
场景非常典型:我改完 schema,跑完migrate,忘了跑prisma generate,结果在前端api.post.create.useMutation()里,给content传值时报“类型不存在”。看着编译红波浪,查了半天发现 Prisma Client 根本没有新增字段类型。
排查顺序我个人总结成一条流程:
- 确认
prisma/schema.prisma是否与期望一致; - 执行
npx prisma generate; - 重启 TypeScript 编译器(VS Code 里
Ctrl+Shift+P搜 “TypeScript: Restart TS Server”); - 如果还不行,检查
.env里的数据库连接串是否指向了正确实例。
这个“生成类型”的环节,真的很容易被忽略,习惯不是一天养成的,建议每次改完 schema 之后形成肌肉记忆:migrate dev --name xxx紧接着generate。
4.3 NextAuth 的 session.user.id 拿不到
用 NextAuth 做鉴权时,默认的session.user类型只包含name、email、image,没有数据库里的id。如果你在protectedProcedure里写ctx.session.user.id,编辑器会直接报错 “Property 'id' does not exist”。
解法是在项目里做类型扩展。脚手架自带了一个src/types/next-auth.d.ts,你需要在里面加:
import { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }同时记得在authOptions的callbacks.session里把token.sub手动赋到session.user.id:
callbacks: { session({ session, token }) { if (session.user) { session.user.id = token.sub as string; } return session; }, },这个坑我印象很深,因为它在本地开发时很可能不报错(因为 session 结构本身有灵活性),但部署到生产环境后,某些第三方登录配置下 session 结构更严格,问题才炸出来。
4.4 生产构建阶段 tRPC 请求路径 404
开发环境下,tRPC 和 Next.js 的 API 路由由 dev server 自己协调,所以路径没问题。到了部署环境(比如 Docker + Nginx),如果 Next.js 的 API 前缀路径被代理规则改掉了,tRPC 请求可能全 404。
排查思路:
- 确认部署后访问
https://yourdomain/api/trpc/user.list?input=...是否返回 JSON; - 如果 404,检查 Nginx 的
location规则是否把/api/trpc透传到了 Next.js 服务端口; - 确认环境变量
NODE_ENV=production下没有设置错误的BASE_URL,tRPC 默认使用相对路径,一般不需要设置绝对地址,但如果你在自定义域名部署,最好显式配置。
另外有个小提示:如果用了 PM2 或 Docker 多实例部署,不要忘了开 http 缓存或者开启 tRPC 的unstable_httpBatchLink批量请求,因为内部系统页面组件多,一次页面加载可能同时发出几十个 tRPC 请求,没有 batched 处理时,HTTP 并发压力会比较大。
4.5 Tailwind 类名在动态拼接时不生效
这不是 t3code 的专有问题,但凡是 Tailwind 玩家都会遇到。我在 t3code 里做主题切换时,写过一个动态类名拼接:
<div className={`text-${color}-500`}>结果text-red-500这类样式在编译后的 CSS 里根本不存在。Tailwind 是静态扫描的,它只认得源码里完整出现的类名字符串,text-${color}-500这种动态拼接的字符在扫描阶段无法识别,所以 CSS 不会包含它。解决办法是使用完整的类名字典,或者用 Tailwind 的safelist配置。
4.6 常见问题速查表
| 现象 | 可能原因 | 优先排查动作 |
|---|---|---|
| 编译红波浪,属性不存在 | Prisma Client 未重新生成 | 执行prisma generate并重启 TS Server |
| 登录后 session.user.id 为空 | NextAuth callback 未赋值 | 检查 callbacks.session 是否返回 id |
| tRPC 请求 401 | 未配置 NextAuth 或 token 过期 | 检查 JWT_SECRET 和 session 策略 |
| 页面点击发布没反应 | Zod 校验未通过 | 打开浏览器 Console 看详细错误信息 |
| 构建时样式丢失 | Tailwind 动态类名拼接 | 改为完整类名或 safelist |
| 生产环境 API 404 | 网关代理规则错误 | 检查/api/trpc是否透传 |
我觉得没必要背排查清单,重要的是理解 t3code 的类型链路,哪里容易断,就去哪里查。大部分问题其实是“类型没更新”和“过程类型没扩展”两类,极少有“玄学错误”。
5. 个人经验总结与扩展建议
如果让我总结这一套 t3code 实践下来最核心的感受,那就是:它把很多需要大脑记忆的隐性约束转化成了编译期的显性报错。以前写后台,脑子里要装一张表:“用户接口返回 { id, name, email },文章接口返回 { id, title, content }”,不记住就会写出运行时才炸的代码。现在这些信息全在编辑器里,写起来安全感极高,重构的时候也非常爽——我改过一个字段名,全项目的引用处全部标红,逐个修完,编译一过,基本就功能完成了,不需要再把前端页面一个个点开测试。
还有一个体会:t3code 这套范式特别适合“先快速验证业务逻辑,再慢慢打磨界面”的开发节奏。脚手架帮你把数据库、接口、鉴权、样式全部配好,业务逻辑只需要往 router 里加函数,前端页面往组件里加useQuery,剩下的事情交给类型系统兜底。我用它做过一个简单的客户管理系统,从需求确认到部署上线,总时长不到两天。换作以前前后端分离的架构,两天可能刚把接口文档写完。
最后给一个可扩展的方向:如果你觉得 t3code 里的 NextAuth 不够灵活,可以换成 Auth.js(其实本质上就是它)或者自定义 session 存储;如果项目大了之后希望把 tRPC router 拆成多个微服务,可以用 tRPC 的多端链接(splitLink)来区分内外网请求。更重要的是,T3 Stack 的社区一直在活跃更新,它不像是会昙花一现的方案,而是和 TypeScript 生态深度绑定的主流玩法。建议新手直接按官方文档走一遍create-t3-app,然后照着我上面的 CRUD 例子自己手敲一遍,不出三天就能完全上手。