去年年中我们团队把一个跑了两年的老后台管理系统推倒重来,立项代号就叫 t3code。原本需求文档写得很克制,只有一句话——“要一个更好维护的前端”。技术评审开了两轮,方案从“换一个组件库”一路卷到了“干脆全栈重写”。最后落地的技术栈是 Next.js + TypeScript + Tailwind CSS + tRPC,也就是社区里常说的 T3 这套组合。项目收尾后我重新看了一遍整个过程,觉得有很多选型、落地和踩坑的细节值得记录下来。这篇文章就是基于 t3code 这个项目的完整复盘,适合正准备用 T3 技术栈做真实业务、或者已经在用但对集成细节心里没底的人参考。
1. t3code是什么:项目代号背后的技术栈取舍
1.1 为什么叫t3code,而不是直接叫“T3项目”
现在搜 t3code,大概率会先看到 T3 Stack 相关的讨论。我们当时给项目起这个名字,其实是“TypeScript × Tailwind × tRPC”的缩写代号,加一个 code 后缀,纯粹是内部仓库命名习惯。但这名字歪打正着,因为它精确概括了这个项目最核心的三根支柱——类型、样式、接口三层全部走 TypeScript 链路。
很多团队做中后台管理系统,常规套餐是 React + Redux Toolkit + Axios + Ant Design。这套组合本身没问题,但时间久了你会发现几个顽固痛点:接口返回值和前端类型定义永远靠手写,接口一变前端就悄悄崩;全局状态里一半是服务端数据缓存,一半是 UI 状态,混在一起根本分不清;样式被组件库牵着走,换主题比重构页面还累。t3code 想解决的其实就是这三件事,只是恰好选择了 T3 这个组合来一次性处理。
1.2 每一层到底负责什么
拆开看,T3 的职责边界非常清晰:
- TypeScript是整个项目的“语法底座”,没有它后面两层的类型安全都无从谈起。
- Tailwind CSS管样式层。它不提供现成组件,而是提供一套原子化的样式类,配合设计 token 可以让 UI 保持一致。
- tRPC管接口层。它让前端调用后端函数像调用本地函数一样,参数和返回值类型完全由 TypeScript 自动推导。
再加上 Next.js 作为框架外壳,负责路由、渲染、API 入口和部署形态。这四样东西单独拿出来都不是新东西,但组合起来之后,t3code 获得了个很重要的产品特性:从前端组件到数据库查询,整条链路上的数据形状是一致的。这个特性在后期的需求变更里价值极大——后端多返回一个字段,前端立刻能在编辑器里看到,不需要再去翻接口文档。
2. 开工第一天:create-t3-app 的每一个选项我都重新过了一遍
2.1 脚手架默认配置并不等于最佳配置
create-t3-app 确实能让你三分钟跑起来一个 demo,但它默认生成的是“全家桶最全形态”,实际项目不能全盘照收。t3code 开工的第一天,我们就把脚手架生成的代码从头到尾过了一遍,最后删掉了近三分之一的内容。
一个现实原因:T3 模板默认集成了 Prisma 和 NextAuth.js,这两个东西非常好用,但也非常“重”。我们的旧系统里用户体系是单独的服务在管,数据库也不是每个开发者都有本地权限。如果无脑保留 Prisma 和 NextAuth,光配本地开发环境就能耗掉两天,还会把数据模型的所有权从后端团队手里抢过来,这在一个多团队协作的仓库里是致命的。所以 t3code 最终自定义了脚手架,去掉了 Prisma 和 NextAuth,只保留 Next.js、TypeScript、Tailwind、tRPC 和 ESLint。
如果你也想自定义,初始化的命令很简单:
pnpm create t3-app@latest跑起来之后会问你启用哪些模块,这里我建议按需勾选,而不是全选:
- Next.js:必选。
- TypeScript:必选,这层不选就没有后文了。
- Tailwind CSS:如果你们组件库用 Ant Design 这类,可以晚点再改造;但既然选了 T3,建议直接启用。
- tRPC:后端不打算写 BFF 层的话可以去掉,一路强类型会很爽。
- Prisma:由谁负责数据库 schema?如果是独立后端团队,建议不选。
- NextAuth.js:认证体系是自研或第三方平台的,建议不选。
- ESLint:必选,最好直接接上团队现有规则。
2.2 包管理器、目录结构、环境变量的一次性配置
在没跑dev之前,还有三件小事值得花半小时搞定,否则后面一定会返工。
第一是包管理器。我们统一用 pnpm,原因是依赖安装速度快、磁盘占用低,更重要的是它天然支持 monorepo——如果后面想把 t3code 拆成多个应用,改造成本低。脚手架默认也支持你选 pnpm,直接用pnpm install就行。
第二是目录结构。默认模板里src/server/api/routers和src/server/db.ts是项目全局的,我们把它改成了按业务域切分:
src/ app/ (marketing)/ # 不需要登录的页面 (dashboard)/ # 需要登录的后台页面 server/ api/ routers/ _app.ts # 根 router 汇总 user/ index.ts # 用户域 schema.ts # input 校验 order/ index.ts schema.ts auth/ session.ts lib/ trpc.ts # tRPC 客户端实例 utils.ts这样改的好处是:新增一个业务域时,只需要新增一个文件夹、在_app.ts里挂一行appRouter = router({ user: userRouter }),改动范围完全局部化,不会出现“一次性改了十几个文件”的局面。
第三是环境变量。T3 模板默认用 zod 在运行时校验环境变量,这个机制非常值得保留,但一定要把变量清单和团队内部文档对齐。真实项目里最容易踩的坑是.env.example和.env不一致:新同学 clone 代码后起不来服务,最后发现是少了某个NEXT_PUBLIC_前缀的变量。我们后来在 CI 里加了一步——跑tsc --noEmit之前先校验一遍.env,从根上把这个问题堵死。
2.3 首屏能跑通之后,我做的第一件“非脚手架”动作
Demo 跑通那天,我没急着写业务,而是用了半天时间把layout.tsx、根page.tsx和trpc/server的包装代码全部重新写了一遍。原因很简单:脚手架模板的默认页面带了一堆品牌宣传内容,而我们的后台需要的是一个干净的嵌入式布局壳。这一步看着没什么技术含量,但意义重大——它确定了整个项目后续所有页面的“骨架规范”,后面新页面都是在这个壳上长出来的,不再各自造轮子。
提示:create-t3-app 生成的初始代码质量很高,但它面向的是“让你快速看效果”,不是“给你一个生产模板”。开工第一天多花四小时清理脚手架,比业务写到一半再回头重构省得多。
3. tRPC 从 demo 到生产:router 设计与类型链路的完整落地
3.1 类型安全不是玄学,tRPC 只是把两端的类型动态关联起来了
很多人第一次看到 tRPC 的 “端到端类型安全”会产生一个误解,以为它是在编译期通过某种魔法把前后端类型绑定在一起。真实原理其实很朴素:tRPC 的 server 端定义 router 时,会把每个 procedure 的输入输出类型记录下来,生成一个类型对象;client 端通过createTRPCReact创建 hooks 时,把AppRouter这个类型传进去,于是编辑器里每个方法的参数和返回都被自动推导出来了。
打个比方,这就好比去银行办业务,柜员系统里每个窗口能办什么业务是明明白白写死在系统里的。你走到哪个窗口,系统就只给你显示那几颗按钮,不需要在纸面单据上猜。tRPC 把“单据类型匹配”这件事在没有额外代码生成的情况下做掉了。
核心的初始化代码非常简单:
// src/server/api/trpc.ts import { initTRPC } from '@trpc/server'; import { ZodError } from 'zod'; import type { Context } from './context'; const t = initTRPC.context<Context>().create({ errorFormatter({ shape, error }) { return { ...shape, message: error.message, issues: error.cause instanceof ZodError ? error.cause.flatten() : undefined, }; }, }); export const router = t.router; export const publicProcedure = t.procedure;现在的 tRPC 版本统一走initTRPC入口,老教程里配置createRouter的写法已经过时了,按新写法来才能跟上官方的类型定义。
3.2 按业务域拆分的 router 设计和输入校验
t3code 的核心业务是订单管理和用户分群,每个域都有自己的 router。以订单域为例:
// src/server/api/routers/order/index.ts import { z } from 'zod'; import { router, publicProcedure, protectedProcedure } from '../../trpc'; import { getOrderById } from './handlers'; import { orderSchema } from './schema'; export const orderRouter = router({ detail: protectedProcedure .input(z.object({ orderId: z.string().uuid() })) .output(orderSchema) .query(async ({ ctx, input }) => { const order = await getOrderById(ctx.database, input.orderId); if (!order) { throw new Error('ORDER_NOT_FOUND'); } return order; }), recentList: protectedProcedure .input(z.object({ limit: z.number().int().min(1).max(50).default(20) })) .query(async ({ ctx, input }) => { return ctx.database.order.findMany({ where: { ownerId: ctx.session.userId }, take: input.limit, orderBy: { createdAt: 'desc' }, }); }), });这里有两个设计点是项目后期沉淀出来的经验。
第一,每个 domain 都有独立的schema.ts,里面放 zod 的 input/output 定义。这样前端引入类型时不会把整个 router 都拖进来,bundle 体积友好,而且 schema 本身就是一种可执行的接口文档,前后端沟通成本直接归零。
第二,用output显式声明返回结构。很多人初期只写input不写output,这在简单场景没问题,但一旦数据库字段开始膨胀,前端很难发现“结构悄悄变了”。显式output相当于给接口立了一个合同,哪次后端违反了合同,编辑器会直接把错误标出来,这一条在超过十个人的团队里价值巨大。
3.3 鉴权中间件的分层:哪些 procedure 必须登录,哪些可以公开
业务系统不可能每个接口都登录,但也不可能每个都公开。t3code 里我们做了两个中间件:publicProcedure和protectedProcedure。前面代码里的protectedProcedure是这样实现的:
// src/server/api/trpc.ts import { TRPCError } from '@trpc/server'; const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: '请先登录' }); } return next({ ctx: { session: ctx.session, }, }); }); export const protectedProcedure = t.procedure.use(isAuthed);注意这里没有把用户信息直接从数据库查出来塞进session,只把userId传下去,真正需要用户完整信息时在 procedure 内部查。为什么?因为大部分接口用不到完整用户信息,提前查一次等于每多一层就多一次数据库压力。中间件应该只做“分流”,不做“搬运”。这个细节执行后,接口平均查询次数降了不少。
3.4 SSR 场景下 query 与 hydration 对齐的坑
tRPC 配合 Next.js App Router 时,最典型的坑是:服务端用createCaller调了一次接口拿数据,渲染出 HTML 后,客户端 hydration 时 React Query 的缓存 key 对不上,导致页面刚打开就发第二次请求,甚至出现闪烁。
原因在于服务端调用和客户端由浏览器触发调用,虽然都是同一个useQuery,但queryKey受输入参数和context影响。跨 SSR/CSR 时,tRPC 客户端实例要保证同一个queryKey拿到同样的数据序列化结果。解决办法是统一使用superjson作为 transformer,并保证服务端注入的初始数据在hydrate时使用相同 key:
// src/lib/trpc.ts import { createTRPCReact } from '@trpc/react-query'; import SuperJSON from 'superjson'; import type { AppRouter } from '@/server/api/root'; export const trpc = createTRPCReact<AppRouter>({ transformer: SuperJSON, });在服务端渲染时用createCaller直接拿数据,然后以trpc.dehydrate()的结果注入 React Query client,客户端直接hydrate。这样首屏就不会二次请求。这条经验我们从第三次踩坑后写进了项目的肥文档里,后面再没人翻过车。
4. 样式层治理:Tailwind 组件多了以后,class 就成了新的技术债
4.1 从“拼字符串”进化为“设计 token”
用过 Tailwind 的人都会有同一个感觉:写的时候很爽,维护的时候就头疼。说夸张一点,一个两百行的页面里可能有一百个 class,其中三分之一是方向和间距的组合。t3code 刚起步时也经历了这个阶段,后来我们做了两件事控制技术债。
第一件事,建立设计 token,别直接写魔法数值。在tailwind.config.ts里把颜色、间距、圆角、阴影都具象化为语义名称:
// tailwind.config.ts import type { Config } from 'tailwindcss'; export default { theme: { extend: { colors: { primary: { 50: '#eff6ff', ... 500: '#3b82f6', }, surface: { page: '#f8fafc', card: '#ffffff', }, }, spacing: { layout: '24px', control: '12px', }, borderRadius: { control: '8px', }, }, }, } satisfies Config;这个 token 体系定下来之后,不仅样式统一了,暗色主题也只是改配置里的色值就够了。后面我们整体切换品牌色,从决定到全量上线只用了半天——这在以前写 CSS 变量的时代至少得做三天。
4.2 组件封装边界:什么该抽,什么不该抽
Tailwind 默认让你在 JSX 里堆 class,但不代表每个按钮都要写成几十个 class 的复制粘贴。我们的经验是:可复用的最小 UI 单元必须抽出来,业务相关的东西留在页面里。
举个例子,t3code 里所有表单提交按钮统一是加载态 + 禁用态 + 主色底。我们封装了一个<SubmitButton>:
import clsx from 'clsx'; import { twMerge } from 'tailwind-merge'; type SubmitButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean; }; export function SubmitButton({ loading, disabled, children, className, ...rest }: SubmitButtonProps) { return ( <button type="submit" disabled={disabled || loading} className={twMerge( 'inline-flex items-center justify-center gap-2 rounded-control bg-primary-500 px-4 py-2 text-sm font-medium text-white transition-colors', 'hover:bg-primary-600 disabled:cursor-not-allowed disabled:bg-slate-300', className, )} {...rest} > {loading ? <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" /> : children} </button> ); }核心是用clsx+tailwind-merge处理 class 合并。这两个库做的事情很简单:前者处理条件拼接,后者处理后写的 class 覆盖先写的 class。没有tailwind-merge的话,外部传入的bg-red-500会和内部的bg-primary-500打架,最终生效哪个取决于 CSS 文件里的顺序,完全不可控。
至于更具体的业务卡片、表格、弹窗,我们倾向于在页面内组装,而不是全局抽象。因为业务组件常常两周变一个样,过早上抽象,后面每一次需求变更都在给“抽象层”打补丁,最终抽象层比业务代码还难维护。
4.3 暗色模式的实现不是简单的“换个 class”
暗色模式大家第一反应是dark:前缀 +next-themes,t3code 也这么做了,但踩过一个更隐蔽的坑:全局弹窗和 drawer 默认挂在 body 下,它们拿不到页面根节点上绑定的class="dark",导致暗色模式下弹窗依然是亮的。
解决办法是给弹窗类组件传入显式的themeprop,或者把darkclass 挂在document.documentElement上,确保全局都能感知。这个细节测试最容易忽略,建议在验收清单里专门列一条:“所有 portal 类组件在暗色模式下检查一遍”。
// components/global-drawer.tsx export function GlobalDrawer() { const { resolvedTheme } = useTheme(); return ( <DrawerRoot className={resolvedTheme === 'dark' ? 'dark' : ''}> ... </DrawerRoot> ); }5. 上线前盯住的三项数据:体积、响应与类型检查时长
5.1 构建产物体积
tRPC 最大的争议点在于“客户端会把所有 procedure 的代码打进 bundle 吗?”答案是:不会,只要你在服务端 router 的 import 路径上做文章。默认项目里AppRouter只是类型引用,运行时不会带入任何 handler 代码。但如果你把createTRPCReact写进页面组件,并且import { AppRouter } from '@/server/api/root不是一个import type,打包器就可能把服务端依赖一起带进来。
我们上线前用@next/bundle-analyzer扫过整体体积,一个典型的未优化后台页面 bundle 在 220KB 左右,排掉第三方库后,tRPC 相关的开销可以控制在 10KB 以内。需要盯住的是两个指标:
- 首屏 JS 总字节数,建议不超过 400KB,超过就说明某些页面把不该引的模块引了。
- tRPC 相关 chunk 体积变化,如果某次发布后明显变大,优先怀疑是不是把服务端代码塞进了客户端。
5.2 响应速度:不能只看接口耗时,要看查询数
tRPC 的优点是把后端逻辑写在一处,很快很容易出现“接口好写、SQL 难查”的问题。t3code 的订单列表曾出现一个接口耗时 800ms 的例子,链路里三张表连续关联查询,prisma 默认生成了 N+1 查询。解决方案分两步:
第一步,给 prisma 的 include 加take和orderBy限制,防止内部关联展开大量无用的数据。 第二步,对于长列表,改用轻量的findMany+ 内存里的映射拼装,减少 join 带来的重复数据。
通过这两步,接口从 800ms 降到 120ms。经验是:任何 tRPC procedure 的耗时超过 300ms,先怀疑不是网络带宽,而是 ORM 的查询计划跑偏了。
5.3 类型检查时长:类型安全是要付出编译代价的
tRPC 的类型推导在天花板配置下很复杂,导致tsc --noEmit变慢。t3code 中期,全仓类型检查从 8 秒慢慢爬到了 22 秒,这个膨胀速度对 CI 很不友好。
我们没有做任何“绕开类型安全”的妥协,只做了三件小事:
- 把
prisma generate生成的类型文件丢到.gitignore里,交给postinstall统一生成,避免大文件参与 diff。 - 把
tsc --noEmit限定在next.config.ts的webpack之外,用pnpm type-check单独跑。 - 把 CI 里类型检查和 lint 拆分执行,互不阻塞,可以让失败信息更早暴露。
效果是类型检查稳定在 15 秒以内,这个成本换来的是生产环境几乎见不到“字段类型错误”类的 bug,值。
6. 踩坑实录:三个让我排查到深夜的棘手问题
6.1 排查链路:从一段“看起来没问题”的代码到根因
t3code 上线第二周,有用户反馈“列表页偶尔白屏”,但接口 200、浏览器控制台也没有报错。我们前端查了三个小时没头绪。后来用 production 模式打开才发现,控制台有一条Hydration failed because the server rendered HTML didn't match the client的 warning。
往前回溯,这个问题的来源是某个页面的服务端数据里有Date类型字段,模板客户端渲染时调了一次toLocaleDateString(),而服务器和浏览器所在的时区不同,导致服务端渲染的文本和客户端第一次渲染的文本不同。React 严格模式下这种 mismatch 会直接把整个子树丢弃。
根因清楚了,修复很简单:把服务端查询结果里的Date提前格式化成字符串,或者用suppressHydrationWarning显式跳过文本节点的校验。我们对全项目审计了一遍,把模板里所有依赖new Date()的渲染都改成了从 API 拿格式化后的字符串。至此,白屏问题绝迹。
6.2 “类型明明对上了”但编译不过去
某个周末,同事改完一个接口,返回类型从orderSchema扩展了几个字段,但他所在的 feature 分支编译始终报 “Type X is missing the following properties”。我们两个一起看他的代码,逻辑完全没问题,类型看起来也对得上。
最后发现是分支合并导致node_modules/.prisma里的类型缓存是旧的,pnpm dev没有自动更新 Prisma Client。这种情况下tsc拿到的类型定义和运行时完全不一致,看起来就像 TypeScript 抽风了。解决方式是清掉缓存重新 install:
pnpm prisma generate后来我们在package.json里加了postinstall: "prisma generate",从流程上杜绝这种幽灵错误。这类问题最大的特点就是:你的眼睛盯的是代码逻辑,真正的敌人是构建缓存。
6.3 并发刷新时的 401 与 token 刷新竞态
另一个高频问题跟认证相关。用户点击刷新按钮,页面同时发出三个受保护的 tRPC query,此时 token 过期,三个请求同时返回 401。我们的客户端拦截器对每个 401 都去请求一次刷新 token 接口,结果后端收到三个刷新请求,只有一个成功,另外两个要么 409 要么 403,页面直接跳登录。
这个问题的本质是并发场景下未做请求去重,诱导多个刷新请求同时到达后端。修复方式是在客户端做 Promise 单例化:
let refreshPromise: Promise<string | null> | null = null; async function refreshToken() { if (!refreshPromise) { refreshPromise = fetch('/api/auth/refresh', { method: 'POST' }) .then((res) => (res.ok ? res.text() : null)) .finally(() => { refreshPromise = null; }); } return refreshPromise; }配合在 tRPC 链接的httpBatchLink里统一处理 401 重试,并发刷新问题就彻底消失了。现在看这段代码很简单,但当时分布式排查日志、翻后端刷新接口的时间加起来,整整一个晚上。
如果非要说 t3code 这个项目留给我什么,最大的体感并不是某个具体框架有多好用,而是“整条链路类型统一”带来的确定性。后端加字段、改返回结构,前端不用等联调就能知道影响面;写错参数名,编辑器直接报错,而不是等线上用户探索出来。这套心智体系才是 t3code 真正沉淀下来的东西,如果你想在团队里推 T3 这套实践,建议从一个小模块开始试点投影,拿到收益再铺开,远比全量推翻来得稳妥。