t3code 这个名字,第一次听可能觉得有点“编码器/工具类项目”的味道。实际它就是一个围绕 T3 Stack 做全栈开发的工程化实践,把 TypeScript、tRPC、Tailwind CSS、Next.js 这几个技术绑成一套开发模板。我做这个项目最主要的原因,是受够了“前后端各自维护类型、联调靠手写接口文档、改个字段要搜遍前后端”这种传统的开发方式。t3code 解决的核心痛点,就是让类型安全从数据库一路通到前端按钮,把原本前后端分离开发中“对接口、对类型、对文档”的体力劳动,压缩到几乎为零。
如果你是那种天天写 REST API、每个接口都要在 Postman 里试半天、TypeScript 项目里还用一堆any的开发者,或者你正打算从零搭建一个全栈项目却不知道技术栈怎么选,这篇文章值得你看完。我会把 t3code 的整体设计思路、核心机制的实现细节、完整跑通流程,以及我实际踩过的坑全部整理出来。内容偏实战,代码会直接给出,你只需要一个能跑 Next.js 的 Node 环境就能跟着做一遍。
1. t3code 的整体设计与技术选型
1.1 项目到底想解决什么问题
先说个场景。以前做全栈项目,典型流程是后端先定数据库表,再写路由接口,然后输出一份接口文档;前端拿到文档之后,自己定义一遍请求函数,再手动声明一遍响应类型。整个过程有两处最大问题:一是接口文档永远滞后于代码,前后端人员对字段名的理解经常有偏差;二是类型安全在边界上断掉了——后端改了字段,前端根本不会报错,只有等到运行时才发现。
t3code 的思路很简单:既然前后端都用 TypeScript,那就让类型定义成为唯一事实来源,API 调用直接复用同一套类型。这样后端加字段,前端立刻得到类型更新;后端改字段,前端编译直接报错。这个效果听起来很理想,但传统 REST 架构很难做到——毕竟 HTTP 协议本身不带类型信息,只能靠 OpenAPI 生成或者手动同步,做不干净。而 t3code 换了一条路:不走 REST,走 tRPC。
tRPC 是 T3 Stack 的核心粘合剂。它不发明新的数据协议,而是让你直接调用服务端函数,所有输入输出都走 TypeScript 类型推断。你不再需要定义 URL、不再需要设计请求体、不再需要反复调试“请求格式对没对”,前端直接像调用本地模块一样调用后端逻辑,而整个过程依然是经过 HTTP 的真实网络请求。这套方案在中小型全栈项目中非常实用,减少的胶水代码量非常可观。
1.2 为什么是 T3 Stack,而不是其他组合
做技术选型时,我纠结过几个方向:纯 Next.js API Route + SWR、NestJS + REST、GraphQL + Apollo,最后还是选定了 T3 Stack。理由逐个说给你听。
- 纯 Next.js API Route + SWR:能解决一部分问题,但类型安全依然要靠手动维护。API Route 只是帮你省了路由样板代码,类型边界还是断的,前端依然要重复声明类型。
- NestJS + REST:适合大型后端项目,Dependency Injection 那一套体系很完整,但它更像一个后端框架,前端要自己接过来,类型联动还是需要额外生成工具。对中小项目来说太重。
- GraphQL + Apollo:类型安全确实不错,Codegen 出来一堆类型,但代价是团队必须维护 Schema、写 Resolver、布缓存,学习成本明显偏高。一个人开发时很爽,多人协作时约束太多。
- T3 Stack:Next.js 管页面和 SSR,tRPC 管调用链,Zod 管运行时校验,Prisma 管数据库,Tailwind 管样式,整套组合把“类型安全”作为一等公民来设计。不用写文档,不用定义类型,不会出现两端不一致。
所以 t3code 的定位不是一个“万能脚手架”,更像是一个刻意收敛技术栈、把类型安全推到极致的中型全栈起手模板。如果你认同“宁可多花半小时设计类型,也不要花三天排查联调问题”这个理念,这个技术路线会很适合你。
1.3 核心技术栈一览
| 模块 | 技术 | 作用说明 |
|---|---|---|
| 应用框架 | Next.js(App Router) | 负责 SSR、路由、页面结构 |
| API 层 | tRPC v10/v11 | 前后端类型安全的远程调用协议 |
| 校验层 | Zod v3 | 请求参数、环境变量、配置对象的运行时校验 |
| 数据库 | Prisma ORM | 类型安全的数据库访问(本项目用 SQLite 做演示) |
| 样式 | Tailwind CSS | 原子化样式方案,配合 UI 快速迭代 |
| 语言 | TypeScript(strict 模式) | 全项目类型约束的根基 |
| 代码规范 | ESLint + Prettier | 统一代码风格,配合 t3 的官方配置 |
表格里的每一项都不是随便加的。尤其是 TypeScript 的 strict 模式,t3code 的整个类型安全大厦都建立在它之上——如果strict不开,null会渗透进每一层调用,tRPC 类型推断会逐渐失效。
2. 核心机制解析:tRPC 的端到端类型安全怎么实现
2.1 目录结构与职责划分
t3code 的项目目录沿用了 T3 Stack 的开发规范,但它有一个明显特点:模块边界非常清晰。核心结构长这样:
src/ pages/ # Next.js 页面 api/ trpc/ [trpc].ts # tRPC 的 HTTP 入口 index.tsx server/ api/ routers/ post.ts # 业务路由 user.ts root.ts # 路由聚合 db.ts # Prisma client 单例 trpc.ts # tRPC 初始化、中间件、公共流程 utils/ api.ts # 客户端 tRPC 封装 zod.ts # 全局 Zod schema styles/ globals.css这个结构的关键在于server/和utils/的分工。server/trpc.ts里创建的 tRPC 实例只属于服务端,里面有鉴权中间件、日志中间件、错误处理逻辑;而utils/api.ts是客户端入口,它只包含类型导入和请求工具的组装,不会把服务端逻辑暴露出去。这个隔离非常重要,避免把鉴权密钥之类的服务端代码打进前端包。我在早期版本里犯过这个错误——把所有 tRPC 初始化都放在一个共享文件里,结果用户 token 被编译进了客户端 bundle。后来强制把server/和utils/分开,从根源上杜绝了这个问题。
2.2 tRPC 初始化时的上下文设计
tRPC 的所有请求都会经过一个 context。它就像每个请求的地基——你可以在里面塞数据库实例、当前用户信息、请求头等数据。t3code 里常见的写法是这样:
// src/server/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { ZodError } from "zod"; import { prisma } from "./db"; type Context = { prisma: typeof prisma; userId: string | null; }; export const createContext = async ({ req, }: { req: NextApiRequest; }): Promise<Context> => { const token = req.headers.authorization?.replace("Bearer ", "") ?? null; const userId = token ? await getUserIdFromToken(token) : null; return { prisma, userId, }; }; const t = initTRPC.context<Context>().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, message: error.cause instanceof ZodError ? error.cause.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ") : shape.message, }; }, }); export const router = t.router; export const publicProcedure = t.procedure;这里有一个很容易忽略的细节:createContext是在服务端执行的,它接收 Next.js 的req,因此可以拿到headers、cookies等请求信息。所有 router 都能通过ctx.userId判断当前用户身份。我在实际项目里还加了一层数据库查询的中间件,把用户信息缓存在 context 里,避免每个接口都查一遍数据库:
export const authedProcedure = publicProcedure.use(async ({ ctx, next }) => { if (!ctx.userId) { throw new TRPCError({ code: "UNAUTHORIZED" }); } const user = await prisma.user.findUnique({ where: { id: ctx.userId }, }); if (!user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, user, }, }); });这样,凡是走authedProcedure的接口,在业务代码里直接可以通过ctx.user拿到完整用户信息,不用每个 router 都去查一遍。刚开始你觉得写 context 很抽象,但用久了就会发现,这其实是整个 t3code 里最值得花时间打磨的部分——中间件、鉴权、错误格式化全都可以在这一层统一处理。
2.3 Zod 校验与类型推断的联动
在 t3code 中,每个 router 函数的输入参数都要求在 router 声明时用 Zod 定义 schema。一个典型的 tRPC mutation 写法如下:
// src/server/api/routers/post.ts import { z } from "zod"; import { router, authedProcedure } from "../../trpc"; const createPostSchema = z.object({ title: z.string().min(1).max(100), content: z.string().min(1), published: z.boolean().default(false), }); export const postRouter = router({ create: authedProcedure .input(createPostSchema) .mutation(async ({ ctx, input }) => { const post = await ctx.prisma.post.create({ data: { title: input.title, content: input.content, published: input.published, authorId: ctx.user.id, }, }); return post; }), list: publicProcedure.query(async ({ ctx }) => { return ctx.prisma.post.findMany({ orderBy: { createdAt: "desc" }, take: 20, }); }), });这段代码里最有价值的地方在于:前端调用post.create时,TypeScript 不会让你传入title数字或缺少content,因为input的类型是从 Zod schema 直接推导出来的。你传入的数据在编译时代就被检查了,运行时还会再过一遍 Zod 校验。这意味着你可以不再为每个接口手动写“校验函数”。
有个点想提醒你:Zod 的default()行为和 TypeScript 的推断有细微差别。比如published: z.boolean().default(false)的输入类型是{ published?: boolean },但输出类型是{ published: boolean }。这个差异一般不会出问题,但如果你在客户端用input类型去定义表单初始值,可能会遇到类型报错,原因是“可选 vs 必选”的冲突。我的习惯是给所有表单提交的 schema 单独加z.preprocess处理空字符串,这样能减少不少编码时的挫败感。
2.4 客户端调用与类型自动推导
服务端 router 定义好之后,前端接入的代码量极少。t3code 的utils/api.ts长这样:
// src/utils/api.ts import { createTRPCNext } from "@trpc/next"; import { httpBatchLink } from "@trpc/client"; import superjson from "superjson"; import type { AppRouter } from "../server/api/root"; export const api = createTRPCNext<AppRouter>({ config() { return { transformer: superjson, links: [ httpBatchLink({ url: "/api/trpc", headers() { return { authorization: `Bearer ${localStorage.getItem("token") ?? ""}`, }; }, }), ], }; }, ssr: true, });所有 router 的类型都从AppRouter中自动推导出来,前端写一个调用就像这样:
const postList = api.post.list.useQuery(); const createPost = api.post.create.useMutation({ onSuccess: () => { queryClient.invalidateQueries(["post.list"]); }, }); createPost.mutate({ title: "第一篇博文", content: "内容内容内容", });测试下来你会特别明显感受到一个好处:useState和useEffect的开销大大减少。之前手动管理loading、error、data的状态,tRPC 的 hooks 全给你包了。更妙的是,TypeScript 会直接提示你data的类型,不需要任何手写的类型文件,也不会因为“服务端加了字段但前端不知道”而出错。这种体验用一句话概括就是:改完数据库模型,重启一下 prisma,全项目类型立刻同步,前后端不会脱节。
3. 实操过程:从零跑通一个 t3code 项目
3.1 环境准备与初始化
机器上装好 Node.js 18+ 和 pnpm。t3code 走到今天,包管理我强烈建议用 pnpm,依赖安装速度和重复文件处理比 npm 好很多,尤其是 tRPC 和 Prisma 这种依赖树较深的项目。安装依赖用一行命令:
pnpm dlx create-t3-app@latest my-t3code命令执行后跟着交互提示选择 App Router、tRPC、Prisma、Tailwind。初始化完成后直接cd my-t3code,你会发现项目里已经自带 tRPC 和 Zod 的全部样板,src/server/api/routers 目录下还留了一个 post router 示例。也就是说,一个能跑通了全栈类型安全项目一分钟内就能拥有,后面的工作重点完全是在模板基础之上做业务开发。
跑通以前有个步骤容易被忽略:先检查.env文件,把DATABASE_URL="file:./dev.db"设置对,然后运行:
pnpm db:push这个命令会按照 Prisma schema 把 SQLite 表结构建出来。如果没有这一步,很多 tRPC 查询会直接报“Table does not exist”,我第一次搞的时候还以为是 tRPC 的锅,结果就是数据库没初始化。
3.2 定义第一条业务路由
初始化完成后,把示例 post router 扩展一下。假设要在项目里做一个“待办事项”功能,Prisma schema 里加一栏模型:
model Todo { id String @id @default(cuid()) title String done Boolean @default(false) createdAt DateTime @default(now()) authorId String }修改完 schema 后跑pnpm db:push,Prisma Client 会自动重新生成类型。此时你会发现,你的 TypeScript 代码里如果在prisma.todo.create中传错字段名,编译器直接就会报错,不需要任何手写类型。
业务路由文件:
// src/server/api/routers/todo.ts import { z } from "zod"; import { router, authedProcedure } from "../../trpc"; const todoSchema = z.object({ title: z.string().min(1).max(200), }); export const todoRouter = router({ list: authedProcedure.query(async ({ ctx }) => { return ctx.prisma.todo.findMany({ where: { authorId: ctx.user.id }, orderBy: { createdAt: "desc" }, }); }), create: authedProcedure .input(todoSchema) .mutation(async ({ ctx, input }) => { return ctx.prisma.todo.create({ data: { title: input.title, authorId: ctx.user.id, }, }); }), toggle: authedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { const existing = await ctx.prisma.todo.findUnique({ where: { id: input.id }, }); if (!existing || existing.authorId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND" }); } return ctx.prisma.todo.update({ where: { id: input.id }, data: { done: !existing.done }, }); }), delete: authedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { const existing = await ctx.prisma.todo.findUnique({ where: { id: input.id }, }); if (!existing || existing.authorId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND" }); } await ctx.prisma.todo.delete({ where: { id: input.id } }); return { success: true }; }), });我把增删改查都塞在这个文件里,而且在每个操作前都做了权限校验,确保用户只能操作自己的待办。这里有个习惯很重要:不要在mutation里信任前端传的authorId,一律从ctx.user.id取。很多新手做全栈项目,为了省事直接让前端传用户 ID,实际上一旦这个 ID 被篡改,就会越权操作别人的数据。tRPC 的 context 机制天然适合做这种事,不用白不用。
3.3 在前端接入路由
todo router 定义好之后,记得把它挂到 root router 上:
// src/server/api/root.ts import { todoRouter } from "./routers/todo"; export const appRouter = router({ post: postRouter, todo: todoRouter, }); export type AppRouter = typeof appRouter;在页面组件里调用:
// src/pages/index.tsx import { api } from "../utils/api"; export default function Home() { const { data: todos, isLoading } = api.todo.list.useQuery(); const createTodo = api.todo.create.useMutation({ onSuccess: () => { // 清空表单逻辑... }, }); const toggleTodo = api.todo.toggle.useMutation({ onSuccess: () => { void utils.todo.list.invalidate(); }, }); if (isLoading) return <div>加载中</div>; return ( <ul> {todos?.map((todo) => ( <li key={todo.id}> <input type="checkbox" checked={todo.done} onChange={() => toggleTodo.mutate({ id: todo.id })} /> {todo.title} </li> ))} </ul> ); }这段代码里你会发现一个非常有用的隐藏功能:api.todo.list.invalidate()。它像一个全局的“刷新键”,调用之后所有正在使用todo.list的组件都会自动重新拉取数据。以前用 REST API 时,更新一条数据之后往往需要手动调一次 list 接口,或者用 SWR 的 mutate 去缓存里做修改;现在只需要调一个invalidate,tRPC 会帮你把相关查询全部刷新。这是 tRPC 的 QueryClient 机制带来的优势,也是我越来越依赖它的原因。
3.4 服务端调用与 SSG 场景下的注意点
tRPC 不仅仅能在客户端调用,服务端也可以在 getServerSideProps 或 Server Components 里直接调用。t3code 的客户端封装里用了ssr: true,也就是首屏渲染时 Next.js 会先在服务端请求一次 tRPC,拿到完整数据再返回 HTML。这时候有个容易踩的坑:服务端调用时,前端的 headers 可能无法同步传递。
比如你依赖 cookie 里的 session 来鉴权,在getServerSideProps里调用 tRPC 时,tRPC 的httpBatchLink使用的是服务端 fetch,它不会自动带上浏览器的 cookie。解决方案是手动把 cookie 传进 headers:
// src/pages/todos.tsx export const getServerSideProps = async ({ req }) => { const ssg = createServerSideHelpers({ router: appRouter, ctx: await createContext({ req }), transformer: superjson, }); await ssg.todo.list.fetch(); return { props: { trpcState: ssg.dehydrate(), }, }; };注意这里createServerSideHelpers的ctx参数必须传入原始的req,这样 tRPC 请求链路才能拿到 cookie。如果你漏传,服务端渲染阶段会直接鉴权失败,而浏览器端却正常——这类问题特别难排查,因为错误信息往往只是'UNAUTHORIZED'。这个坑我在好几个项目里都遇到过,特别值得记下来。
3.5 部署时的工程化配置
部署阶段有几个细节需要处理。Next.js 的 standalone 输出模式开启后,会自动生成一个.next/standalone目录,里面包括了跑生产环境所需的最小文件集。用 Docker 部署时,要注意把.env和 Prisma 的 schema 文件一起打进去,并且执行prisma generate和prisma db push:
FROM node:18-alpine AS builder WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY . . RUN pnpm prisma generate RUN pnpm build FROM node:18-alpine WORKDIR /app COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/prisma ./prisma EXPOSE 3000 CMD ["node", "server.js"]这段 Dockerfile 的优点是把构建阶段和运行阶段分离,最终镜像体积能控制在 200MB 左右。如果你直接用next start部署,也没问题,但镜像里会多出一大堆 build 时不需要的文件。我在早期版本里是直接把整个.next目录拷进去的,一个镜像快 1GB 了,后来才改成 standalone 模式,体积小了一大截。
4. 常见问题与排查技巧实录
4.1 tRPC 类型报错:router 类型不匹配
这个现象通常发生在手动往 root router 添加新路由后,前端调用处显示类型错误,但后端代码的编译是正常的。原因基本是类型没有重新推导:t3code 中AppRouter是typeof appRouter,当你给appRouter加了新属性后,TypeScript 的类型应该自动更新。但如果你改了 root router 后没有重启 dev server,旧类型可能残留在内存里。
排查经验:先重启 dev server,如果还报错,检查 root router 的导出是否在utils/api.ts中被正确引用,以及是否有循环依赖。最常见的循环依赖是root.ts引用了 router 文件,router 文件又引用了root.ts里的某个类型。这种情况用 ESLint 的import/no-cycle规则能很快定位。
4.2 Zod 校验失败时,前端看不到具体错误信息
默认情况下,tRPC 的错误 message 是笼统的,比如“Input validation failed”,这不利于表单提示。前面 2.2 节的errorFormatter就是为了解决这个问题。如果你发现前端只显示“输入校验失败”,但没有具体是哪个字段的问题,十有八九是自定义 errorFormatter 没有生效。
另外,如果你用了superjson,一定要在createTRPCNext的 transformer 和服务端initTRPC的 transformer 保持一致。不一致的表现是前端收到的数据全都是undefined或被序列化错误的格式,排查起来很迷。
4.3 部署后页面空白,接口 404
本地一切正常,部署到 Vercel 或 Docker 后,所有 tRPC 请求都返回 404。这个问题的根源通常是 Next.js 的rewrites或部署环境的路径配置问题。t3code 中 tRPC 的入口在pages/api/trpc/[trpc].ts,部署时要确保这个路由被正确打包。如果用 standalone 模式却漏了public目录或移动了prisma路径,也很容易出现接口 404。
排查方法很简单:在部署环境访问/api/trpc/post.list?batch=1这个 URL,看看返回的是不是 JSON 错误。如果也是 404,说明路由入口缺失或配置不对;如果能返回 401/400,说明路由没问题,问题出在鉴权或参数校验上。这样可以快速缩小范围,不用盲猜。
4.4 数据变更后 UI 不更新
这个问题的核心通常是invalidate没有生效。比如你用api.todo.toggle.useMutation改完数据,但页面上的复选框状态没变。最可能的原因是你在onSuccess里调用了invalidateQueries,但传入的 key 与 query 的 key 不一致。
tRPC 的 key 格式类似["todo.list"],如果你用utils.todo.list.invalidate()就不会写错。但有几次我发现,在useEffect里手动把数据存进了本地 state,后续 tRPC 的响应更新了,但 React 组件优先读了 state,导致 UI 与数据不同步。t3code 的文档里也建议:尽量直接使用 tRPC 返回的数据,不要手动把它拷贝到本地 state,除非确实需要做客户端缓存映射。
4.5 环境变量被编译进客户端
这个坑比想象中常见。t3code 的.env中如果存放了 API 密钥,任何以NEXT_PUBLIC_开头的变量都会被发送到浏览器。如果你不小心把DATABASE_URL或登录密钥命名为NEXT_PUBLIC_开头,部署后任何人都能通过浏览器源码看到这些敏感信息。
我目前的做法是,所有服务端环境变量一律不加NEXT_PUBLIC_,通过serverEnv共享模块做统一校验:
// src/server/env.ts import { z } from "zod"; const serverEnvSchema = z.object({ DATABASE_URL: z.string().min(1), JWT_SECRET: z.string().min(16), }); export const serverEnv = serverEnvSchema.parse(process.env);这样在应用初始化时就能立刻发现环境变量缺失或格式不对,不会等到运行时才爆出"secret undefined"这种让人抓狂的错误。
5. 一些我长期执行的有效习惯
做 t3code 这类项目,最有价值的东西往往不是某一个框架的 API,而是背后的一整套协作习惯。我自己总结了几条,直接分享给你。
一是坚决不使用any,除非你写的是连类型都不知道的第三方库 bridge。t3code 的 ESLint 配置默认会禁掉显式any,但这个规则太容易被绕过(比如as any)。在 tRPC 的项目里,any的危害会被放大,因为类型安全一旦断了一环,前后端的契约就会出现不可见的缺口。我给自己立了一条规矩:新代码里出现any必须写注释说明为什么类型推导不出来,并标记 TODO 今天解决。
二是把 Zod schema 当作全项目共享的契约文件。不要在 router 文件里写一堆“就地 schema”,而是把常用的 schema 抽取到src/utils/zod.ts或独立的schemas/目录中。前端表单的初始值、校验提示,都可以从同一个 schema 里推导出来。这个习惯能让“输入校验规则”从后端一路透明到前端,不重复定义。
三是尽量使用 pnpm + 一个 lockfile。多成员协作时,经常出现“我本地跑得好好的,你 pull 下来就报错”的问题。t3code 对依赖版本敏感度较高,tRPC 的大版本升级会带来类型 API 变化,所以建议 commit 锁源文件并统一使用 pnpm。如果团队里有人用 npm 装了不同的版本,类型推断很可能发生微妙但难查的变化。
四是把 tRPC 的中间件尽量写薄。虽然 t3code 的authedProcedure很强大,但不要把大量业务逻辑都塞进中间件里。中间件适合做横切关注点(鉴权、日志、权限校验),而具体的业务规则应该留在 router 的query/mutation中。我早期把缓存和权限检查都塞在中间件里,说实话可读性很差,改个权限逻辑要翻很多人。后来一切以“一个函数一个故事”为标准,清晰了很多。
最后分享一个我在实际使用中的体会:t3code 本质上不是说“换一个框架写代码”,而是换一种“全栈协作方式”。传统的前后端分离,两边总有一个人吃亏——要么后端多写文档,要么前端反复猜接口。现在,所有类型都由机器来保证一致,文档反而变成了锦上添花的东西。如果团队里每个人都养成“先说类型定义,再谈业务实现”的习惯,全栈开发的疲劳感会下降得非常明显。这也是我一再和朋友推荐 T3 Stack 的根本原因:它不追求大而全,只认真做好“类型安全”这一件事,而这件事做好了,恰恰能把全栈开发里最耗神的环节省掉。