我维护 t3code 这个项目已经有段时间了。最早它只是朋友圈子里内部用的一个全栈脚手架,后来慢慢长成了一整套"从数据库到按钮"的类型安全开发模板。很多人第一次看到这个名字以为是个新框架,其实它不是。它是围绕 T3 技术栈——Next.js、TypeScript、Tailwind CSS、tRPC——沉淀下来的一套代码组织方式、工程约定和可直接复制的项目基底。这篇文章不打算讲什么高深理论,就是把我从零搭这个模板、用它落地了好几个实际业务之后,踩过的坑、想清楚的取舍、值得直接抄作业的地方都摊开讲一讲。如果你正用 Next.js 写全栈应用,或者团队里每次改一个接口字段都要前后端各改一遍类型定义并暗自祈祷别漏掉,那这篇应该能帮你省不少时间。
1. 为什么我会把整套东西沉淀成 t3code
1.1 最开始的痛点:类型在两个端之间各说各话
先说一件让我下定决心的事。之前我维护过一个项目,后端 Next.js API Routes 配 Prisma,前端直接 fetch。业务很简单,就是文章列表。某次需求给文章加一个readingTime字段,后端改完数据库和返回结构,前端同学没同步更新类型,TS 在编译期完全不报警,等真上线了,前端拿到undefined直接渲染出一个诡异的空白区域。这种问题我觉得所有人都经历过,但真正让人难受的是它在"没有错误"的情况下悄悄出错。
后来我统计了一下,这类问题的根源不是谁手滑,而是 REST 接口和前端调用方之间根本没有一个强制的类型契约。后端返回什么靠"约定",前端知道什么形状靠"记忆"。人可以约定,会忘;记忆会漂移。我要找的就是一个把类型契约写死在代码里的方案,让两端不一致时编译都不通过,而不是上线后才发现。
1.2 T3 技术栈解决了什么,又留下哪些空白
T3 Stack 是国外开发者 Theo 提出的一个技术选型理念,核心就几个词:TypeScript 优先、类型安全、极简可靠。配套的 create-t3-app 脚手架可以一键生成包含 Next.js、tRPC、Prisma、NextAuth、Tailwind 的项目。这工具解决了"新项目从零配一堆东西"的苦差事,我第一次用的时候确实被爽到,三分钟左右项目就跑起来了。
但用了一段时间我发现,脚手架解决的是"起跑",没解决"跑到第三个月不散架"。比如:
- 业务代码怎么分层,routers 越来越大要怎么拆分;
- Server Component 和 tRPC 应该怎么配合,还是说干脆不配合;
- 错误处理是每个 mutation 写一遍,还是有个统一拦截;
- Prisma 客户端到底在哪个文件里创建,才能在 Next.js 开发模式下不把连接数打爆。
这些问题是脚手架不负责回答的,而它们恰恰决定了项目能活多久。t3code 就是我把这些问题的答案固化下来的产物。
1.3 t3code 的定位:让脚手架的项目跑三个月不散架
一句话概括:create-t3-app 负责让你三分钟跑起来,t3code 负责让你跑三个月不重写。
具体来说,t3code 做了四件事:第一,把业务代码强制分成"路由层—逻辑层—数据层",路由层只放 tRPC 路由定义,逻辑抽到 service,数据访问统一走 Prisma;第二,约定所有跨端调用必须走 tRPC,不允许在页面组件里直接 fetch 内部 API;第三,把鉴权公共逻辑收拢成protectedProcedure,新写一个需要登录的接口时,三行代码解决,不用记中间件怎么写;第四,把错误处理、上传、环境变量管理这些高频场景的代码都整理成了开箱即用的模板。
对独立开发者和中小团队来说,这套约定成本很低,收益很直接:新人进来不用读一整天文档,照着既有路由文件抄就能写出来一个符合规范的新接口。
2. t3code 五个基础构件的选型逻辑
2.1 为什么基座是 Next.js App Router
选 Next.js 没什么悬念,它现在是全栈 React 应用最成熟的载体。真正需要决策的是用 App Router 还是 Pages Router。t3code 从第一天就锁定 App Router。
我的理由是这样的:App Router 的 Server Component 模型天然适合做读多写少的页面,比如列表页、详情页可以直接在服务端取数,数据不会经过网络传一份到浏览器;而需要交互、需要客户端状态的地方,用"use client"标注的组件和 tRPC 客户端配合。这种"默认服务端取数,按需客户端取数"的模式,比 Pages Router 里那种"所有页面都要自己选 ssr 还是 ssg"的心智负担低很多。
还有一个实际原因:Next.js 团队所有新特性都优先给 App Router,未来方向很明确。早切比晚切舒服。
2.2 tRPC:我为什么丢弃 REST 和 GraphQL
这是 t3code 里争议最大也可能是价值最大的一层。
先说说 REST。REST 本身没错,但它作为内部前后端通信协议有个天然缺陷:它不携带类型契约。OpenAPI 可以补这个空缺,但实际项目里维护一份 OpenAPI 文档的意愿和成本都很高,前端要依赖生成的 SDK 还得保证生成步骤在 CI 里永远执行。反正我见到的团队,文档和代码说翻脸就翻脸。
再说 GraphQL。我团队里几个人都学过 GraphQL,也认同它强 schema 的思路,但当一个项目的前后端都是 TypeScript 时,GraphQL 需要你写 schema、跑 codegen、处理缓存、设计查询复杂度控制,整套东西对一个本来不需要多端多语言服务的项目来说太重了。它解决的是"多客户端、多语言、复杂聚合"的问题,而 t3code 面向的是"一个人或三五个 TS 工程师包打前后端"的场景。
tRPC 的思路完全不同——它干脆不定义协议,直接把服务端 router 的 TypeScript 类型暴露给客户端。你不需要写 schema,不需要跑 codegen,不需要生成 SDK。服务端加一个字段,客户端类型和编辑器补全立刻同步。它的前提是前后端必须都是 TypeScript,但 t3code 的用户恰好满足这个前提,所以这个"缺点"对我们来说不存在。
2.3 Prisma 与 TypeScript 一起锁定数据库类型
数据层选 Prisma 几乎不需要犹豫。必要性在于:手写 SQL 或直接用 ORM 返回的模型类,类型都是靠人维护的,数据库表改个字段名,代码里相关类型不会自动变。Prisma 的做法是让你先定义 schema,然后prisma migrate和prisma generate生成类型,整个代码库的类型会跟着数据库 schema 联动。
举个例子,我在 t3code 里的一个文章模型长这样:
model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) createdAt DateTime @default(now()) author User @relation(fields: [authorId], references: [id]) authorId String }当我在 schema 里给Post加一个viewCount Int @default(0),然后跑npx prisma migrate dev,所有涉及查询 Post 的 tRPC 路由返回类型立刻带上viewCount。前端调用api.post.getAll.useQuery()拿到的数据也立即有这个字段提示。那种"改完库,整个链路类型自动全部纠正"的感觉,用过一次就很难回去。
2.4 Tailwind CSS 和被我删掉的 Redux
样式这块没什么好辩论的,Tailwind 的原子类在 t3code 里负责所有组件样式,配合 Next.js 开箱即用,连 postcss 配置都是现成的。
值得说的是状态管理。很多从 Vue 或老 React 项目转过来的人会习惯性引入 Redux Toolkit,我在 t3code 的第一个版本也这么干过。后来发现真没必要。t3code 里大部分需要跨组件共享的数据是"服务端状态"——文章列表、用户信息、点赞状态,这些完全可以交给 tRPC 底层的 React Query 缓存去管,它有全局缓存、失效重取、乐观更新,比手写 actions 和 reducers 高效得多。真的只剩一些本地 UI 状态,比如某个弹窗开关,那种东西用useState就够了。
所以最终 t3code 的状态管理方案是:服务端状态全部走 tRPC query cache,客户端瞬时状态走useState,不再引入任何全局状态库。
2.5 被裁掉的 axios 和重复封装
以前的项目习惯性会封装一个request.ts,包一层 axios,处理 token、错误码、请求拦截。t3code 里这个文件彻底删了。
原因很简单:tRPC 客户端本身就内置了 headers 注入、错误拦截、loading 状态管理。你不该在它外面再包一层请求工具,那等于在已经打通的全链路上硬生生加一段需要维护的胶水层。我在 t3code 的模板里写了一个trpc/react.tsx,负责创建客户端、注入鉴权 headers、配置全局错误 handler,写完之后业务代码就只需要关心数据和输入,HTTP 细节交给它。
3. 从空目录到第一个业务接口:完整实操记录
3.1 初始化项目时的参数选择
如果你要复现 t3code 这套结构,最好直接从 create-t3-app 起步,而不是手动往已有的 Next.js 项目里塞依赖。命令很简单:
npm create t3-app@latest交互选项我建议这样选:
- TypeScript、Tailwind CSS、tRPC、Prisma、ESLint 全部勾上;
- NextAuth 第一次可以勾,也可以不勾。如果项目最终一定要登录功能,建议一开始就勾上,因为 tRPC 的上下文(context)设计会依赖 session 类型,后面补会比较麻烦;
- 包管理器选你团队日常用的,npm/pnpm 都行,我自己习惯 pnpm。
有一句经验之谈:脚手架生成的默认项目会有一个很直观的"示例页面"——一个 CRUD 示例带着 tRPC 路由和 Prisma 模型。很多人会急着把它删掉。我建议先别删,读完那个示例的每个文件再删,那几乎是理解这套技术栈最好的入门材料。
3.2 目录结构解读:真正该放代码的位置
生成完项目,目录结构是这样的。我标一下 t3code 里哪些是核心:
app/ api/trpc/[trpc]/route.ts # tRPC 的 HTTP 入口 layout.tsx page.tsx server/ api/ root.ts # 汇总所有子 router 的地方 routers/ post.ts # 你的业务路由 trpc.ts # tRPC 实例、中间件、上下文工厂 db.ts # Prisma 客户端单例 trpc/ react.tsx # 客户端 tRPC 创建工具 server.ts # Server Component 取数工具最容易踩的坑在server/api/root.ts。每新建一个routers/comment.ts,都要记得在root.ts里把它挂到appRouter合并一下,然后export type AppRouter。很多新人写完了路由但忘了注册,前端一直说找不到api.comment.xxx,查半天才发现是 root 没更新。
t3code 实际结构里,我会额外加一个server/services/目录,用来放一些路由里比较重的业务逻辑。我对团队的要求是:路由文件不超过 200 行,超过就把逻辑抽到 service,这样 router 文件的阅读成本非常低。
3.3 第一个带鉴权的 tRPC mutation:三行级联的体验
初始化完成后,我建议从写一个"创建文章"接口开始。先看一下 Prisma 的 Post 模型(上面已经写过),然后在server/api/routers/post.ts里写路由:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });注意protectedProcedure是 t3code 里预定义好的,它内部已经完成了"没登录就抛 UNAUTHORIZED"这个动作。你在业务代码里根本不需要判断 session 是否存在,直接ctx.session.user.id,类型上就已经保证了 user 非空。这就是把鉴权收敛进过程定义(procedure)的价值,每个新接口都少写一段人人都会复制错的中间件代码。
前端调用同样干净:
const createPost = api.post.create.useMutation(); const onSubmit = (data: { title: string; content: string }) => { createPost.mutate(data, { onSuccess: () => { // 创建成功后使列表缓存失效 utils.post.getAll.invalidate(); }, }); };从输入校验、鉴权、写库、类型推导、到缓存失效,一次全链路写下来只有十几行代码。这就是 t3code 想给你的日常:类型跟着走,样板代码消失,你需要关心的只有业务字段本身。
4. tRPC 的类型继承是怎么做到"不可能写错"的
4.1 从 Router 到 AppRouter 的引用链路
关于类型继承,很多新手第一反应是"这不就是代码生成吗",还真不是。区别在于:
- 代码生成(比如 OpenAPI 生成 SDK、GraphQL codegen)是"把契约翻译成代码文件",生成文件一旦过期,类型就失真;
- tRPC 是"直接复用服务端类型本身",不产生中间文件,类型是活着的。
链路是这样的。服务端在server/api/root.ts里把所有子路由合并成一个appRouter:
import { postRouter } from "./routers/post"; import { commentRouter } from "./routers/comment"; import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ post: postRouter, comment: commentRouter, }); export type AppRouter = typeof appRouter;这里的AppRouter不是运行时对象,它是一个类型。客户端拿到这个类型:
import type { AppRouter } from "@/server/api/root"; import { createTRPCReact } from "@trpc/react-query"; export const api = createTRPCReact<AppRouter>();从这一刻起,前端代码里api.post.create这个对象上的每个字段——参数、返回值、错误类型——都是从服务端 router 原封不动"继承"过来的。你在服务端给查询加了一个参数,前端不传,TypeScript 立刻报错;你在服务端还改了返回结构里的一个字段名,前端的补全和类型检查也会立刻失效。
4.2 一个字符串的字段改动如何传遍全链路
为了让你对"类型活着"有更直观的体感,我模拟一个真实改需求的过程。
需求:文章列表要返回作者名。改动只有两处。第一处,后端postRouter.getAll的查询加一个include: { author: true }:
getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, include: { author: true }, }); });第二处,前端列表组件里直接用:
const { data } = api.post.getAll.useQuery(); // 这里编辑器会直接提示 data[0].author.name 是 string | undefined // 如果你写 data[0].auther.name —— 直接红线全程没有任何人手动同步过类型。如果没有 tRPC,你需要:改 Prisma 模型、改后端 DTO、改前端 interface、改 mock 数据。中间任何一步漏了,就会回到文章开头那个"悄悄出错"的场景。
4.3 为什么不建议在 t3code 里手写"DTO"
有一个相反的提议我在团队评审时也听过:"我们保留了 REST API 和手写 DTO,这不是更清晰吗?"
我理解这个想法,但它需要一个重要前提:你的项目真的有很多不同的消费端(Web、iOS、Android、第三方公开 API),并且需要对外保证 API 永远不变。如果你的前端只有 Next.js 自己的页面组件,那手写 DTO 就是在两份代码里人为制造漂移的机会。t3code 的取舍很明确:内部调用一律直接返回 Prisma 模型,不套壳、不做 DTO 转换;只有当这个数据要对外暴露给不可控的第三方时,才在路由出口做一次显式的映射。
这个取舍让代码量少了很多,也让"数据库字段就是前端字段"成为可能——单包项目的核心优势,不应该主动放弃。
5. 三个月实战踩坑:从报错信息开始的完整排查链路
这一部分我认为比前面的选型分析更值得读,因为每一项都是我实际遇到、且网上资料通常只讲一半的。我会把排查链路写完整,而不是直接丢结论。
5.1 部署到线上后所有查询全部返回 UNAUTHORIZED
现象很诡异:本地开发环境一切正常,一部署到 Vercel,所有带鉴权的查询直接返回 401。
我的排查顺序是:
- 打开浏览器 Network 面板,确认请求确实打到了
/api/trpc/post.getAll,且 cookie 带了; - 看服务端日志,返回的 code 是
UNAUTHORIZED,说明不是网络问题,而是代码层面认定"你未登录"; - 检查 NextAuth 配置,发现
NEXTAUTH_SECRET在生产环境没有配置。NextAuth 在本地开发时可以不配 secret,生产环境 JWT 签名完全依赖这个值,缺失时它没法解析 session cookie,于是每次请求的 session 都是 null; - 补上环境变量,重新部署,问题消失。
这不是复杂问题,但我当时也没意识到 NextAuth 对生产环境 secret 是强校验。t3code 里我加了一个启动时检查:缺少NEXTAUTH_SECRET时直接编译报错,把这种"线上才爆"的问题提前到部署前。
5.2 React hydration 不一致导致的白屏
这问题典型得像教科书。某个页面的 header 组件从localStorage里读取用户偏好设置渲染,导致客户端第一次渲染的 HTML 和服务端渲染出来的 HTML 不一致。结果就是浏览器控制台报Text content does not match,页面直接白屏。
排查链路:
- 定位报错组件,发现它依赖浏览器端存储;
- 把读取逻辑从渲染期挪到
useEffect里,初始状态给一个确定值:const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); if (!mounted) return null; - 页面恢复,但有个副作用:首屏会先空白一下,这对体验有损。最终的优雅做法是服务端也读取同样的偏好数据(如果是 cookie),或使用
suppressHydrationWarning做局部豁免。
在 t3code 里,我干脆规定了一个约定:任何站在localStorage上的渲染,都必须先判断typeof window !== "undefined"并走 mounted 模式,不许直接读取。这个约定让新进来的成员少踩很多雷。
5.3 类型推导突然变成 any 污染了整个页面
这是个很隐蔽的问题。某天开始,前端api.post.getAll.useQuery()返回的数据类型突然变成any,编辑器里所有字段提示消失。我当时第一反应是 tRPC 配置坏了,查了半天没结果。
后来真正的原因在server/api/routers/post.ts里。我在写辅助函数时没有显式标注返回类型:
// 这里没有写 : Promise<Post[]> const fetchPosts = async (db: PrismaClient) => { return db.post.findMany(); };按说这也能推断出类型,但那个文件里引入了循环依赖——post.ts顺便 import 了comment.ts,comment.ts又回头引了post.ts里的一个类型。TypeScript 在循环依赖和泛型推断同时出现时,推导会直接放弃,把类型降级成any,然后any顺着 tRPC 的类型传播把整个客户端页面污染了。
解决办法分两步:先把循环依赖拆掉——comment 里需要的类型提到一个公共types.ts;再给所有公共 helper 函数显式标注返回类型,切断"推断中断"的源头。
这条经验我反复讲给团队:t3code 的承诺是"类型安全从服务端传到客户端",但它依赖一个前提——你的 TypeScript 项目本身不能出现贯穿式的any。代码里一旦有未标注类型的any,它就像一滴墨汁掉进清水里,会把整个类型继承链路染脏。
5.4 Prisma 连接数在生产环境被打爆
有一次客户反馈:线上高峰期应用极慢,数据库日志全是连接超限。查代码才发现,某个 Server Component 页面里直接new PrismaClient()了,而且是在组件函数体内。Server Component 没有生命周期概念,每个请求都会 new 一个新实例,每个实例都去开连接,连接数瞬间爆炸。
正确做法是全局单例复用。t3code 里server/db.ts的标准写法是:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"], }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;globalThis是为了避免开发模式下热更新导致多个实例。更重要的一点是:t3code 的路由层统一通过ctx.db拿 Prisma 实例,而不是到处 importdb,这样每个请求只需要一个 prisma 客户端引用,从根上减少误用。
6. t3code 还能往哪些方向继续演进
6.1 文件上传的边界:tRPC 只管元数据,不碰文件本体
一个经常被问到的问题是 t3code 里怎么做文件上传。我的建议比较明确:不要把文件内容塞进 tRPC 请求。tRPC 本质是 JSON 序列化,大文件转 base64 会导致请求体巨大、内存和带宽双高。
推荐模式是这样的:tRPC 负责"申请上传凭证"和"记录元数据",文件本体走对象存储的预签名直传。实现起来很清晰:
- 前端先调用
api.upload.createPresignedUrl,传入文件名和大小,拿到一个带签名、有时效的上传 URL; - 前端直接把文件 PUT 到那个 URL;
- 完成后调用
api.upload.confirm,把文件的 key、大小、类型写入数据库。
这个方案对用户体感好,上传速度快,后端内存压力小,而且进度条也好做。t3code 的模板里我把云存储 SDK 相关的调用封装在server/services/storage.ts,哪个路由要用就调用它,不散落得到处都是。
6.2 和 NextAuth 集成时最容易漏的 userId 注入
登录鉴权流程里,新手最常问的问题其实是:"我在 session 里怎么拿 userId?"
默认的 NextAuth session 类型里只有user.name、user.email、user.image,没有user.id。t3code 的处理是在 NextAuth 配置的callbacks.session里手动把 token 里的sub(即 userId)注入 session:
callbacks: { session({ session, token }) { if (session.user) { session.user.id = token.sub as string; } return session; }, },然后做一件事:声明 session.user.id 类型不为空。这一步之后,protectedProcedure里ctx.session.user.id的类型推导就是可靠的 string,而不是string | undefined。
别看就这一行,它直接决定了你在 mutation 里写authorId: ctx.session.user.id时,到底要不要写一堆非空断言。写类型声明前被undefined追着跑,写完之后整个世界清净了。这个细节,我在 t3code 的 README 里专门开了一节。
6.3 统一错误处理:把 TRPCError 翻译成用户能看懂的话
t3code 的最后一个演进方向是错误处理。tRPC 内置了一套错误码体系,比如UNAUTHORIZED、NOT_FOUND、INTERNAL_SERVER_ERROR、PRECONDITION_FAILED。如果你在每个组件里手动catch然后分别处理,代码会非常碎。
我的做法是在客户端trpc/react.tsx里配一个全局onError拦截,统一翻译:
const onError = (err: TRPCClientErrorLike<AppRouter>) => { if (err.data?.code === "UNAUTHORIZED") { router.push("/login"); return; } if (err.data?.code === "NOT_FOUND") { toast("记录不存在或已被删除"); return; } if (err instanceof TRPCClientError && err.data?.zodError) { const message = err.data.zodError.fieldErrors; toast(formatZodErrors(message)); return; } toast("操作失败,请稍后重试"); };这样业务代码里不需要再关心错误处理,所有 tRPC 请求的错误都被这个拦截器统一接走了。如果要针对某个请求做特殊处理,可以在useQuery/useMutation的局部onError里覆盖或补充。
最后说一个我特别坚持的习惯。在 t3code 里,我不允许任何跨端类型出现any。真遇到类型推断不下去的情况,宁可先用一个显式的类型断言撑着,也要让编辑器红色标出来,因为它提醒你这里断了,需要你补上声明。一旦你放下这个口子,让any顺着 tRPC 的类型链路溜进页面,前面所有按类型对齐建立的效率优势,几分钟内就能土崩瓦解。类型上的自由从来不是免费的,你省的每一次断言,以后都可能在某个深夜变成一场无声的线上事故,我反正是领教过了。