
在整理了大半年的代码碎片之后我把那些在多个项目里反复用到的东西抽出来做成了一套名叫 t3code 的项目骨架。它不是一个新框架也不是某个现成的脚手架而是基于 T3 Stack 这条技术路线沉淀下来的代码集合。只要你用过或者听说过 TypeScript、Tailwind CSS、tRPC 这几个词这篇博文应该能给你一些实实在在的参考价值。如果你正打算做一个中小型全栈应用困在各种重复劳动里或者一直在纠结前后端类型怎么才能统一那这篇文章就是写给你的。1. t3code 到底在解决什么问题1.1 为什么我会攒出这么一套代码我算是比较早接触 Next.js 的那批人前后做过几个中型业务项目有后台管理系统也有面向 C 端的落地页。折腾得多了就发现一个问题每一次新项目开工撬起来的重复代码量都高得吓人。请求封装、数据校验、错误处理、权限判断、日期格式化、金额展示这些东西每次都要重新写一遍。重新写倒也不是不行但麻烦的是每一次写出来的方案还不一样今天用 axios 封装明天用 fetch后天又换成了 react-query。团队成员交接的时候光理解这些“各自为政”的代码就要花不少时间。t3code 的出发点非常简单把我认为最合理的、经过多次验证的写法和约定沉淀成一套可以直接复制、直接演进的项目模板。换句话说它就是一个我自己的“最佳实践代码库”里面包含了统一的技术选型、标准的目录结构、可复用的工具函数以及一套前端和后端都能共享类型定义的开发模式。它面向的是中小型全栈项目尤其是那种需要快速上线、但又要保证代码不会越写越乱的场景。我必须说清楚一点t3code 不是一个包npm install 一下就完事了。它更像一个“起点仓库”你把它 clone 下来改掉项目名加上自己的业务代码就可以开始干活。它解决的不是“怎么搭建环境”的问题而是“项目搭起来之后代码按什么节奏长”的问题。1.2 t3code 的目标与边界不做什么很多技术人容易陷入一个误区觉得一套好的项目骨架就应该“什么都管”。在我看来恰恰相反一个骨架最需要明确的是“什么不该管”。t3code 的主要目标有三个方向。第一统一技术栈全项目只使用 TypeScript 这一门语言前端页面、后端接口、数据模型全部用 TS 表达消灭“前端讲 JavaScript、后端讲 Java”这种割裂感。第二统一数据流模式所有服务端操作的入口都收敛到 tRPC 的 procedure 上前端调接口像调用本地函数一样类型天然同步。第三统一代码组织方式组件怎么分目录、工具函数怎么写、页面逻辑放哪里都有明确约定新人看一眼就知道代码该往哪里塞。同时t3code 明确不做 UI 组件库你完全可以自己去接 shadcn/ui、Mantine 或者 Ant Design骨架不会干预你。它也不做完整的权限体系只提供一个基于 NextAuth 的基础登录链路业务上更复杂的角色权限需要你自己按业务去扩展。它更不是一个微前端框架也不打算解决多团队、多仓库协同的问题。边界划清楚之后项目才不会越搞越重。我见过太多骨架项目最后因为大包大揽变成了维护成本最高的那个代码库这是我最想避免的。2. 技术选型与设计思路2.1 为什么选 TypeScript 作为唯一语言T3 Stack 的核心是 TypeScript、Tailwind CSS、tRPCt3code 这个名字也是从这里来的。我在选型的时候最看重的一点就是“类型能不能打通”。这里用生活化的方式解释一下以前你写一个接口后端定义一个user对象前端再用 JavaScript 自己去写一个长得差不多的对象两边谁也不知道对方长什么样一旦接口字段改了前端往往等接口调通了才会发现报错然后对着控制台猜“字段名是不是拼错了”。这种痛苦做过联调的人都懂。TypeScript 把这个问题解决了一半它让前端也能有类型检查。但如果你只是把 TypeScript 当成“带类型的 JavaScript”来用那价值其实很有限。真正的价值在于前端、后端、数据库三边的类型能不能从一个源头出来。t3code 的答案是能。通过 tRPC 和 zod后端定义的一个 schema可以直接推导出前端调用时的入参类型和返回值类型前端里根本不需要维护任何一份接口定义文件。我不用再问同事“这个接口返回的字段叫什么”代码提示里直接就有。选 TypeScript 还有一个隐形的好处重构的时候胆子大。以前改一个字段名要全局搜索一遍字符串还怕漏掉。现在类型系统会像一张网一样帮你兜住改漏了的地方编译器第一个跳出来告诉你。这种安心感一旦体验过就很难回去了。2.2 Tailwind CSS 的取舍原子化与视觉一致性关于样式方案我在 CSS Modules、styled-components、Tailwind CSS 之间来回切换过几次最后在 t3code 里稳定到了 Tailwind。原因很简单项目里需要快速迭代而 Tailwind 的原子化类名让“写页面”变成了“拼积木”。大多数场景下我根本不用离开 JSX 去新建一个 CSS 文件响应式布局、暗色模式、悬浮效果都可以用类名组合直接表达。这种开发节奏对于小团队特别友好。但我也得提一下它的问题。如果你没有一个设计规范Tailwind 很容易让页面变成“彩虹色”。同一个按钮有人用bg-blue-500有人用bg-indigo-600看起来差不多其实色值不同。所以 t3code 里我强制约定颜色、圆角、阴影这些设计变量统一通过tailwind.config.ts里的 theme 扩展来定义业务代码里尽量使用bg-primary、rounded-card这类语义类名而不是直接写死颜色值。这样既保留了原子化的效率又不会在样式细节上失控。还有一个必须注意的坑Tailwind 的类名必须是完整字符串。如果你写className{bg-${color}-500}类名是动态拼接的Tailwind 编译的时候扫描不到样式就会莫名其妙地丢失。这个问题我在后面“常见问题”里还会专门讲。2.3 tRPC没有 API 文档的前后端直连在我接触过的所有全栈方案里tRPC 是让我觉得“最爽”的一个。它做的事情很简单让前端像调用本地函数一样去调用后端函数。传统 REST 模式下你要定义GET /api/users、POST /api/users这些路由然后前后端各维护一份文档。tRPC 模式下后端定义一个getUsers函数前端直接await api.user.getUsers()参数和返回值都是 TypeScript 类型编辑器里就能看到完整的类型提示不需要切换上下文去查文档也不需要手动维护接口类型。有人可能会问这和直接写一个 async 函数然后 import 进来有什么区别区别在于执行环境。tRPC 的函数跑在服务端它可以访问数据库、操作 session、读取服务端环境变量但对前端只暴露经过类型推导的“函数式接口”。也就是说你拿到了函数调用的体验但安全性依然保留在服务端边界之内。t3code 里我大量使用了 tRPC 的 protectedProcedure这个后面会详细说。需要提醒的是tRPC 并不适合对外提供 API如果你想给第三方开放接口还是老老实实走 REST 或者 GraphQL。tRPC 解决的是“前后端同一个团队、同一个仓库、同一个类型系统”这个场景这也是它在 t3code 里如鱼得水的原因。3. 核心模块拆解与实现细节3.1 请求层的封装从 fetch 到 tRPC procedure在传统的前端项目里请求层是一个很头疼的东西。你要处理fetch的 JSON 序列化、HTTP 状态码、错误体格式、超时、取消请求、loading 状态……每个项目一套封装每个封装都有自己的脾气。t3code 的做法是用 tRPC 的 procedure 把请求层统一收编。后端写业务逻辑时不再单独定义“接口”而是定义 procedure比如一个查询产品列表的 procedure// src/server/api/routers/product.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; export const productRouter createTRPCRouter({ list: protectedProcedure .input( z.object({ page: z.number().min(1).default(1), pageSize: z.number().min(1).max(50).default(10), keyword: z.string().optional(), }) ) .query(async ({ ctx, input }) { const products await ctx.db.product.findMany({ where: { name: { contains: input.keyword } }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, }); const total await ctx.db.product.count(); return { data: products, total, page: input.page, pageSize: input.pageSize }; }), });前端调用时不需要写任何 fetch 封装代码// src/app/products/page.tsxServer Component import { api } from ~/trpc/server; export default async function ProductsPage() { const { data, total } await api.product.list({ page: 1, pageSize: 10 }); return ProductTable data{data} total{total} /; }这段代码的好处是我不需要在前端关心 HTTP 请求是怎么发出去的也不需要在后端关心路由是怎么注册的。类型从productRouter.list的 input 一路推断到前端调用处中间没有任何手写类型。这就是 t3code 里请求层设计的核心思路把传输细节交给框架把业务逻辑留给自己。3.2 校验层的标准姿势zod 在 t3code 中的位置数据校验是另一个重复劳动重灾区。以前我在后端写一遍参数校验可能还得在页面上写一遍表单校验两边规则不一致就会出 BUG。t3code 使用 zod 做统一校验。zod 是一个 TypeScript 优先的 schema 声明库它最大的特点是一个 schema 同时承担运行时校验和类型推导两个职责。举个例子定义一个创建用户的 schema// src/server/api/routers/user.ts import { z } from zod; const createUserSchema z.object({ name: z.string().min(1, 姓名不能为空), email: z.string().email(邮箱格式不正确), age: z.number().int().min(18).optional(), }); export type CreateUserInput z.infertypeof createUserSchema;前面的z.infertypeof createUserSchema自动推导出了类型CreateUserInput你不需要手动再写一遍 interface。如果你改了 schema比如把email改成必填那么所有用到这个类型的地方都会跟着变编译期就能发现不匹配的调用。在 procedure 里zod 的校验会作为第一道关卡自动执行create: protectedProcedure .input(createUserSchema) .mutation(async ({ ctx, input }) { return ctx.db.user.create({ data: input }); }),如果前端传入的参数不符合 schematRPC 会直接返回校验错误不会进入到业务函数内部。这等于把“参数不合法”这一类最常见的错误挡在了业务逻辑的外面。我在 t3code 里约定所有对外暴露的 mutation 和 query都必须声明 input schema不允许出现裸的、不校验参数的业务函数。3.3 业务组件的三层分离组件怎么组织是很多项目早期看着没问题、后期改不动的重要原因。t3code 里我把组件分成三层展示组件、容器组件、页面组件。展示组件是纯函数只接收 props渲染 UI内部不调用任何数据请求。比如一个UserCard你给它一个user对象它就显示头像、名字、邮箱。这类组件放在src/components下面尽量无状态方便测试和复用。容器组件负责数据和交互逻辑它会去调用 tRPC 的查询或变更然后把数据传给展示组件。项目里通常以业务域来划分比如src/features/user下面放UserListContainer负责拉取用户列表、处理分页、控制 loading 状态。这些组件可以有自己的内部状态但不会直接写页面级路由。页面组件是路由对应的最外层它只负责把容器组件拼装起来以及处理页面级别的布局。比如src/app/users/page.tsx可能就只是UserListContainer /外面包一层页面标题。这套分层的意义在于业务逻辑和 UI 表现解耦。你想换样式改展示组件你想改数据逻辑改容器组件你想调整路由布局改页面组件。各改各的冲突极少。t3code 的约定是单向依赖页面依赖容器容器依赖展示不能反过来。3.4 工具函数的沉淀规则项目写多了工具函数会膨胀得很厉害。什么formatDate、formatMoney、debounce、getQueryParams几十个文件堆在一起最后谁也说不清哪些还在用。t3code 对工具函数有两条硬性规则。第一条纯函数优先。一个工具函数必须是不依赖全局状态、同样的输入一定得到同样输出的纯函数这样才方便测试和调试。第二条必须有类型定义且通过 zod 或 TS 编译器检查不允许出现一个定义在utils里、但调用时还要靠any去糊弄的函数。实际沉淀的时候我会先看这个函数是不是在至少两个不同页面里用到。如果只在一个页面里用过那就先放页面内部除非它有明确的通用价值否则不要急着抽到公共层。过早抽象和过度抽象都是负担。比如我经常用的一个格式化金额函数// src/lib/format.ts export function formatCurrency(value: number, currency CNY): string { return new Intl.NumberFormat(zh-CN, { style: currency, currency, }).format(value); } export function formatDate(input: string | Date): string { const date input instanceof Date ? input : new Date(input); return new Intl.DateTimeFormat(zh-CN, { year: numeric, month: 2-digit, day: 2-digit, }).format(date); }这类工具函数放进src/lib下按功能拆分文件不用一个文件塞一堆无关函数。我有一次从老项目迁移过来发现utils.ts有三千多行里面日期处理和数组去重混在一起改起来相当痛苦。所以 t3code 从一开始就要求一个文件一个职责函数命名尽量做到望文生义。4. 从零搭建 t3code 的完整过程4.1 初始化仓库与依赖安装如果你也想搭一套类似 t3code 的项目我建议直接使用官方推荐的方式起步。create-t3-app 会自动把 Next.js、TypeScript、Tailwind、tRPC、Prisma、NextAuth 组合好不需要手动一个个配。npm create t3-applatest执行过程中会问你几个问题比如要不要用 NextAuth、要不要用 Prisma、用不用 ESLint。我通常都会选上因为 t3code 的目标是开箱即用鉴权和数据库前面说得再天花乱坠不实际接上都是空中楼阁。数据库默认是 SQLite开发阶段很方便后面要换 PostgreSQL 也容易Prisma 里改一下 provider 就行。初始化完之后你会得到一个大致的目录结构prisma/ schema.prisma public/ src/ app/ components/ lib/ server/ api/ db.ts auth.ts trpc/ env.js middleware.ts这个结构比我早年自己手搭的干净多了。以前手动集成 tRPC 和 Prisma光把依赖版本对齐就要折腾一个下午create-t3-app 把这些细节都处理好了。如果你搭的时候遇到版本兼容问题比如 Next.js 和 tRPC 版本不一致可以优先查一下官方文档的版本要求不要在踩坑上浪费太多时间。4.2 配置数据库与鉴权初始化完成后先去修改prisma/schema.prisma这是数据模型的起点。t3code 里我建议先建立与业务强相关的核心模型比如 Product、User 的扩展字段等。下面是一个基础示例model User { id String id default(cuid()) name String? email String unique emailVerified DateTime? image String? products Product[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Product { id String id default(cuid()) name String description String? price Decimal db.Decimal(10, 2) ownerId String owner User relation(fields: [ownerId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt }改完 schema 之后执行迁移npx prisma migrate dev --name init这会在本地 SQLite 数据库里生成对应的表并自动生成 Prisma Client 的类型。紧接着执行npx prisma generate确保客户端类型最新这一步如果漏了后面写 tRPC 的时候ctx.db.product会一直飘红。NextAuth 的配置除了常规的密钥、回调地址最关键的是要把 Provider 信息填到.env里。create-t3-app 生成的.env里已经有DATABASE_URL和NEXTAUTH_SECRET、NEXTAUTH_URL等占位符你替换成真实值即可。t3code 里我用的是 GitHub Provider 和 Credentials Provider 组合GitHub 方便测试Credentials 方便做账号密码登录。如果你只是本地开发先用 GitHub 就够了。4.3 写第一个 tRPC 查询从产品列表开始数据库和鉴权就绪后我们来写第一个查询。先在src/server/api/routers/product.ts里定义 productRouter前面已经展示过来 list 的写法我这里补充一下注册到根路由的过程。// src/server/api/root.ts import { productRouter } from ~/server/api/routers/product; import { createTRPCRouter } from ~/server/api/trpc; export const appRouter createTRPCRouter({ product: productRouter, }); export type AppRouter typeof appRouter;这一步很关键AppRouter类型会被前端引用从而让前端 api 对象自动拥有后端全部 router 的类型。如果你新增了 router 却忘记在root.ts注册前端api.product就不会存在报错也会很明显。然后启动开发服务器npm run dev打开http://localhost:3000访问产品页面不出意外你会看到产品列表直接渲染出来了。整个过程没有写一条 HTTP 路由没有复制任何接口类型数据流从数据库到页面是直通的。这就是 t3code 里最核心的“类型直通”体验。4.4 前端接入与页面渲染t3code 里的页面渲染有两种方式。第一种是 Server Component直接在服务端调用 tRPC像前面展示的那样数据在服务端就绪后随页面一起下发适合首屏以展示为主的页面。第二种是 Client Component适合交互频繁的场景比如搜索、分页、筛选之后需要局部刷新。举一个客户端组件的例子产品搜索框use client; import { useState } from react; import { api } from ~/trpc/react; export function ProductSearch() { const [keyword, setKeyword] useState(); const [page, setPage] useState(1); const { data, isLoading, refetch } api.product.list.useQuery( { page, pageSize: 10, keyword }, { placeholderData: (prev) prev } ); return ( div input value{keyword} onChange{(e) { setKeyword(e.target.value); setPage(1); }} / {isLoading ? p加载中.../p : ProductTable data{data?.data} /} /div ); }注意到useQuery自动帮你管理了 loading、error、缓存你不需要手动写 useEffect 去发请求。这也是 t3code 选 tRPC 的另一个原因它和 react-queryTanStack Query深度集成数据请求的本地状态管理省掉了很大一部分代码。这里有个小细节placeholderData: (prev) prev意思是分页切换时先用上一页数据占位避免每次翻页都白屏闪一下。这种细节在真实产品里很影响体验建议保留。5. 常见问题与排障实录5.1 tRPC 类型不同步改完 router 后前端报错这是我被问到最多的问题。你在后端修改了productRouter比如给list增加了一个orderBy参数但前端api.product.list.useQuery调用处还是提示参数不对甚至过了一段时间都没有更新。根本原因是类型信息没有实时同步到前端。新一代 tRPC 版本通常利用 TypeScript 的引用来关联前后端理论上改动后端代码前端类型应该立刻生效。但如果你开着 dev server旧的类型缓存可能还在生效。我遇到这个情况时先确认src/trpc/react.ts里引用的AppRouter是从root.ts正确导入的然后重启 dev server基本就能解决。另外建议执行npm run type-check项目初始化时一般就配好了type-check脚本跑一遍就能看到具体是哪个文件类型不匹配。别急着“关闭类型检查来绕过”那会让整个 t3code 失去意义。5.2 Tailwind 样式不生效内容配置陷阱Tailwind 样式不生效最典型的场景是你新建了一个文件写了classNamebg-blue-500页面类名也在但样式就是没有。80% 的可能是tailwind.config.ts里的content数组没有覆盖到那个文件。create-t3-app 生成的配置默认是content: [./src/**/*.{js,ts,jsx,tsx}],如果你的页面写在src外面或者文件后缀不在扫描范围内Tailwind 根本不会生成对应的工具类。另一个高阶陷阱就是我前面提过的动态类名。你写className{text-${level}-500}Tailwind 静态扫描的时候找不到text-xxx-500这个完整字符串于是这些类名不会被打包进产物。解决办法有两个。要么把所有可能用到的类名完整写出来放到safelist里要么不要动态拼类名直接用一个映射对象把 value 映射到完整类名比如const colorMap { primary: bg-blue-500, danger: bg-red-500 }然后取映射值。我在 t3code 里强制约定样式类名必须是字面量完整字符串禁止动态拼接。5.3 热更新慢到底是谁的锅T3 Stack 项目用久了热更新变慢是一个很容易被忽视的问题。我在一个中大型项目里每次保存都要等三四秒严重影响节奏。排查了几天原因有三层。第一层是 Tailwind 的扫描范围太广如果你的content包含了大量的.md文件、node_modules下的某个目录编译器会被无效内容拖慢。第二层是 Next.js 的编译缓存当项目文件特别多时路由组件和 tRPC router 的重编译会比较重。第三层是 Prisma Client 每次 dev server 启动时都会生成一次虽然不频繁但每次生成都不算快。我的处理方式是Tailwind 的 content 只保留./src/**/*.{ts,tsx}排除测试文件和文档路由页面尽量用 Server Component减少客户端 state 导致的重渲染如果项目确实大到无法忍受把 tRPC router 按业务域拆成多个文件利用 Next.js 的增量编译减少单次编译范围。5.4 部署时最容易忽略的几个点t3code 部署到 Vercel 的时候有几个我反复踩过的坑。第一个是环境变量没配齐尤其是NEXTAUTH_SECRET本地可能因为用了默认值没事部署到生产环境必须显式设置否则登录功能会报错。第二个是数据库迁移没有执行。你用 SQLite 开发部署到线上还要继续用 SQLite那没问题但如果迁移到 PostgreSQL记得在构建前执行prisma migrate deploy并且在构建命令里加上 Prisma 生成步骤build: prisma generate next build第三个是网络访问数据库的权限。很多云数据库默认不允许外部 IP 直连部署时连不上数据库通常不是代码问题而是数据库白名单里没有放行 Vercel 的出口 IP。这些细节如果没经验容易卡住一整天。我还建议在部署前跑一遍npm run build把它当作最便宜的一次线上预演。构建能过至少说明类型、路由、API 编译这几大块没问题到了线上排错范围会小很多。6. 一些写在最后的经验回头看看t3code 最大的价值不是我用了多新的技术而是把一组“能减少内耗”的工具按合理的方式组合在了一起。TypeScript、Tailwind、tRPC、Prisma、NextAuth每一件单独拿出来都有人用过但真正把它们串成一条流畅链路的项目其实没有想象中那么多。我在最开始的时候也犹豫过要不要上这么“激进”的技术组合担心团队学习成本担心生态稳定性。使用下来的体会是前端的收益是立竿见影的尤其是在前后端联调这一环时间能省掉三分之一以上。如果你正在筹备新项目我的建议是从小处切入先搭一个最简的 t3code 骨架只写一个业务模块比如产品管理跑通注册、查询、鉴权、部署这一整条链路再决定要不要全面铺开。脚手架这东西只有在你真正理解每一步为什么这么设计的时候才能改出自己的风格否则只会变成另一个陌生的项目模板。最后分享一个小技巧在 t3code 里尽量贯彻“schema 优先”的习惯。凡是要写参数的地方先写 zod再写业务逻辑。这个习惯一旦养成你会发现自己写出的代码越来越有章法前后端之间的默契也会比靠着口头沟通建立起来的要稳固得多。