T3Code 这个名字,第一次看到的人多半会愣一下——T3 是什么?Code 又是什么?如果你在技术社区混过一段时间,大概知道 T3 Stack 是 Next.js、TypeScript、Tailwind CSS、tRPC 这些技术组合的代名词。但 t3code 放到实际项目中,代表的是一套完整的全栈脚手架,一套从零到上线的高效工作流。我自己在好几个项目里用过这套组合之后,才意识到它的真正价值不在某个单一框架,而在“类型安全贯穿全链路”这件事带来的体验飞跃。
这套方案到底解决了什么问题?简单说,前后端共享类型、接口变更自动暴露、数据库模型与 API 层无缝衔接,写代码的时候大脑不需要来回切换上下文。对刚接触全栈的新手来说,它把“前端调接口”这件事简化成了“直接调用一个函数”;对有经验的团队来说,它避免了前后端联调时因为字段拼写不一致而浪费的周末。这篇内容我按自己的实操路径来写,包含初始化步骤、一个完整 CRUD 示例、还有我在实际项目中踩过的几个坑,希望能帮你快速把 t3code 用起来。
1. t3code 到底是什么:从命名到技术组合
1.1 名字里的 T3 指什么
T3 这个名字来源于 The T3 Stack,最早的提出者是 tRPC 的作者 Theo。T3 不是三个以 T 开头的技术,而是 tRPC、Tailwind、TypeScript 三个单词的首字母组合,顺便致敬了《黑客帝国》里的 Trinity。这个命名虽然带点玩票性质,但背后投射出来的理念非常认真:先用 TypeScript 搞定两端类型,再用 tRPC 抹掉 API 层的手工对接,最后用 Tailwind 把样式开发速度提上来。
所以 t3code 并不是某个官方项目名,而是大家围绕这套技术栈搭建的实战项目代号。你会发现社区里不少人把自己的全栈模板仓库命名成 t3code、t3-app、my-t3-project 之类的名字,本质上都是同一套组合拳。它的目标场景很清晰:中小型全栈应用、内部工具、快速原型、需要认证和数据库的 Web 应用。大型分布式系统用不用它是另一回事,但在团队资源有限、需要快速交付的场景里,这套组合真的能把开发周期压缩很多。
1.2 为什么 TypeScript、Tailwind、tRPC 会走到一起
从我的使用体感来看,这套组合解决了一个很真实的痛点:开发者的上下文切换成本。
以前做全栈项目,前端写 fetch,后端写 REST 接口,中间夹一层 OpenAPI 文档,字段一多就容易出现前端用了userName、后端返回username这种低级错误。调试的时候还得打开浏览器 Network 面板,一条条核对字段名。用上 TypeScript 之后,至少前后端各自的代码质量上来了,但接口两端依然没有打通。tRPC 做的事情就是把后端的函数变成前端可以直接调用的类型安全方法,字段名拼错了、类型不对了,编译阶段就直接报错,根本轮不到联调的时候才发现。
至于 Tailwind,它解决的是样式层面的效率问题。全栈项目里最容易被拖垮的就是 UI 环节,Tailwind 的原子类写法配合组件化开发,会让页面迭代非常快。而且 T3 生态里还有 Next.js 当骨架,Prisma 管数据库,NextAuth 管登录,整套下来从前到后的技术选型都不用你操心。它不是没有缺点,但它在“快速、安全、可维护”三者之间的平衡做得非常漂亮,适合大多数 Web 应用的研发。
2. 环境准备与项目初始化全记录
2.1 动手前先检查这些前置条件
在终端里敲下npm create t3-app@latest之前,建议先确认几个环境因素。Node.js 版本是头号检查项,这套技术栈要求 18.17 及以上版本,我自己之前在 Node 16 上跑过,直接卡在 Next.js 14 的依赖安装上。最好用 nvm 装一个 LTS 版本再动手,省得后面因为版本问题多熬两小时。
还有个容易被忽略的准备:数据库。create-t3-app 初始化的时候会让你选数据库 ORM,默认是 Prisma,同时会要求你提供一个数据库连接串。如果你不想一开始就装 MySQL 或 PostgreSQL,可以先选 SQLite,连接串直接填file:./dev.db,零成本起步,后面要切生产库时再改 schema 和连接串就行。这个过程我建议新手务必走一遍,因为 SQLite 到 PostgreSQL 的切换其实就是改 provider 和 URL 的事,不上手永远不知道该改哪里。
2.2 create-t3-app 交互式初始化实操
安装命令有两种方式,npm 和 pnpm 都行。我个人推荐 pnpm,安装速度快很多,而且依赖管理严格,不会出现 node_modules 里一堆幽灵依赖的问题。初始化命令:
pnpm create t3-app@latest my-t3code执行后会进入交互式问答,会问你项目名、要不要 TypeScript、Tailwind、tRPC、Prisma、NextAuth、Docker 这些选项。我建议第一次接触的时候全选,把完整结构跑起来,之后再去掉不需要的模块。有个容易忽略的选项是ESLint和Prettier,建议都勾上,后面代码规范检查会省很多事。
初始化完成后,进入目录安装依赖:
cd my-t3code pnpm install然后初始化 Prisma 并生成客户端:
npx prisma init npx prisma db push npx prisma generate如果你用的是 SQLite,db push会直接帮你建好数据库文件;如果是 PostgreSQL,需要先把数据库建好再执行。看到生成的 client 文件之后,整个骨架就算跑起来了。运行pnpm dev,浏览器打开localhost:3000,如果能看到默认的 T3 首页,说明环境正常。我第一次跑的时候卡在prisma generate上,总是提示找不到 schema.prisma,后来发现是因为没有先执行prisma init,这个顺序坑了不少人。
2.3 初始化后的目录结构与模块职责
整个项目跑起来之后,目录结构值得花几分钟熟悉一下:
my-t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── pages/ │ │ ├── api/ │ │ │ └── trpc/ │ │ └── index.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ └── root.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── styles/ │ └── trpc/ │ ├── client.ts │ ├── server.ts │ └── react.tsx ├── .env └── package.json这个结构最大的好处是分层清晰,src/pages是 Next.js 的页面路由,src/server/api/routers是你所有业务逻辑的汇聚点,src/trpc是 tRPC 客户端和服务端的胶水层。新手最容易犯的错是把业务逻辑写在pages里,正确做法是在路由组件里调用 tRPC 的 hooks,真正的逻辑收在 router 里。
3. 一个完整的类型安全 CRUD 实战
3.1 需求场景与数据模型设计
接下来用一个笔记应用的例子串一遍完整流程。需求很简单:用户创建笔记、查看列表、编辑内容、删除笔记。这个例子虽然小,但 CRUD 全覆盖,足够体现 tRPC + Prisma + Zod 的组合拳怎么打。
先设计 Prisma 数据模型,打开prisma/schema.prisma:
model Note { id String @id @default(cuid()) title String content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }这一层设计算是最简单的版本,但它包含一个关键点:id 用了cuid()而不是自增整数。用自增 id 在中小项目看没问题,但一旦涉及数据迁移、分库或者对外暴露 id 的场景,自增 id 容易被遍历,cuid 更稳。这个经验是我在做过几个项目后换来的,一开始图省事用自增 id,结果后来要给数据做同步,直接把自己坑了。
改完 schema,执行npx prisma db push和npx prisma generate,把表建好,把客户端类型重新生成。
3.2 为业务逻辑创建 tRPC Router
tRPC 的 Router 就是你后端 API 函数的集合。在src/server/api/routers下新建一个note.ts:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const noteRouter = createTRPCRouter({ getAll: publicProcedure.query(({ ctx }) => { return ctx.db.note.findMany({ orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1, "标题不能为空"), content: z.string(), }) ) .mutation(({ ctx, input }) => { return ctx.db.note.create({ data: { title: input.title, content: input.content, }, }); }), });这里几个细节要说明一下。createTRPCRouter是 tRPC v11 的写法,用createTRPCContext生成的上下文里带了db实例,所以你不需要在每个函数里重新创建 PrismaClient,直接用ctx.db就行。publicProcedure表示这个接口无需登录也能调用,如果你需要用户身份,换成protectedProcedure,它会在执行前检查 NextAuth 会话。
Zod 的input定义是整条链路的起点,它既可以在服务端做运行时校验,又会把类型推导到前端。这么写的好处是:前端调用create时,title传成数字类型,编辑器直接报错,根本发不出请求。
3.3 把 Router 挂载到根 Router
光有noteRouter还不够,要把挂到根路由上。打开src/server/api/root.ts,在appRouter里加上:
import { noteRouter } from "~/server/api/routers/note"; export const appRouter = createTRPCRouter({ note: noteRouter, });然后确保src/pages/api/trpc/[trpc].ts存在且内容正确:
import { createNextApiHandler } from "@trpc/server/adapters/next"; import { appRouter } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; export default createNextApiHandler({ router: appRouter, createContext: createTRPCContext, });这一段是整个 tRPC 服务的 HTTP 入口。之前有朋友跑来问我说接口总是 404,结果发现他压根没建这个[trpc].ts文件,tRPC 没有挂到 Next.js 路由上。所以这个文件别省,也别改文件名,中括号是必须的。
3.4 页面里调用 tRPC hooks
服务端就绪后,前端调用方式会清晰得让人感动。在src/pages/index.tsx里写:
import { api } from "~/trpc/react"; export default function Home() { const utils = api.useUtils(); const { data: notes, isLoading } = api.note.getAll.useQuery(); const createNote = api.note.create.useMutation({ onSuccess: () => { utils.note.getAll.invalidate(); }, }); const deleteNote = api.note.delete.useMutation({ onSuccess: () => { utils.note.getAll.invalidate(); }, }); // ... 渲染逻辑 }useQuery和useMutation这两个 hooks 是日常开发里最常用的,用法差不多对应 React Query 的封装。utils.note.getAll.invalidate()的意思是把笔记列表的缓存标记为失效,重新拉取数据。这一步是数据更新后保持 UI 同步的关键,很多新手写完 mutation 之后发现列表不刷新,就是因为少了 invalidate 调用。
api对象是从src/trpc/react.tsx导出的,底层就是一个带类型包裹的 React Query 客户端。你会在代码提示里直接看到后端定义的所有路由、输入参数类型、返回值类型。第一次用的时候那种“前后端终于通了”的感觉,确实会上瘾。
3.5 Zod 校验的错误提示如何传给前端
前面代码里z.string().min(1, "标题不能为空")这种写法很有讲究。tRPC 会把 Zod 校验失败的信息作为TRPCClientError抛到前端,你可以直接在页面的error字段里看到具体信息,用 toast 弹出也好、表单控件下方展示也好,都方便。
不过默认的 Zod 错误信息比较直白,想要更好看的字段错误,可以给 input 加z.object的错误定制:
.input( z.object({ title: z.string().min(1, "标题不能为空"), content: z.string().min(10, "内容至少写十个字"), }) )这种方式比自己在服务端写一堆if (!input.title) throw new Error(...)干净太多,代码量直接减少一半以上,而且所有的校验分散在 Router 定义里,看代码就能明白每个接口的约束。
4. 常见问题与排查经验
4.1 tRPC 类型不匹配的排查套路
类型错误是 tRPC 使用中最常见的痛点,不管前端代码提示红波浪线,还是在服务端 get 不到 input 属性,多半是类型没对上。tRPC 类型是服务端推导出来的,前端 IDE 里看到的类型信息会被缓存,改了后端 Router 但前端不刷新时常会发生。
我碰到的典型场景是修改某个 query 的返回值结构后,前端组件里的字段引用立刻报错。这其实是类型的实时性优势在起作用,不是框架的问题,是在提醒你前端也要同步改。排查时先确认appRouter是否已经把新 router 挂载进去,再确认前端引入的api对象来自~/trpc/react而不是自己创建的原生 React Query Client。两边的类型不通用,一混用就报错。
另外需要提醒的是,tRPC 的procedure和router有两个来源:一个来自@trpc/server,一个来自项目里的server/api/trpc.ts。永远优先从项目里的createTRPCRouter创建,因为它带了自定义的上下文类型。用了@trpc/server的原始版本,等于丢掉了 ctx.db 的类型,后面写起来处处受限。
4.2 Prisma 连接与迁移容易踩的坑
Prisma 这条线最常出事的两个环节,一个是连接串,一个是迁移。先说连接串,PostgreSQL 的连接串必须放在.env里的DATABASE_URL,create-t3-app 默认会生成一份.env.example模板,但不会生成.env。如果你照着文档敲但是一直报连接错,先查.env在不在、变量名是不是DATABASE_URL。
迁移这块,prisma db push适合开发初期快速跑通结构,但表结构一旦定型,协作开发时推荐走正式的 migration。我习惯是先把 provider 配好,然后执行npx prisma migrate dev --name init,它会把每次变更记录到 migrations 目录,多人协作时别人拉下代码执行migrate deploy就能重建库结构。很多人一开始想省事只 push,后来换环境部署时发现生产库表结构和开发环境对不上,那就更难搞了。
还有个小提醒:改了schema.prisma之后必须重新prisma generate,否则你会发现 PrismaClient 的类型永远不会更新。这个操作太容易漏了。
4.3 NextAuth 会话与 protectedProcedure 的类型问题
加认证之后,protectedProcedure会从 session 里读取用户信息,比如常用写法ctx.session.user.id。这里面经常出现的问题是 session 类型没扩展,TypeScript 报user.id不存在,因为 NextAuth 默认 session 里只有user.name、user.email、user.image这些字段。
解决办法是在类型声明里增加自定义模块声明,通常放在src/types/next-auth.d.ts:
import "next-auth"; declare module "next-auth" { interface Session { user: { id: string; name?: string | null; email?: string | null; image?: string | null; }; } }类型声明加完之后要重启 dev server 才会生效,这个不起眼的问题经常会让页面反复报红,但其实只是类型缓存没刷新。另外如果登录回调里已经拿到了session.user.id但前端还是拿不到,注意看callback里有没有将它塞回去。我在做用户数据隔离时,就是在这个回调里先查数据库拿到数据库里的用户 id,然后塞进 session,前端才能直接用。
4.4 部署时最容易遗漏的环境变量
本地开发没问题,一到部署就出各种 auth 或数据库报错的情况,十有八九是环境变量没传全。t3code 项目里几个关键变量分别是DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL。
NEXTAUTH_URL在本地默认是http://localhost:3000,部署后要改成线上的完整地址,没有加https://前缀时 NextAuth 提醒说不安全,但又会运行,行为会比较诡异。我踩过把NEXTAUTH_SECRET写成固定字符串的坑,然后是部署平台重启了就登出。后来用命令行生成强随机字符串填进去,这个问题再没出现过。
如果用了 S3 之类的存储服务,对应的密钥也得一并配好。部署之前建议用平台自带的环境变量管理页面对照本地.env一条条核对,不要问我是怎么知道这点的。
4.5 开发时热更新失效的奇怪现象
我遇到过一次很诡异的场景:改了后端 router,前端调用后返回的数据还是旧的。排查了一圈发现是 dev server 运行太久,Next.js 的热更新机制在某些大项目里会失灵,重启pnpm dev就恢复正常了。这个问题其实不算 t3code 特有,Next.js 的 Fast Refresh 在改错文件位置时也容易出现,改了.env之后尤其明显,因为环境变量加载不会自动触发重编译。
遇到这种情况不要慌,先看终端里有没有编译报错,再看有没有类型定义文件被 IDE 缓存住。大部分此类问题重启都能解决,真正难搞的是 tRPC 的路由写了很多层,类型链路又长,重启后依然报错。这时候我会用npx tsc --noEmit做一次全量类型检查,看看具体是哪个文件的类型对不上,通常能定位到问题。
5. 几个我建议从第一天就养成的习惯
先说 Prisma 的命名规范。字段名统一用驼峰,表名用复数还是单数保持一致。我见过团队里Note、notes、noteRecord三种叫法混着用,到后面写查询想死的心都有。schema 里表名和模型名最好一一对应,模型名用单数,数据库表名用复数,这样 tRPC 返回的对象读起来更自然。
再说目录划分。t3code 项目里 router 会越来越多,建议按业务模块组织文件,比如auth/、note/、project/,每个模块里放自己的 router、schema、service。不要在root.ts里堆几百行,那纯粹给自己找麻烦。
tRPC 的middleware适合做权限校验和数据过滤。比如只允许用户操作自己的笔记,可以在 procedure 上加一个中间件检查ctx.session.user.id和资源归属。这个模式写一次,整个 router 都能复用,比在每个 mutation 里写重复判断要好得多。我建议把那些反复用的逻辑沉淀成 middleware,这是 t3code 进阶的必修课。
最后少折腾花活。我用 t3code 写过带 WebSocket 的应用,也做过文件上传,但真正稳定高效的还是标准 CRUD 加认证的场景。遇到需求时先想一层:能不能用现有 tRPC query 解决,能就别自己搭一套新的通信机制。这套技术栈最大的优势是把常规事做得极顺手,你要是把它改造成花式 API 网关,反而失去意义。根据我的经验,守住简单,比追逐花哨更能帮你把项目做完并顺利上线。