前阵子团队内部讨论新项目骨架时,又有人问起 t3code 这个名字——说真的,这个项目一开始只是我熬夜拼出来的个人脚手架,名字起得也随意:T3 是当时圈子里热门的 T3 技术栈(Next.js、tRPC、Prisma、Tailwind CSS)的简称,code 则是希望它在代码层面足够直白、足够类型安全。后来几个内部项目都拿它当底座,从概念验证一路跑到了生产环境。经常有朋友问 t3code 到底是什么、能解决什么问题、适不适合他们的项目,所以我把这段时间的完整思路和踩坑过程整理成文。如果你也在搭建新的全栈 TypeScript 项目(无论是个人项目还是团队业务系统),正纠结于脚手架怎么选、这套技术栈怎么组合,这篇文章应该能帮你省不少时间。
1. 从"没有趁手的脚手架"到 t3code:一个反复折腾后的产物
1.1 为什么现有方案总是差一点
明明生态里已经有 create-t3-app 这样成熟的项目脚手架,为什么还要再折腾一个 t3code?这是我被问过最多的问题。create-t3-app 的定位是"最小可用",它把 Next.js、tRPC、Prisma、Tailwind 组合起来,跑起来很快,但真的把它当生产项目的底座时,还有很多坑要自己填:登录注册没有、文件上传没有、部署配置没有、环境变量示例没有、权限模型没有。你当然可以一个个去集成,但这恰恰是每个新项目都要重复一遍的机械劳动。
另一个极端是公司内部脚手架。不少中大型团队都维护着自己的工程模板,里面绑定了内部组件库、埋点规范、权限审批流程、甚至 CI 流水线的特定命名规则。这种脚手架在团队内部效率很高,但搬到个人项目或者一个新的独立业务线时,往往要删掉一大半"公司特色"才能用,改起来比从零搭还痛苦。
所以我的真实诉求其实是:一个介于"官方最小样板"和"公司重定制模板"之间的基线版本,它自带登录、上传、部署、数据建模这些最通用的东西,但每一块都可以独立拆卸。t3code 就是从这种反复折腾里长出来的。
1.2 t3code 打算解决的核心问题清单
我当时给自己列了一个非常具体的问题清单,分享出来供大家参考:
- 一个新项目从 clone 到本地能跑,时间应该控制在十分钟左右,而不是半天。
- Git 仓库里应该有一套完整的示例页面,而不是只留一个 hello world。
- 类型安全要贯穿从数据库到组件渲染的整条链路,不能只在某个孤立的接口里体现。
- 鉴权、文件上传、部署等高频需求应该以"能用、可改、易拆"的方式预置,而不是把业务绑死。
- 配置文件要少而清晰,每个配置文件都有注释说明它为什么存在。
这个清单听起来不复杂,但真正落地时会牵扯到非常多细节。比如"类型安全贯穿链路"这一条,意味着 Prisma 生成的类型、tRPC 的输入输出类型、zod 的校验类型、React 组件的 props 类型必须形成一条闭环,任何一环脱节,最后的体验都会打折。再比如"易拆"这一条,要求模块之间不能深度耦合,用了 Auth.js 就不会把 Next.js 的中间件、Prisma 的 User 模型、前端 Session 判断全部写死。所以 t3code 与其说是一个脚手架,不如说是一套"被验证过的技术栈组合方式",代码只是这套组合方式的可执行表达。
2. 技术选型复盘:T3 栈每个成员都在扮演什么角色
2.1 Next.js App Router 作为应用层骨架的取舍
选择 Next.js 其实是一个"既要又要"的结果。我希望项目同时具备前后端能力、服务端渲染、良好的 SEO、以及足够的生态扩展空间。Next.js 的 App Router 把这些能力统一到了一个目录结构里,app目录下既可以放页面,也可以放 API 路由;服务端组件在默认情况下把数据请求放在服务器完成,首屏的性能和页面的安全性都更好。
取舍上需要提醒的是,App Router 的学习曲线比 Pages Router 陡峭一些。刚接触async function Page()这种写法时,很容易在"我到底应该在服务端拿数据,还是应该在客户端用 fetch"之间纠结。t3code 的做法是明确给出规则:页面初始化需要的数据走服务端调用,用户交互后的变更走 tRPC mutation,这样团队成员就不需要每次做架构决策,模式统一之后代码也好 review。
如果你问为什么不用 Vite 或者 Remix:Vite 的 SPA 模式做纯前端体验很棒,但全栈项目还需要 SSR、路由规范、服务端数据获取的统一方案;Remix 本身也优秀,但在周边生态、招聘、社区资料的丰富程度上,Next.js 仍然是我更稳妥的选择。这套模板的核心目标是"让团队新成员快速上手",那么生态成熟度就必须排在决策因素前列。
2.2 Prisma 与 PostgreSQL:数据层的类型闭环
数据库选了 PostgreSQL 而非 MySQL,主要看重它的扩展性:JSONB 字段、数组类型、全文检索、行级安全这些能力在业务复杂起来之后非常有用,而 Prisma 对 PostgreSQL 的支持也最完善。
Prisma 的核心价值在于 schema 文件作为单一事实来源。你可以把它理解成"数据库建模的 TypeScript":定义一个Post模型,npx prisma migrate后数据库表结构有了,npx prisma generate后类型也有了。这几件事是同源的,所以不会出现"数据库字段改了,代码类型忘了升级"的错位感。这个体验在传统 ORM 里很难找到替代,也是 t3code 敢把"类型安全"当成卖点的底气。
这里也给一个建议:不要把 Prisma 当成传统 ORM 来用。它没有暴露完整的底层查询能力,一些复杂查询(比如深层递归、窗口函数)还是需要手写 SQL,但这对于绝大多数业务系统来说是值得的,因为换来的是类型安全和开发效率。如果你实在需要更轻的查询体感,Drizzle 也是一个方向,但它在 schema 迁移和类型生成的自动化程度上还差点火候,所以我仍然把 Prisma 作为默认选项。
2.3 tRPC 取代 REST 后,前后端边界长什么样
tRPC 是 t3code 里最让前后端协作舒服的一环。传统 REST 接口调试时,前后端各自维护一份接口文档,字段改了要手动同步,漏改就出 bug。tRPC 的思路是:后端导出一个 router 对象,前端直接导入它的类型,于是 client 调用的参数、返回值、甚至抛出的错误类型都是编译期强约束的。字段名拼错了、参数类型不对,根本编译不过去,而不是跑起来之后才被接口文档坑。
相比 GraphQL,tRPC 不需要引入额外的 schema 语言和学习成本,也就没有 codegen、缓存策略这套复杂体系。它的代价是:它对"全栈 TypeScript"场景要求比较高,如果前端不是 TypeScript,或者前后端完全分离成两个独立仓库,tRPC 的优势就会减弱。t3code 的使用场景里,前后端在同一个仓库中,这个选项就非常合适。
可以看一下项目目录结构,基本能表达出"类型从 schema 流出,router 在中间,页面消费"的链路:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ │ │ └── db.ts │ └── trpc/ │ ├── client.ts │ └── server.ts这个结构在物理层面约束了每个模块的位置:数据库访问归server/db.ts,路由归server/api/routers,页面组件只负责调用。谁越界了,code review 时一眼就能看出来。
3. 类型安全下的数据链路:从数据库到组件一次走通
3.1 一个最小业务模块的完整落地
理论讲再多不如落地跑一遍。用一个最常见的"帖子列表 + 创建帖子"模块来说明 t3code 里数据是怎么流动的。
先定义 Prisma schema:
model Post { id String @id @default(cuid()) title String content String author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }然后定义 tRPC router,注意 zod 同时承担了输入校验和类型推断:
import { z } from "zod"; import { publicProcedure, router } from "../trpc"; export const postRouter = router({ list: publicProcedure.query(({ ctx }) => ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: 20, }) ), create: publicProcedure .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.user.id, }, }); }), });在服务端组件里读取初始列表:
export default async function HomePage() { const posts = await api.post.list(); return ( <ul> {posts.map((post) => ( <li key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </li> ))} </ul> ); }在客户端组件里提交创建表单:
const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => utils.post.list.invalidate(), });从数据库记录到页面列表,每一层拿到的类型都来自同一个源。这里最重要的体验是:当你把post.title改成post.titl时,IDE 会立刻画红线,而不是等编译或者等线上报错。类型的闭环不是一个"高级特性",它是日常开发里每天都在省时间的隐形助手。
3.2 输入校验与错误处理:zod 如何成为 tRPC 的一部分
输入校验很容易被忽略。很多项目字符串长度、枚举值、分页参数都在前端验一遍,后端再手写一遍,两套逻辑迟早不一致。t3code 里用的 zod + tRPC 组合,zod schema 既是运行时校验规则,又是 TypeScript 类型来源。也就是说,你写了z.string().min(1).max(100)之后,后面的input.title在类型上就已经是string,长度约束也在运行时生效。
错误处理也会因为类型对齐而变得简单。tRPC 允许自定义 errorFormatter,把 zod 校验错误映射成结构化字段错误:
const errorFormatter = ({ shape, error }) => { if (error.code === "BAD_REQUEST" && error.cause instanceof z.ZodError) { const fieldErrors = error.cause.flatten().fieldErrors; return { ...shape, data: { fieldErrors } }; } return shape; };这样前端拿到的错误信息就是精确到字段的:"title 长度不能超过 100"、"content 不能为空",而不是一条"请求失败"的废话。表单组件可以直接把这些错误渲染到对应输入框下面,不需要前端再猜后端的校验规则。
3.3 服务端组件与 tRPC 的协作边界
App Router 下最容易混乱的问题:服务端组件能不能用 useQuery?不能。服务端组件没有客户端运行时,自然不能使用 React Query 的缓存和订阅能力;而客户端组件直接用服务端数据又会削弱 SEO 和首屏性能。t3code 给出的边界规则很简单:
- 首次渲染需要的数据,在 Server Component 里通过
api.<router>.<procedure>()直接取。 - 用户点击、提交表单后的动态变更,走客户端组件里的
useMutation。 - 某个组件既需要初始数据又需要动态刷新,就拆成"服务端取初值 + 客户端接管更新"两层。
这个边界不是拍脑袋定的,它和 Next.js 的渲染模型强相关。我见过不少团队在 App Router 下写出了"客户端组件里 fetch 接口再 setState"的老写法,等于主动放弃了服务端渲染的所有优势。按上面的规则来,每个页面组件都能明确自己该做什么,代码 review 时少很多争论。
4. 鉴权、文件上传与部署:模板里最容易打脸的三个环节
4.1 Auth.js 集成:session 与数据库会话的同步问题
t3code 预置了 Auth.js(NextAuth),但集成时踩了一个很现实的坑:只配置 JWT session 是不需要数据库额外模型的,一旦切到 database session,Prisma schema 里少了 Account、Session、VerificationToken 三个模型,登录直接报错。解决方案是提前在 schema 中加上 Auth.js 官方要求的这几个模型,并做好外键关系。这个坑之所以高发,是因为官方 starter 为了"最小可用"默认用 JWT,但真实业务想做多设备登录管理、会话撤销时,数据库 session 几乎是必须的,而这时文档里的"可选"配置变成"必选",schema 却没跟上。
另一个容易忽略的点是NEXTAUTH_SECRET。开发环境下不配置也能跑,但部署到线上少这行环境变量,session 就会诡异失效,表现为"登录成功但刷新后立即退出"。这类问题排查起来非常隐蔽,因为本地完全正常,只有线上复现。后来我在.env.example里把NEXTAUTH_SECRET、NEXTAUTH_URL都列出来并加了注释,才彻底避免团队新人在这个环节浪费半天。
4.2 文件上传后的静态资源路径与权限控制
文件上传是模板中另一个容易"看起来能用、实际很危险"的部分。如果直接把上传文件存到public/uploads,用户上传了什么,搜索引擎就能直接公开访问到什么,这个坑在真实项目里出过事。t3code 的建议是:开发环境把上传文件放在项目私有目录,通过受控 API 返回文件流;上生产环境时直接切换到对象存储,数据库只存对象 key,前端拿到预签名 URL 再展示。这样权限控制的逻辑不在应用代码里散落,而是在"文件存储层"统一完成。
这里要特别说明一个取舍:本地目录存文件虽然简单,但实例一多、文件一多就不好扩展;对象存储则有成本和学习成本。t3code 的处理方式是抽象一个storage接口,本地实现和对象存储实现都实现了同一组方法,业务代码只依赖接口。这样开发环境跑得舒服,生产环境也能平滑切换,不会被存储方案绑架。
4.3 从本地到容器的环境变量管理
环境变量的问题永远不在本地,而在环境切换。t3code 提供了一个.env.example,把每个变量都写了注释,同时给出 Docker Compose 示例:
services: db: image: postgres:16-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: t3code ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:CI 部署时再用平台自带的 secrets 注入真实变量。这里有一个最容易被忽略的纪律:.env绝对不要提交到 Git 仓库,.env.example每次修改环境变量时要同步更新。我见过太多项目因为.env.example和实际代码不一致,导致新成员本地跑起来反复报错,最后才发现是示例文件里写着旧变量名。模板类项目最不该犯的错,就是文档里的东西跑不起来。
5. 踩坑实录:我在这套模板里栽过的跟头和完整排查链路
5.1 Prisma 连接池在 Serverless 环境下的崩溃排查
这个坑发生在上线第二天。现象是线上时不时报PostgresError: sorry, too many clients already,重启后又能撑一阵,然后再次爆发。第一反应是数据库 max_connections 不够,但把连接数调大后问题依旧。后来看 Prisma 文档才意识到:Serverless 环境每个实例都有独立的 Node.js 运行时,如果每次创建 PrismaClient 时都新建连接,实例数量一多,连接池的上限马上被打穿。
排查的完整链路是:查数据库连接数 → 看监控确认连接来自多个实例 IP → 顺着这个现象去找 PrismaClient 的初始化位置 → 命中根因。解决方法是两条路:一是对连接进行池化,比如通过 PgBouncer 代理;二是在 Prisma 的 datasource url 上配置适合连接池的参数,并确保全局只复用同一个 PrismaClient 实例。这个过程花了大概一个下午,事后看起来路径清晰,但当时每一步都像在猜,因为现象和原因的关联并不直观。
5.2 tRPC query 缓存不更新的根因定位过程
另一个高频问题是"mutation 成功了,但列表页面不刷新"。开发模式可能偶尔正常,生产环境稳定复现。排查链路:先确认 mutation 确实返回了成功 → 再看 React Query 的 staleTime 和 gcTime 配置 → 最后发现问题是 mutation 成功后根本没有调用 invalidate。
修复方案很简单,在 mutation 的 onSuccess 里加上一行:
const onSuccess = () => { utils.post.list.invalidate(); };这里要提醒一点:invalidate 的 key 和 query 的 key 必须完全一致。如果 query 里用了带input的对象,比如api.post.list.useQuery({ page: 1 }),invalidate 时也要带同样的{ page: 1 },否则看起来调用了,但实际上没有清除对应缓存。这个细节在文档里只提了一句话,但实际踩到时,你会对着 React Query DevTools 怀疑半天人生。
5.3 Tailwind 类名覆盖的"权重陷阱"
这个坑不算严重,但遇到时很困惑:自定义一个 Button 组件,默认bg-blue-600,外部传入className="bg-red-500",结果颜色还是蓝色。原因是 Tailwind 生成的 CSS 顺序和 HTML 中类名顺序无关,同名工具类的优先级取决于它们出现在 stylesheet 里的先后位置,而不是 className 字符串里的顺序。
解决方法是引入tailwind-merge,它能把冲突的类名合并成最终结果:
import { clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }然后用cn("bg-blue-600", className)来拼接。这件事看起来只是一个小函数,但如果没有它,组件库的可复用性会大打折扣。尤其是团队里多人同时写样式时,没有cn统一入口,迟早会出现"传了类名却改不动样式"的灵异事件。
6. 给想直接上手的团队的三条建议
6.1 先跑 demo 再取舍,别迷信全家桶
t3code 无法避免所有团队的偏好差异。有人喜欢用 Drizzle 替换 Prisma,有人希望把 Auth.js 换成自己的内部登录,这都合理。我的建议是:先按默认技术栈完整跑通一个 demo 页面,再决定保留哪几层。模板最忌讳的是"别人说好就全部收下",技术栈是要为自己的团队用的,不是用来展示的。跑 demo 的过程会让你真正感受到 tRPC 的类型安全值多少钱、Tailwind 的样式覆盖有多痛,然后你再做裁剪,会理性得多。
6.2 模板是起点,不是终点
我把 t3code 当作团队技术基线的"第一个版本",而不是"永远照抄的答案"。每次在真实项目里踩到新的坑,我会把解决方案回写到模板里,比如加上新的错误处理示例、补上某个部署配置、优化一下路由组织方式。这样模板会随着真实需求演进,而不是变成一个落满灰尘的静态仓库。模板的真正价值不在于开箱即用,而在于它能沉淀团队对技术栈的理解,成为后续项目的"活文档"。
6.3 文档与示例代码要跟上
最后也是我认为最容易被忽略的一点:模板的成本主要不在开发,而在维护。如果模板里没有 README、没有.env.example、没有每个关键文件的功能注释,三个月的团队新人看到它只会有一种感觉:恐惧。所以我每次更新 t3code,都会顺手把文档一起更新。因为脚手架本身解决的是"怎么起步"的问题,而文档解决的是"怎么理解"的问题。一个只有代码没有文档的模板,和一份只有结论没有推导过程的笔记一样,保鲜期都很短。
如果你问我现在让我从零再搭一次,我大概率还是会从头写一个类似 t3code 的东西——不是为了折腾,而是这个过程逼着我想清楚每一个技术选型背后的理由:数据库为什么选它、鉴权放在哪一层、类型为什么要贯穿到底。这些决策能力,比最终那套代码本身值钱多了。