1. 为什么搞 t3code
1.1 T3 Stack:它不是框架,而是一套组合拳
先说结论:t3code 是我最近一段时间在维护的一个全栈项目代号,它的根子是现在社区里热度一直没降下去的T3 Stack——也就是 Next.js + TypeScript + Tailwind CSS + tRPC 这一整套组合。
很多人第一次听到 T3 Stack 会以为它是个新框架,其实它没有发明任何东西,它的价值纯粹在于“选型组合”。用 T3 的思路搭项目,你拿到的不是一个像 NestJS 那样的重型全家桶,而是一套轻量、类型安全、前后端高度耦合的工具链。t3code 这个名字是我给自己这套实践起的代号:T3 为主,code 是落地的代码,整条链路从数据库建模到前端页面调用,全部由 TypeScript 的类型约束贯穿起来。
我在这个项目里实际要做的事,是把一个带用户体系、动态列表和后台简单管理的 Web 应用,用 T3 栈从零跑通。用 T3 最大的直观感受是:写后端接口不再需要手动维护一个 API 文档,也不需要在前后端之间反复对齐字段名。因为 tRPC 会把路由的类型自动暴露给前端,后端改了一个返回字段,前端编译立刻报错。这种“类型即文档”的体验,一旦用过就很难回去。
1.2 我在这个项目里踩过的选型坑
不吹不黑,T3 不是万金油,选它之前我翻过不少车。早期我用传统前后端分离方案做过类似项目:后端 Node + Express,前端 React,中间通过 REST 接口通信,光维护接口文档就花了大量时间。后来换过 GraphQL,确实灵活,但很多业务并不需要那个灵活度——一批简单的 CRUD 接口,GraphQL 引入的 resolver 和 codegen 反而增加了不少心智负担。
做 t3code 的时候我彻底换了个思路。因为项目本身是单体 Web 应用,不需要对外提供多端 API,让前后端共享同一份类型定义是最划算的选择。T3 栈在这种场景下几乎是量身定做:Next.js 负责页面和服务端渲染,tRPC 代替 REST 做内部接口,Prisma 负责数据库访问,再加上 Tailwind 处理样式,一层层类型全部打通。
这个项目适合谁参考?我认为是这几类人:第一类是刚接触全栈但不想被复杂工程化劝退的开发者,第二类是已经在用 Next.js 但觉得前后端交互类型很松散的人,第三类是纯好奇 tRPC 到底好在哪、想找一份真实可跑代码的人。如果你现在手上在做的是纯后端 API 服务、要对外开放接口给第三方,那 T3 反而未必合适,这一点我后面会专门展开说。
2. 架构设计:类型安全应该贯穿到哪一层
2.1 从数据库到浏览器,类型是怎么一路畅通的
t3code 的架构核心,一句话讲清楚就是:以类型为轴,数据库是起点,浏览器是终点,中间不允许出现任何裸奔的无类型地带。
我用的是一个非常典型的链路:Prisma Schema 写数据库模型 → Prisma Client 操作数据库 → tRPC Router 暴露业务方法 → 前端 React Hook 消费类型化接口。这里面最关键的一环是 Prisma。因为 Prisma 会基于 schema 文件生成完整类型,比如我给一张Post表定义了字段,生成的Post类型就会自动带上所有字段和关系。这个类型接着被 tRPC 的 procedure 引用,最终通过 tRPC 的泛型机制“透传”到前端的useQuery调用上。
打个不严谨但好懂的比方:这就像你在一根水管的最末端装了传感器,水龙头一开,水质数据直接同步显示到仪表盘。中间不用人工抄表、不用写二次编码转换。字段从数据库流到浏览器,全程是同一份类型定义在背书。
很多项目做不到这个效果,问题通常出在“链路中段断裂”——比如后端写死一个 DTO 对象、前端自己又声明了一遍interface。两边没有建立校验层,数据库字段一变,前端还在傻傻地引用旧字段。t3code 在结构上杜绝了这种情况:你改 Prisma schema,然后重新生类型,接下来所有用到这个模型的代码都会响应式地编译报错。
2.2 tRPC 路由设计该保持多少粒度
路由设计是 t3code 里我花心思最多的地方。很多第一次用 tRPC 的人容易犯一个毛病:把每个数据库操作都做成一个独立 procedure,结果路由数量和 REST 接口一样多,换个皮而已。
我在 t3code 里的做法是按“业务动作”聚合,而不是按“数据表”拆分。比如用户列表页需要拿用户信息和他的最近动态,我会在user路由下写一个getProfile查询,内部用 Prisma 的关联查询一次搞定。如果按 REST 习惯来,你大概得先调/api/user/1,再调/api/post?authorId=1,前端还要自己并发处理。tRPC 里一次性取回来,既省网络请求,又不用前端自己组装数据。
还有一个值得注意的细节:输出类型不要直接暴露数据库模型的全部字段。用户表里如果有密码哈希、内部备注这种字段,你直接从 router 里 return Prisma 对象,类型就会跟着泄露给前端。我在 t3code 的 router 层做了一个极简选择函数,用 Prisma 的select白名单控制返回字段,比在控制器里 return 之后再做一次序列化过滤要稳得多。
2.3 项目目录与模块组织
T3 Stack 的官方脚手架create-t3-app已经把目录结构铺得很合理了,但我实际跑下来还是做了一些调整。默认结构下server/api/routers是放路由的地方,我把每个业务域拆成一个文件,比如user.ts和post.ts,在root.ts里用mergeRouters汇总。这样做的直接好处是定位代码很快,收新需求时不需要在巨型 router 文件里翻半天。
前端页面我尽量保持“页面薄、逻辑集中在自定义 hook 里”的原则。t3code 的页面组件不直接调api.post.getAll.useQuery(),而是在外层封装一个 hook,比如usePostList,把查询、缓存失效、参数校验逻辑都收进去。有点 React Query 经验的人应该能理解这个做法:页面只负责渲染,数据逻辑交给 hook,后面增加乐观更新或分页,动的只有 hook 内部。
另外环境变量和配置我也单独整理过。DATABASE_URL、NEXTAUTH_SECRET这类不该提交到仓库,默认的.env模板保留了入口,但我会在 README 里把每个变量的用途写清楚,避免团队协作时有人直接复制别人配好的本地数据库地址。
3. 实操过程:从空目录到跑通一个完整功能
3.1 初始化 T3 项目
如果你现在已经决定试一把,我先带你过一遍 t3code 的初始搭建过程。用官方脚手架创建项目:
npx create-t3-app@latest t3code命令行会问你选哪些模块。我这次选的是 Next.js(App Router)+ TypeScript + Tailwind CSS + tRPC + Prisma + NextAuth。如果你暂时用不到登录,可以先把 NextAuth 拿掉,后面需要再补,脚手架支持按需选择。
创建完成后目录结构大概是这样的:
t3code/ prisma/ schema.prisma src/ app/ api/trpc/[trpc]/route.ts layout.tsx page.tsx server/ api/ routers/ root.ts trpc.ts db.ts auth.ts trpc/ server.ts client.ts react.ts整个初始化过程通常几分钟就好,速度取决于网络。跑起来用npm run dev,默认在localhost:3000访问。这里有一个小提醒:T3 脚手架默认会开严格模式的 ESLint,如果你之前写代码不太在意no-explicit-any这类规则,刚开始可能会被一堆红色波浪线搞得心烦。但我不建议立刻关掉规则,先忍几周,你会发现自己的代码质量有明显的提升。
3.2 用 Prisma 定义数据模型
t3code 这次的功能里需要一张文章表和一张用户表,用户和文章是一对多关系。prisma/schema.prisma里的核心模型是这么写的:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } 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 }写好 schema 之后执行一次迁移:
npx prisma migrate dev --name init这条命令会在数据库里建表,同时生成 Prisma Client。很多第一次用 Prisma 的人会忽略的是:改完 schema 后需要重新生成客户端,而migrate dev会自动触发prisma generate,所以一般情况下你不必单独执行。但如果你改了 schema 又不想立刻做迁移,至少要跑一次npx prisma generate,否则程序里会一直用旧类型。
3.3 编写第一个查询路由
tRPC 的代码集中在src/server/api/routers/post.ts。我写一个查询已发布文章的列表,并附上作者基本信息:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ listPublished: publicProcedure .input( z.object({ limit: z.number().min(1).max(50).default(10), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const posts = await ctx.db.post.findMany({ where: { published: true }, take: input.limit + 1, orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); let nextCursor: string | undefined; if (posts.length > input.limit) { const nextItem = posts.pop(); nextCursor = nextItem?.id; } return { posts, nextCursor }; }), });这里有两个实操要点。第一,分页用的是游标而不是页码,因为游标分页在数据持续写入时不会出现“翻页重复或漏条”的问题,性能也更稳定。第二,include里嵌套了一个select,这样文章列表接口不会把用户手机号、邮箱这些敏感字段带出去。input 里我用了 Zod 做运行时校验,limit最大限制到 50,防止有人传一个limit: 999999把数据库拖垮。
接着在root.ts里注册这个路由:
import { createTRPCRouter } from "./trpc"; import { postRouter } from "./routers/post"; import { userRouter } from "./routers/user"; export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, }); export type AppRouter = typeof appRouter;AppRouter这个类型是整个类型安全链路的起点。前端引到它之后,所有useQuery和useMutation都能自动推断出输入输出格式。
3.4 前端页面调用与状态串联
前端调用不需要手写 fetch URL,也不用声明接口响应类型。t3code 的页面里,调一个列表接口大概是这个样子:
"use client"; import { api } from "~/trpc/react"; export default function PostListPage() { const { data, isLoading, fetchNextPage, hasNextPage } = api.post.listPublished.useInfiniteQuery({ limit: 10, }); if (isLoading) return <div>加载中...</div>; return ( <div> {data?.pages.map((page) => page.posts.map((post) => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> <span>作者:{post.author?.name ?? "匿名"}</span> </article> )) )} {hasNextPage && <button onClick={() => fetchNextPage()}>加载更多</button>} </div> ); }useInfiniteQuery是 tRPC + React Query 结合的产物,它会自动把上一个 query 返回的nextCursor作为下一个 query 的cursor传进去,不需要你手动接。这在开发动态 feed 流的时候相当省事。
有一点要特别提醒:服务端组件里不能直接调用useQuery这类 hooks,只能在"use client"客户端组件里用。如果你在 App Router 的 server component 里尝试调用api.post.listPublished.useQuery(),会直接报错。T3 官方其实提供了api.post.listPublished.fetch()这种在服务端直连 tRPC 的写法,适合在服务端组件里取初始数据,然后在客户端组件里继续用 hook 做交互。
3.5 构建部署要留意的三个点
t3code 的部署我用的是 Vercel,配合一个外部的 PostgreSQL 实例(本地开发用 Docker 起一个 Postgres)。第一次部署遇到的坑还挺典型。
第一个是环境变量。Vercel 项目设置里必须把DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL全部配好,否则构建会成功但页面一拉数据就 500。NEXTAUTH_URL在本地是http://localhost:3000,线上要变成你的真实域名,这个很多新人容易漏。
第二个是数据库网络隔离。如果你用的是云数据库,记得把 Vercel 的函数部署 IP 加入数据库白名单,或者直接开启公共网络直连(需谨慎),否则线上环境连接数据库会超时。本地跑得好好的、一上生产就超时,这种情况九成是网络策略问题。
第三个是构建命令。默认脚手架已经写好了build脚本,内部会执行prisma generate。但如果你手动调整过脚本,要注意顺序:先 generate 再 next build,否则 Prisma Client 没生成好,构建会直接失败。我见过有人把prisma generate放到了 build 之后,结果每次上线都白屏,排查半天才发现是这个顺序问题。
4. 常见问题与排查实录
4.1 热更新失效或页面空白报错
t3code 开发过程中最常碰的怪问题,是改了 Prisma schema 之后,npm run dev进程没有退出,然后页面开始报PrismaClient is not defined,或者直接白屏。原因很简单:prisma generate生成的代码和当前运行中的 Next.js dev server 状态不同步。
解决办法是改完 schema 后,先重新生成 Prisma Client,再重启 dev server:
npx prisma generate npm run dev看起来有点笨,但确实比让 dev server 自己 hot reload 稳定。如果你用的是 pnpm,偶尔还会遇到 Prisma 引擎二进制和 Node 版本不匹配的问题,此时删掉node_modules/.prisma再重新npm install基本都能解。
4.2 tRPC 泛型报错:看起来像玄学的问题
tRPC 类型推断非常强,强到有时候错误信息写得像天书。我遇到最多的报错是Argument of type ... is not assignable to parameter of type ...,发生在前端给 procedure 传 input 时。排查步骤就三步:
第一步,看 input 的 Zod schema 是否定义了.default(),如果有默认值,调用时对应的字段是可选的,但你传了undefined可能触发严格模式报错。第二步,检查是否在某个中间件里对 input 做了 transform,转换后的类型和 Zod 声明的输出类型不一致。第三步,确认前端引的api对象和 router 的引用路径是对的——这个看起来荒谬但确实发生过,有人从~/server/api/root直接引了 router 类型,而不是从~/trpc/react引创建好的客户端。
有一个通用技巧:把鼠标悬停在报错表达式的变量类型上,理一理它到底被推断成了什么。tRPC 的报错本质上是 TS 泛型推导链路上某个环节的类型没有匹配,把类型“解剖”出来,基本就能定位到问题在哪一环。
4.3 Prisma 类型被缓存,改了模型没生效
这个问题和第一个热更新问题类似,但更容易迷惑人。现象是:改了schema.prisma,也执行了prisma migrate dev,但前端代码里引用新字段还是编译报错。原因是 IDE 的语言服务还缓存着旧的类型定义。
最省事的解法是重启 TypeScript 服务。在 VS Code 里按Cmd/Ctrl + Shift + P,输入TypeScript: Restart TS Server,一般立刻就正常了。另外如果你是 monorepo 结构,要确认tsconfig.json里的paths配置没问题,免得 IDE 从错误的路径解析到旧的 Prisma 类型定义。
4.4 这套方案不适合的场景
我踩了不少坑之后必须承认,T3 栈有明确的能力边界,不是所有项目都适合闭眼上。如果你要做一个开放 API 平台,把接口给别的团队或第三方客户端调用,tRPC 这套“前后端共享类型”的模型是行不通的——外部调用方根本不在你的 TypeScript 编译器控制范围内。这时候老老实实按 OpenAPI 规范做 REST,或者用 GraphQL 暴露 schema,才更合适。
另外,如果你的团队前后端是完全分开的两个小组,后端写业务、前端只看接口文档,那么 T3 的核心优势也会大打折扣。tRPC 的妙处建立在“同一份代码库、同一套语言、类型直接流动”的前提上。一旦组织上跨了团队边界,共享类型就成了昂贵的耦合。所以我的建议是:T3 适合小而精的全栈团队或独立开发者,不适合组织强隔离的大型团队。
5. 后续还能往哪里走(个人体会)
t3code 目前已经跑通了一个比较完整的功能闭环,但我不打算把它止步于一个“能跑的 demo”。作为个人项目,下一步我想做两件事:一件事是把 NextAuth 的登录逻辑再往下沉淀,做成一个更通用的权限封装,让protectedProcedure能基于角色做更细粒度的控制;另一件事是把现有的 Prisma 查询用得更狠一点,比如做全文搜索、多条件筛选,而不是所有查询都停留在简单findMany。
回头看这次项目,最大心得倒不是某个技术细节,而是 ——类型安全这件事,靠约定是守不住的,必须靠工具链路强制。t3code 的价值不在于我写了多少行代码,而在于它让我验证了一件事:当类型从数据库一路通到浏览器,那种“改一处、全链路报错提醒”的开发方式,才是真正适合中等复杂度全栈应用的生产力形态。
如果你也想拿同一个思路跑一遍,我建议直接 clone 一个 create-t3-app 的模板,从一个最小的 CRUD 开始改,而不是花大把时间看文档。跑通一篇博客列表、加一条记录、刷新看效果,这三个动作做完,你对 T3 栈的感知会比看任何教程都来得真实。