1. t3code 到底在解决什么问题:从一次全栈联调说起
我第一次听说 t3code,是在一次前后端联调被接口文档折磨到崩溃之后。
当时接手的项目结构很典型:Next.js 写前端,Java 后端提供 REST API,两边靠 Swagger 文档对接口。前端改一个字段名,后端没同步更新文档,联调半天,最后发现是类型对不上。还有那种经典的 runtime 错误——后端说userId是 string,前端当 number 用,签名校验不过去,线上直接挂了。那一刻我意识到,真正的问题不是某个字段写错了,而是前后端之间从来没有共享过同一个类型契约。
t3code 这个词,现在基本指代 T3 Stack 这套全栈 TypeScript 技术栈:Next.js 做前端框架,tRPC 做类型安全的接口层,Tailwind CSS 做样式,Prisma 做数据库访问,Zod 做校验,全程跑在 TypeScript 上。它的核心卖点不是"又多了一个新框架",而是把这几个工具绑在一起之后,前端调用后端接口时,类型是直接从服务端"传"过来的——接口改了,前端编译立刻报错,而不是等联调时才发现。
这套东西适合谁?适合那些受够了"接口文档和代码不同步""联调靠口口相传""类型全靠手写 any"的小团队和独立开发者。你不需要单独维护一份 API 类型定义,因为 tRPC 会从 router 定义里自动推导出客户端能调用的所有方法签名。说白了,它就是给全栈开发装了一条"类型管道":服务端定义一次,客户端全链路受益。
这篇文章我不会讲太多概念,主要从我实际搭建、跑通、部署、踩坑的过程出发,把 t3code 里最核心的 tRPC 链路、类型推导边界、生产配置取舍这些东西拆开讲。看完你至少能知道:这套技术栈适合什么项目,不适合什么项目,以及真上手的时候哪些坑是文档里不会写的。
2. create-t3-app 初始化清单与工程结构解读
2.1 脚手架选择:为什么直接吃官方初始化
搭建 T3 Stack 项目,我建议直接跑官方脚手架create-t3-app,不要自己一个一个装依赖。原因很简单:这套技术栈的版本兼容关系非常拧巴。Next.js 13/14 的 App Router 模式、tRPC 的 v10/v11、Prisma 的 client 生成方式、React Query 的版本,这些包各自更新频率都很快,手动拼装很容易遇到"next 版本升级导致 tRPC Server 类型推导失效"这种玄学问题。
初始化命令就一条:
npx create-t3-app@latest my-t3-project交互式问你选哪些模块时,我实际勾选的经验是这样的:
| 模块 | 我的选择 | 理由 |
|---|---|---|
| Next.js | 必须选 | T3 的前端骨架,App Router 模式 |
| TypeScript | 必须选 | 整套类型管道的根基 |
| tRPC | 必须选 | 这是 t3code 的核心,不选就失去意义 |
| Prisma | 看情况选 | 有数据库需求就选,纯前端原型可跳过 |
| Tailwind CSS | 建议选 | 配合 Next.js 写样式效率高 |
| NextAuth.js | 按需选 | 做用户认证再选,会增加配置复杂度 |
| ESLint + Prettier | 建议保留 | 团队协作必留 |
踩过的第一个坑:初始化时如果选了 ESLint,后面 Prisma 生成的查询语句里如果有未使用变量,CI 会被挂掉。这不算 bug,但新手第一次跑prisma db push后立刻撞上 lint 报错,容易懵。我的处理方式是先在eslintrc里把@typescript-eslint/no-unused-vars从 error 改成 warn,等业务稳定后再收紧。
2.2 生成的目录结构:先认清这些文件是干嘛的
脚手架跑完,你会看到一个结构比普通 Next.js 项目复杂一截的目录。核心几个文件必须先弄明白:
src/ server/ api/routers/ # tRPC 路由定义,一个文件对应一组接口 db.ts # Prisma Client 单例(避免开发时热更新重复实例化) api/trpc.ts # tRPC 上下文与路由汇总 app/ api/trpc/[trpc]/route.ts # Next.js 把 tRPC 请求交给该处理函数 _trpc/client.ts # 客户端 tRPC 调用入口 _trpc/server.ts # 服务端可以直接调用 tRPC 逻辑的逻辑入口 trpc/ router.ts # 根路由,把所有子路由聚合 trpc.ts # tRPC 初始化实例这里我特别想强调_trpc/server.ts的作用。T3 Stack 里有个很妙的用法:服务端组件可以直接调用 tRPC 的 procedure,不需要走 HTTP 请求,等于把"业务逻辑层"和"服务端路由"统一了。比如你在一个无客户端交互的页面里要查用户列表,直接:
import { api } from "~/trpc/server"; export default async function UsersPage() { const users = await api.user.list.query(); // 直接渲染,无需任何 fetch }这个能力初看没什么,但它引出 t3code 的一个核心思路:同一套业务函数,既能被 RPC 暴露给远端客户端,也能在本地被服务端直接调用。做 SSR 页面时不用再另写一套数据获取逻辑,很省事。
2.3 初始化后先做三件事再动手写业务
脚手架装完,先别急着写业务代码,我建议按下面顺序做一次"环境验证",能省掉后面大量莫名其妙的问题:
第一,跑一次npm run dev,确认首页能起来。然后打开浏览器控制台看有没有trpc请求报错。正常情况下首页会发一个healthcheck的 query 请求,如果这个请求 500,说明 tRPC 挂在 Next.js 上的路由有问题,先解决这个再继续。
第二,检查 Prisma。如果选了 Prisma,先编辑prisma/schema.prisma加一个最简单的模型,然后跑npx prisma db push,再跑npx prisma generate。这里有个常见坑:db push是直接改库结构的,开发期可以用,但生产环境建议改用prisma migrate deploy,可控性更强。
第三,把src/env.mjs里的环境变量模板填好。T3 Stack 用 zod 做环境变量校验,启动时如果缺变量会直接抛错。这个设计初看烦人,实际救过我好多次——部署的时候漏配DATABASE_URL会立刻报错,而不是页面运行到一半才炸。
3. tRPC 核心链路:从 API 定义到客户端调用的类型闭环
3.1 procedure 的三层结构:query、mutation、subscription
tRPC 的编程模型,说穿了就三类过程:query(查数据)、mutation(改数据)、subscription(订阅推送)。对应前端认知里的 GET、POST/UPDATE/DELETE,以及 WebSocket。
一个最朴素的例子,创建一个 user router:
// src/server/api/routers/user.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/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().min(1), email: z.string().email(), })) .mutation(async ({ ctx, input }) => { return ctx.db.user.create({ data: input }); }), });注意input()里用了 Zod schema。这一步不是多此一举,它同时干了三件事:运行时的参数校验、类型推导的源头、客户端类型提示的依据。你在客户端写api.user.create.useMutation()时,传入的 data 参数类型就是从这里的 Zod schema 推导出来的。
客户端调用长这样:
"use client"; import { api } from "~/trpc/client"; export function UserForm() { const utils = api.useUtils(); const createUser = api.user.create.useMutation({ onSuccess: async () => { // 创建成功后,主动刷新列表的缓存 await utils.user.list.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); const name = e.currentTarget.name.value; const email = e.currentTarget.email.value; createUser.mutate({ name, email }); }}> {/* 表单内容 */} </form> ); }有 React Query 使用经验的人看到这里应该很亲切:tRPC 客户端构建在 React Query 之上,useQuery、useMutation、缓存失效这些概念完全复用。没有 React Query 经验也没关系,你只需要记住三条规则:查数据用useQuery(对应query方法),改数据用useMutation(对应mutation方法),改完数据后用utils.xxx.invalidate()刷新相关查询。
3.2 类型是怎么一路"穿"到客户端的
很多初学者最困惑的就是:客户端凭什么知道服务端 router 里有什么?这就得看 tRPC 的类型系统工作了。核心在根 router 聚合时:
// trpc/router.ts import { userRouter } from "~/server/api/routers/user"; import { postRouter } from "~/server/api/routers/post"; import { createTRPCRouter } from "~/server/api/trpc"; export const appRouter = createTRPCRouter({ user: userRouter, post: postRouter, }); export type AppRouter = typeof appRouter;接着在_trpc/client.ts里:
import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "~/server/api/root"; export const api = createTRPCReact<AppRouter>();createTRPCReact<AppRouter>()这里把服务端的AppRouter类型作为泛型传给了客户端。TypeScript 会在编译期解析AppRouter的完整结构,生成一个"客户端视角的类型地图"。于是你在客户端写api.user.create时,TS 会沿着这个地图找到对应的 mutation 定义,自动关联 input 类型和 output 类型。
这个机制有一个很重要的隐含前提:客户端代码和服务端代码必须在同一个 TypeScript 工程里。如果前后端分仓库、分代码库,tRPC 这套自动类型推导就失效了,你得另想办法共享类型(后面会讲)。这也是 T3 Stack 更适合全栈统一仓库项目的原因。
3.3 遇到类型报错时的排查顺序
实际开发中,类型链路偶尔会断,常见症状是客户端api.xxx下面找不到某个方法,或者参数类型变成any。我总结了一套排查顺序,按步骤来基本能定位:
- 先看 router 文件有没有语法错误或类型错误,跑一次
npx tsc --noEmit。 - 确认根 router 是否聚合了新增的子 router,
appRouter类型是否更新。 - 检查
_trpc/client.ts和_trpc/server.ts引用的AppRouter类型路径是否正确。脚手架默认路径是~/server/api/root,如果你手动改过目录,类型引用很容易断。 - 确认 Next.js dev server 是否还在正常运行。tRPC 类型推导依赖于 TypeScript 编译,有时候 dev server 状态异常,类型错误提示也会变得诡异,重启一下往往就好了。
其中第 4 条最玄学但确实存在。我在一次把trpc目录移动到别的位置后,dev server 一直报找不到模块,重启后才恢复。这类问题不是代码逻辑错了,而是增量编译缓存没跟上目录变更,遇到别死磕,先重启。
4. 前后端类型共享的三个边界场景与实战解法
4.1 场景一:服务端组件里直接调用 tRPC,绕过 HTTP
前面提到_trpc/server.ts可以让服务端组件直接调用 procedure。但这里有个边界条件很多人不知道:服务端调用走的是"直接执行"路径,不会经过 HTTP 的 input 校验?不对,走的是同一个 procedure 定义,所以 input 校验依然生效。真正需要注意的问题是上下文(context)差异。
tRPC 的 context 是通过创建实例时的createContext函数生成的,通常包含db实例和当前用户信息。HTTP 请求进来时,Next.js 会把req传给createContext,你从里面解析 session 拿到当前用户。但服务端组件直接调用时,没有req,context 如何解析 session 就是一个需要处理的差异。
我的处理方案是在createContext里做兼容:
export const createTRPCContext = async (opts: { headers?: Headers; session?: Session | null; }) => { const session = opts.session ?? (await getServerSession()); return { db, session, }; };服务端调用时,可以显式传入 session;HTTP 场景下走默认的getServerSession()。这个改造初看不复杂,但它在"同构调用"场景里是必须的,否则服务端组件里拿不到当前用户,权限校验全失效。
4.2 场景二:跨代码库共享类型怎么办
如果你的项目确实前后端分离,两个仓库,tRPC 自动推导就用不上了。此时有三条路:
第一,把 router 定义抽成一个独立的 npm 包,前后端都依赖它。服务端引入这个包来创建 router,客户端引入这个包里的AppRouter类型。这需要在仓库层面做 monorepo 改造,推荐用 pnpm workspace 或者 turborepo。
第二,用 tRPC 的 OpenAPI 插件把 router 导出为 OpenAPI 文档,后端维护文档,前端用openapi-typescript生成类型。这其实回到了传统的"文档驱动"路线,只是文档由代码生成,不会出现手写文档和代码不一致的问题。
第三,RPC 层改用 gRPC 或 GraphQL,它们天然支持跨语言的 schema 共享。但这就跳出 t3code 的范畴了,属于架构选型问题。
我的建议:如果团队规模不大、项目边界清晰,优先考虑 monorepo 方案。t3code 的真正价值在于"代码即契约",一旦拆到两个仓库,这个价值就打折了,不如一开始就选 monorepo 结构。
4.3 场景三:Zod schema 复用与前后端校验一致性
tRPC 的 input 校验用的 Zod,而服务端数据库层往往也需要同样的校验。T3 Stack 里,同一个 Zod schema 可以被 router input、表单校验、数据库写入前校验共用。
我曾经在项目里犯过一个错误:数据库字段要求email唯一,我在 router 的createprocedure 里只做了z.string().email()校验,没做唯一性校验。结果客户端传入已存在的邮箱,Prisma 直接抛PrismaClientKnownRequestError,错误码P2002。前端拿到的是一坨 500 错误,根本没法提示用户"邮箱已存在"。
后来我在 mutation 里加了显式检查:
create: publicProcedure .input(userCreateSchema) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.user.findUnique({ where: { email: input.email }, }); if (existing) { // 抛出可预期的业务错误,tRPC 会把它序列化成可读的错误信息 throw new TRPCError({ code: "CONFLICT", message: "该邮箱已被注册", }); } return ctx.db.user.create({ data: input }); })TRPCError是 tRPC 的标准错误类型,客户端能通过error.data?.code判断错误类别。这套机制用熟之后,前端错误提示就能统一维护,不需要在客户端再写一层 if-else 去猜后端返回的 HTTP 状态码。说到底,类型共享不只是"类型长得一样",更应该是"错误语义也一致"。
5. 部署生产环境时的配置取舍与踩坑记录
5.1 构建期的两个经典坑:环境变量校验与 Prisma 生成
T3 Stack 项目部署到常见平台时,最容易在构建阶段就挂掉。第一个坑就是前面提到的env.mjs里的 Zod 校验。你把项目传到 Vercel 或自己的 CI 服务器时,如果.env.production没配置完整,next build阶段就直接报错退出。这其实是设计如此,但很多人第一次部署时会被"问懵"。解决方式很简单:把DATABASE_URL这些必填变量在部署平台的环境变量面板里配置好。注意db push不要在生产环境跑,要用prisma migrate deploy,并且构建脚本里记得加上prisma generate。
第二个坑是 Prisma client 的生成时机。如果你的 Docker 镜像构建过程是"多阶段构建",先装依赖再跑 build,很容易漏掉prisma generate。我见过最典型的情况:本地一切正常,容器里一跑就报"Cannot find module '@prisma/client'或者 query engine 缺失"。解决方式是在 Dockerfile 里明确执行:
RUN npx prisma generate && npm run build另外注意 Prisma 5.x 之后,query engine 默认走library模式,某些 Alpine 基础镜像上会出现glibc兼容问题。建议直接用node:20-slim而不是node:20-alpine,能省不少排查时间。
5.2 生产环境该不该开 tRPC 的 batch 请求
tRPC 默认支持 HTTP Batch:浏览器会在一个请求里合并多个 query,减少往返次数。这在开发环境很爽,但生产环境有个隐患——如果把这些请求打到传统的日志系统或者网关层,合并后的 body 格式和普通 JSON 不一样,网关解析不了,会直接 4xx。
我实际遇到的情况是:公司网关要求 HTTP body 是标准 JSON 结构,而 tRPC batch 请求的 body 是一个数组,网关校验失败。后来我在 tRPC 配置里关掉了 batch:
// app/api/trpc/[trpc]/route.ts const handler = createTRPCNextAppRouterHandler({ router: appRouter, createContext, // 关掉 batch responseMeta: undefined, batching: { enabled: false, }, });如果你们的部署环境没有这种限制,保留 batch 是没问题的,能显著减少请求数量。但做中间件、网关或日志系统的人要提前知道 body 格式差异,否则排查线上问题时会一头雾水。
5.3 缓存策略:React Query 的缓存其实没那么简单
tRPC 集成 React Query 之后,很多人会以为"服务端返回的数据会自动缓存",实际上 React Query 的默认缓存策略是宽松的:默认staleTime为 0,表示数据一拿到就视为过期,切页面后再回来会重新请求。这在严格意义上不算 bug,但在真实业务里,一个数据不怎么变的面板页面,每次都发起请求挺浪费的。
我一般会在客户端入口统一设置默认的staleTime:
// _trpc/client.ts export const api = createTRPCReact<AppRouter>({ queryClientConfig: { defaultOptions: { queries: { staleTime: 60 * 1000, // 1分钟内的请求直接走缓存 }, }, }, });但这引出一个新问题:如果数据在 1 分钟内被别的用户改了呢?客户端看不到最新数据。所以staleTime要根据业务敏感度来定。我的经验是:列表、详情这类展示数据给 30s~1min 没问题;涉及用户账号、支付状态的接口,staleTime设为 0,并且配合服务端 mutation 后的invalidate联动刷新。这个权衡思路,比背什么"缓存默认值"有用得多。
5.4 部署后的监控与错误定位
t3code 项目跑起来之后,错误定位和传统项目有区别。因为 tRPC 的所有请求都走同一个 Next.js 路由/api/trpc/[trpc],你没法像看路径那样直接看出来是哪个业务接口挂了。日志里看到的往往是:
POST /api/trpc/user.create?batch=1如果没用 batch 还好,用了 batch 的话,一次请求里可能混着好几个 procedure,要准确知道是哪个 procedure 报错,得看响应体的结构。我的做法是给每个 procedure 里的关键操作加结构化日志,输出 procedure 名称和 input 摘要。router.meta也是可以放业务标识的地方,方便在日志系统里做筛选。
另外一个容易被忽略的点是TRPCError的cause。tRPC 序列化错误时,默认只把 message 和 code 传给客户端,cause里的原始异常堆栈不会暴露出去,这是好事,避免泄露数据库内部信息。但这也意味着,服务端日志必须完整保留原始错误,否则你在日志系统里看到的只是一句"邮箱已被注册",没有底层堆栈,排错效率极低。我习惯在 mutation 的 catch 里统一打日志:
try { // 业务逻辑 } catch (err) { logger.error({ err, input }, "user.create failed"); throw err; }6. 跑通之后回头看:t3code 到底改变了什么
6.1 类型闭环对团队协作习惯的重塑
说一个不太容易在文档里看到的感受。当你的项目跑通 t3code 这条链路后,前后端联调这件事会发生非常微妙的变化。
以前我们对接接口,靠的是"文档 + 约定 + 追问"。改一个字段,要发消息告诉对方;对方改了代码,也不一定同步文档。现在直接改 router 里的 Zod schema,全项目重新编译,任何引用这个字段的地方全部报错。这种"编译器当裁判"的体验,一开始会让习惯了宽松协作的人有点别扭,但适应之后,你会慢慢减少对"沟通成本"的依赖。
我部门里有个前端同事第一次接触 tRPC,他说了一句让我印象很深的话:"以前接口文档写错了,我要排查老半天;现在类型错了,编译器直接告诉我。"这句话基本概括了 t3code 的核心价值:把大量的隐性契约变成了显性的代码约束。
6.2 什么时候不适合这套技术栈
说完了好处,也得泼点冷水。t3code 不是银弹,我在实践中发现四类场景用它反而难受:
第一,需要开放 API 给第三方开发者。tRPC 的类型安全是 TypeScript 世界的自嗨,第三方用 Python、Go 接你的接口,拿到的是 JSON,一点类型推导都没有。这种情况下,REST + OpenAPI 或者 GraphQL 更合适。
第二,接口数量极大且访问模型复杂。tRPC 的 router 嵌套可以做模块化,但如果你有几十个服务、几百个接口,且跨服务之间还要共享 router 类型定义,维护成本会迅速上升。单仓库的规模边界一旦被突破,整套方案就要重新考量。
第三,团队没有 TypeScript 基础。t3code 全链路依赖 TS 类型系统的精细配合,如果团队还在"能跑就行"的阶段,这套技术栈带来的威慑力大于生产力。让不熟悉泛型的同学去理解createTRPCReact<AppRouter>()的推导逻辑,是真的会劝退的。
第四,项目需要严格的接口版本控制。tRPC 没有内置的版本化机制,router 结构一变,客户端旧版本可能直接类型报错。这跟 REST API 的/v1、/v2版本管理思路完全不同。面向长期演进的多客户端场景,需要你自己设计版本兼容策略。
6.3 我现在的选型框架
手里拿着 t3code 这把锤子,我现在的判断标准很简单:
如果是内部工具、创业项目原型、中小规模的全栈应用,前端网页是唯一或主要入口,团队全员 TypeScript——这套技术栈非常香。它能让你把写接口、写前端、改类型这三件事揉成一件事,交付速度明显提升。
如果是对外开放的 API 平台、多语言客户端项目、服务拆分特别细的微服务架构,我会建议回到 REST/OpenAPI 或者 GraphQL,用独立的 schema 管理来做契约。
6.4 一个小技巧:用 tRPC 的 middleware 做权限收敛
最后分享一个我在业务里用得最多的模式:tRPC 的 middleware。它和 Express 的中间件概念很像,但是因为有了类型推导,做权限控制时非常顺手。
比如我只想给"管理员"角色开放删除用户的能力,可以这样写:
const adminProcedure = publicProcedure.use(({ ctx, next }) => { if (ctx.session?.user.role !== "ADMIN") { throw new TRPCError({ code: "FORBIDDEN" }); } return next(); });然后定义 router 时,用adminProcedure代替publicProcedure:
delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.user.delete({ where: { id: input.id } }); }),这个做法的好处是权限逻辑集中在 middleware 里,不用在业务代码里到处if (!isAdmin)。而且客户端调用的类型提示也会变严格——虽然它不会在编译期阻止普通用户调用,但至少服务端每次都会拦截。我后来把公司内部一个后台的所有写操作都收敛到这类受保护的 procedure 上,上线后权限类安全事故基本清零。
说到底,t3code 这个名字背后不是某个单一的新技术,而是一种"把类型契约贯穿前后端"的工程思路。它解决的问题很具体,代价也很具体。你在决定用不用之前,先想清楚自己的项目是不是它想服务的那类场景就行。