“t3code”这个标题在技术社区里经常和 T3 Stack 绑定出现TypeScript、Tailwind CSS、tRPC 再加上 Next.js 的那套组合拳。我自己在做全栈项目时用这套技术栈写过几个小产品今天就把从零搭建一个 t3code 项目的过程、踩过的坑、以及我是怎么理解和应用这套架构的完整复盘一遍。这篇文章适合两种人一是想快速上手 T3 Stack 但被官方文档的抽象概念劝退的初学者二是已经在用 Next.js 数据库但想体验一把端到端类型安全的进阶玩家。我会尽量用做项目时的真实语境来讲不堆概念只聊实操。1. t3code 的项目认知与核心设计思路1.1 先拆解一下 t3code 这个项目到底在解决什么问题T3 Stack 在我理解里本质上是把“让前后端共享类型”这件事做到了极致。传统的全栈项目里前端写 fetch 请求后端写 REST 接口两边各维护一套类型定义改一个字段名可能要让两个人对着 Postman 来回对半天。t3code 这个项目用 tRPC 作为通信层前端调函数像调本地方法一样类型直接从后端“长”到前端改一个字段编译器立刻告诉你哪里引用了旧类型从根上消灭了联调地狱。注意这听起来很爽但 t3code 不是银弹。我见过不少团队把它当成常规 REST API 的替代品硬套在需要对外开放 API、需要第三方集成的场景里结果越用越别扭。T3 Stack 最适合的场景是内部全栈应用尤其是你自己一个人开发、或者前后端都是同一个团队维护的产物。它的优势是开发效率和类型安全代价是接口协议封闭——你不能要求外部开发者按 tRPC 的协议来调你的服务。所以说t3code 的核心价值不是“不用写接口了”而是“把项目中最大的隐性成本——前后端类型断层——给结构性消灭掉”。这也是我在做小规模工具类产品时优先选择 T3 Stack 的原因它把精力还给了业务逻辑。1.2 为什么选 Next.js tRPC Tailwind这套组合的底层逻辑在哪T3 Stack 的四个核心组件每一个都不是随便选的背后的取舍逻辑值得掰开讲。Next.js 承担的是路由、渲染策略和前后端一体化交付。它让 tRPC 的 server caller 可以嵌入到 React Server Component 里直接调用数据库逻辑绕过了 HTTP 网络层。这在传统 SPA 架构里是需要额外设计和调优的但 Next.js 默认就支持这种混用模式t3code 的代码结构天然受益于此。TypeScript 是整个体系的粘合剂。没有 TypeScript 的 tRPC 毫无意义因为 tRPC 的本质是“类型即协议”。你定义了一个getUserById方法它的输入输出类型被推导出来前端 next 拿到这个类型编辑器里的自动补全、重构、报错全部联动。这种体验一旦用上就很难回去。Tailwind CSS 在这里不是主角但它的存在降低了 UI 层的思考负担。t3code 项目里我的经验是90% 的样式都可以直接用原子类完成不需要起类名、不需要管 CSS Modules 的加载顺序开发节奏很顺畅。它和 tRPC 有一点精神是相通的把约定做进框架层让人不需要做低价值的决策。tRPC 是整个架构的心脏。它的核心设计是让你定义 router 和 procedure然后在前端像调用本地 async 函数一样调用后端逻辑。t3code 里的所有数据操作比如创建、更新、查询都是通过 tRPC 的 procedure 实现的。这里最容易被误解的一点是tRPC 的协议仍然是 HTTP不是 WebSocket也不是什么魔法。它只是在 Development 模式下用了一个特殊的 URL 前缀生产环境同样走常规 POST 请求。理解这点调试时就不会觉得云里雾里。这四件套放在一起形成了一个很有意思的闭环Next.js 负责全栈承载TypeScript 负责把类型这个开发者的“心智模型”具体化tRPC 负责把类型从前端传到后端Tailwind 负责把精力从样式里释放出来。每一层都服务于同一个目标减少上下文切换让开发者专注业务逻辑。2. 核心细节解析与实操要点2.1 从脚手架看目录结构t3code 的每个文件夹是干什么的先聊聊 t3code 项目的初始化。官方推荐用create-t3-app初始化这个脚手架最良心的地方不是生成代码而是让你在初始化时就能勾选需要用到的模块。我一般勾选的是 NextAuth、Prisma、tRPC没勾 Tailwind 的情况很少但 Tailwind 是默认就有的。如果你想先跑通核心流程也可以不勾 Prisma改成用 Drizzle 或者直接操作数据库但那样就感受不到 t3 全家桶的完整威力了。初始化完成后你会得到一个结构清晰、但初学者常常发怵的项目目录。核心的关键路径其实就几个src/server/api/root.ts所有 tRPC router 的入口你新增 router 后需要在这里挂载src/server/api/routers/实际的业务 router 文件夹比如post.ts、user.tssrc/server/db.tsPrisma 客户端实例tRPC 的 procedure 里直接调用它查数据库src/trpc/server.ts和src/trpc/react.tsx服务端和客户端的 tRPC 封装基本不需要改src/app/api/trpc/[trpc]/route.tsNext.js 里处理 tRPC 请求的路由脚手架已经配好我见过不少新人一进来就想重构目录结构把 tRPC 相关代码挪来挪去。我的建议是先老老实实按脚手架的约定来不要一开始就自定义抽象层。你还没有踩过它帮你避掉的坑自然不知道它为什么这么设计。2.2 tRPC procedure 的状态码与错误处理别用手写 if 判断tRPC 里最核心的概念是 procedure它有四种状态publicProcedure、protectedProcedure、adminProcedure等受保护状态以及自定义中间件扩展。实际开发 t3code 项目时我会把权限校验封装成中间件而不是在每个 procedure 里写 if else 判断。举一个具体的例子。假设 t3code 里有一个createNote的 procedure需要登录用户才能操作。如果直接写在 handler 里代码会变成const createNote protectedProcedure .input(z.object({ title: z.string() })) .mutation(async ({ ctx }) { // 这里的 ctx.session 已经被中间件填充好了 return ctx.db.note.create({ data: { title: ctx.input.title, userId: ctx.session.user.id } }) })注意看在protectedProcedure里你不需要自己判断 session 是否存在因为中间件已经做了。这样你的业务逻辑里只有纯粹的数据库操作不会有散落的权限检查代码。这也是 t3code 项目可维护性强的一个重要原因权限逻辑收敛在中间件层业务逻辑收敛在 handler 层。关于错误处理这也是很多人容易画蛇添足的地方。tRPC 默认在出错时会抛一个TRPCClientError前端的useQuery或useMutation会自动捕获并且把它放在error字段里。你不需要自己写一个模拟 Axios 的拦路虎更不要在后端返回{ code: 1, msg: error }这种类似于 REST 风格的包装对象——这完全违背了 tRPC 的类型化错误设计。我早期做 t3code 时就犯过这个错后来统一重构为后端 throw new TRPCError前端用 error.message 展示类型安全又简洁。2.3 Prisma 数据建模的关键点尤其在关联查询和嵌套写入上t3code 和数据库打交道的部分我基本都用 Prisma。Prisma 的模型定义非常直观但有几个细节是新手容易踩坑的。第一外键关系必须双向定义。比如你有一个User和一个Note在 Note 模型里定义了userId作为外键那就必须同时在 User 模型里写notes Note[]否则 schema 校验会直接报错。很多新手只写一边然后发现 Prisma 生成不出来就是这个原因。第二嵌套写入nested write是 Prisma 最爽的功能之一我也是在 t3code 里第一次体会到它的威力。比如你要创建一个带标签的笔记不需要先创建笔记再创建标签再关联可以直接写await ctx.db.note.create({ data: { title: First note, tags: { create: [{ name: typescript }, { name: trpc }] } } })这条语句等价于传统 SQL 里至少三条事务性语句而且 Prisma 会替你处理事务非常安心。第三注意 Prisma 生成的客户端类型是不能直接用于前端 state 的。当你从 tRPC 拿回一个Note[]时如果直接赋给 React statetype 会有一些隐性问题比如 Date 类型没有被序列化。实际上 Next.js 的 tRPC 返回的数据是经过序列化的你拿到的Date已经是一个字符串或数字了。如果你是在服务端组件里用它影响不大但如果是在客户端组件里对它做日期操作最好在返回前就把数据格式转换好不要指望前端处理。3. 实操过程与核心环节实现3.1 新建一个 t3code 项目并配置环境变量五分钟跑通的步骤实际搭建 t3code 项目我一般会严格按下面这套步骤走并且把每一步的关键点都备注出来。第一步初始化项目npx create-t3-applatest t3code-demo交互式选项里面我建议第一次就直接全选NextAuth、Prisma、Tailwind、tRPC。如果你想减少复杂度也可以先不选 NextAuth等后续再加。不过如果你用了 tRPC 的protectedProcedure没有 NextAuth 就表示没有 session 中间件可用体验会打折所以我还是建议一步到位。第二步配置数据库。t3code 默认假定你有一个 PostgreSQL 数据库。如果你本地没装 postgres最快的方案是用 Dockerdocker run --name t3code-db -e POSTGRES_PASSWORDpassword -p 5432:5432 -d postgres:16然后在.env里设置DATABASE_URLDATABASE_URLpostgresql://postgres:passwordlocalhost:5432/t3code-db?schemapublic第三步执行迁移命令npx prisma migrate dev --name init这一步会生成 Prisma Client 并且创建对应的数据库表。接下来跑npm run dev访问localhost:3000你会看到默认的页面已经能访问了。第四步编写自己的第一个 tRPC 接口。在src/server/api/routers/下新建hello.tsimport { z } from zod import { createTRPCRouter, publicProcedure } from ~/server/api/trpc export const helloRouter createTRPCRouter({ getMessage: publicProcedure .input(z.object({ name: z.string().default(World) })) .query(({ input }) { return { message: Hello, ${input.name}! } }) })然后在root.ts里挂载export const appRouter createTRPCRouter({ hello: helloRouter })前端调用import { api } from ~/trpc/react function Hello() { const { data } api.hello.getMessage.useQuery({ name: t3code }) return div{data?.message}/div }能看到这里说明你已经理解了 t3code 的最小闭环后端定义类型化 procedure前端通过 hooks 调用类型自动推导。3.2 给 t3code 加一个数据库驱动的功能模块笔记应用的实现t3code 的示例 hello world 不足以让你感受这套架构的优势所以我用一个笔记模块来演示完整的 CRUD。先在 schema.prisma 里加模型model Note { id String id default(cuid()) title String content String? createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String user User relation(fields: [userId], references: [id]) } model User { id String id default(cuid()) email String? unique notes Note[] }记得在 User 和 Note 之间同时维护关系字段不然prisma migrate会报错。然后写 router 文件note.tsimport { z } from zod import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc export const noteRouter createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) { return ctx.db.note.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: desc } }) }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional() })) .mutation(async ({ ctx, input }) { return ctx.db.note.create({ data: { title: input.title, content: input.content, userId: ctx.session.user.id } }) }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { return ctx.db.note.delete({ where: { id: input.id } }) }) })注意看这里我完全没有手动处理权限也没有手动拼 SQL代码上下文里连session.user.id都是类型推导出来的。这就是 t3code 的典型写法。前端页面调用的时候用NoteList /这样的客户端组件包裹react hook 的数据流非常干净use client import { api } from ~/trpc/react export function NoteList() { const utils api.useUtils() const { data: notes } api.note.list.useQuery() const createMutation api.note.create.useMutation({ onSuccess: () { utils.note.list.invalidate() } }) const deleteMutation api.note.delete.useMutation({ onSuccess: () { utils.note.list.invalidate() } }) return div{/* 渲染逻辑 */}/div }在做这个模块时我最喜欢的细节是invalidate这个 API。它让数据刷新变成声明式的你不需要手动去更新本地缓存只需要告诉 tRPC“这个 query 失效了”它会自动重新拉取。在传统 REST 项目里你需要自己管理 loading 状态、错误状态、缓存更新而 t3code 把这些全部消解了。3.3 服务端调用与客户端调用tRPC 在 Next.js 里的双模式使用很多初学者被 tRPC 在不同渲染场景下的调用方式搞晕了怎么有时候要用api.foo.bar.useQuery有时候又用api.foo.bar.query这里我给出一个最实用的判断方式在 React 组件里用 hooks在服务端组件或者 Server Actions 里用函数调用。例如在服务端组件中import { api } from ~/trpc/server export default async function ServerPage() { const notes await api.note.list.query() return div{/* ... */}/div }但这里有个大坑我必须提醒你如果直接在普通的服务端组件里调用 protectedProcedure你会发现 session 可能是空的。因为服务端组件的默认执行环境里NextAuth 的 session 要显式传入。t3code 里专门提供了一个server/auth的文件来读取 session并且把它注入到 tRPC 的 context 里。在/api/trpc/[trpc]/route.ts里你会看到类似这样的代码const createContext async (opts: CreateNextContextOptions) { const session await getServerAuthSession() return { session, db: prisma } }如果你自己改过结构导致 session 时而有时而无八成是这个 createContext 没有正确传递。我的经验是除非很懂中间件机制否则不要自创 context 的注入方式按脚手架默认的就对了。另外还要提一下查询类的 procedure 在服务端组件中可以直接调用但如果是 mutation尽量还是放在客户端组件里配合 useMutation 使用。原因很简单服务端的 mutation 没有乐观更新机制而且很难处理 loading 状态和错误提示体验差很多。你可以在服务端做初始化查询在客户端做交互变更这种分工在 t3code 中非常自然。4. 常见问题与排查技巧实录4.1 类型报错的排查思路怎么定位是前端类型问题还是后端类型问题t3code 项目的类型报错最常见的来源有两类一是 Prisma 的返回类型和你预期的不一致二是 tRPC 的 input/output 类型没有正确推导。例如前端写api.note.list.useQuery()拿到的notes竟然是个unknown或在data上取属性时报“Property title does not exist on type never”。这种报错基本可以判断不是前端代码问题而是后端 procedure 的返回值类型不够明确。排查路径很简单先去看后端的 procedure比如note.list返回的是ctx.db.note.findMany()。Prisma 的 findMany 返回类型是明确的但如果你的 procedure 外面套了别的函数或者在 router 定义时忘了加 return 语句类型就会坍塌成 never 或 unknown。还有一种情况是 zod 输入校验写得太松导致 input 类型是unknown前端传给.input()时也会报类型错误。我建议始终给 input 一个明确的 zod schema不仅要校验还要定义默认值这样类型推导才稳定完整。遇到类型报错时我调试的顺序是先看后端 procedure 的返回类型再跑到前端的useQuery调用处看 IDE 提示的具体 node_modules 路径。如果提示指向trpc/server内部那大概率是后端定义问题如果指向trpc/react-query内部则可能是 hooks 使用方式问题。这个顺序能帮你快速过滤掉无效排查方向。4.2 种子数据与数据库时区的两个坑t3code 项目开发过程中为了调试方便我通常都要写种子数据。我们用 Prisma 的seed脚本来实现。在package.json里加上prisma: { seed: tsx --env-file.env prisma/seed.ts }或者更官方一点prisma: { seed: node --loader tsx prisma/seed.ts }然后在prisma/seed.ts里写入创建用户和笔记的代码。跑一遍npx prisma db seed后数据库里就有了可直接登录和调用的测试数据。但这里有个我踩过的坑时区差异。如果你的DATABASE_URL里没有加?timezoneAsia/Shanghai之类的参数Prisma 写入的DateTime默认是 UTC。当你前端用new Date(data.createdAt)展示时你会发现比本地时间慢了八个小时。这不是 bug而是默认行为的体现。解决方案有两个要么在 prisma schema 层面就把字段设置为 String存 ISO 字符串但不转换要么在返回给前端前做一次格式化。我更推荐后者因为数据库应该尽可能存储标准时间展示时再做国际化处理。另外一个坑是Prisma 的DateTime类型在 JSON 序列化时会变成一个字符串比如2025-01-15T09:30:00.000Z。如果你需要直接把这个值传给input typedatetime-local这个字符串格式对不上前端会直接报错。所以如果你在项目里做了日期编辑功能记得在 tRPC 的输出里把日期字段转成前端需要的格式不要在 UI 层才试图转换。4.3 部署相关的三个常见问题内存不够、环境变量丢失、Nginx 配置t3code 项目部署到生产环境我用的比较多的是 Vercel 和自有 Node 服务器两种方式。虽然看似简单但有几个问题非常容易踩。第一个坑Node 18 以下的版本跑不了 Next.js 14。t3code 脚手架直接生成的是 Next.js 14要求 Node 至少 18.18 以上。如果服务器上是 Node 16,启动时会报错而且报错信息不够直白。建议在部署前用node -v检查一下。第二个坑环境变量在构建时丢失。t3code 的 tRPC 和 Prisma 很多配置在next build阶段就会被读取尤其是DATABASE_URL和NEXTAUTH_SECRET。如果你在环境变量管理里只配置了运行时变量而构建时变量没有配好典型表现是本地跑npm run build正常但服务器上构建失败错误提示里往往带有PrismaClientInitializationError或Missing NEXTAUTH_SECRET。解决方式是在 CI/CD 或服务器环境里把.env.production文件提前复制到根目录。第三个坑Nginx 或其他反代服务器对 tRPC 长 URL 的默认限制。tRPC 使用的是http://your-domain/api/trpc/* POST 请求体正常情况没问题。但如果你在 Nginx 里配置了非常严格的client_max_body_size比如 1m而你的请求体稍微大一点比如带有大段富文本的笔记创建请求就会返回 413。建议把client_max_body_size设成 10m。另外如果你配置了 CDN 缓存务必绕开/api/trpc路径因为这是一个动态接口路径缓存会导致前端拿到旧数据。这些问题在 t3code 项目里都真实遇到过而且每个问题的报错都容易让人误判。我的解决思路很简单先在本地用生产模式跑一遍npm run build npm run start然后逐一对照环境变量最后再看反代配置。按照这个顺序排查基本没有解决不了的问题。我个人在实际项目中的体会是t3code 这套技术栈真正省下的不是“写代码的时间”而是“对齐类型和排查联调问题的时间”。它把类型安全渗透到全栈的各个角落让开发者可以把问题限定在一个很小的范围内而不是在前后端之间来回跳。如果你正在为全栈项目选型犹豫要不要入 T3 阵营大胆试一次就能感受到这种结构性优势。最后再分享一个小技巧t3code 项目里每次你在schema.prisma里改了模型都建议执行一次npx prisma generate再跑 tRPC 的类型检查。这在传统 ORM 项目里看起来有点多余但在 t3code 里它直接决定了前端 hooks 有没有自动补全。这个小动作做顺手了整个开发流程会非常丝滑。
阅读完成 · 觉得有帮助?