“t3code”这个名字听起来像个玩具项目,但对我来说它是一套从零到生产环境完整跑通的全栈样板:技术底座选的是 T3 Stack,也就是 Next.js + TypeScript + Tailwind CSS + tRPC,再配上 Prisma 和 NextAuth。说实话,第一次听到“T3”这个组合概念时,我第一反应也是“又有人造概念了”。但等我自己把项目做完、上线、又迭代了两个月之后,想法完全变了——这套组合最厉害的地方不是某个框架多强,而是把全栈开发里的“接线成本”几乎消灭干净。下面这些内容,是我从选型纠结、脚手架搭建、核心功能实现、踩坑排雷到部署上线的完整记录,适合正在学 T3 的中级前端,也适合被接口联调逼疯的团队。
1. 为什么我会把全栈项目押在 T3 这套组合上
先交代背景。t3code 这个项目的初衷,是一个人维护一个小型代码片段工具站:用户登录后,创建自己的代码片段,给片段打标签,通过搜索快速找到内容。听起来简单,但以前做这类全栈项目,光“前后端类型不一致”就够让人心累。接口文档写的是string,返回的是null,前端代码里全是防御式判断。所以这次选型我给自己定了三条硬标准:
- 类型系统必须贯穿前后端,不能各写一份然后祈祷对得上;
- 页面渲染要兼顾开发效率和 SEO,不能什么都是客户端空转;
- 样式方案要能在不切文件的前提下快速迭代,最好一条命令起服务就完事。
一圈看下来,符合这三条的组合,恰好就是 T3 Stack 那几个组件。
1.1 先弄清楚 T3 本身的定位
如果把 T3 当成一个“框架”,理解就偏了。T3 更像一套社区验证过的工程默认值,核心是让 TypeScript 的类型往两个方向自然延伸:一是通过 Next.js 在 React 组件里延伸,二是通过 tRPC 在前后端 API 调用层面延伸。在这套默认值里,数据库访问层用 Prisma,查询结果是类型安全的;API 层用 tRPC,服务端 router 和客户端调用共享同一套类型定义;UI 层用 Tailwind 的原子类,基本不需要写独立的 CSS 文件。
用一句话概括:T3 不负责帮你写业务,但它把业务之外那些“接口定义、请求状态、类型同步”的杂活全部压到最低。t3code 选择这套组合,本质上是想拿最少的心智负担,换最高的类型确定性。
1.2 对比了 REST 和 GraphQL,为什么还是选 tRPC
这里说一下我的筛选过程。REST 很容易理解,但类型维护成本高——就算用 OpenAPI 生成类型,实际使用中的 pending、error、data 状态依然要自己在客户端反复处理。GraphQL 的类型安全确实好,但为了一个中小型项目引入一整层 schema 和 resolver,我个人觉得偏重。tRPC 刚好卡在两者之间:它不需要定义一套独立的接口描述语言,类型直接从服务端路由的函数签名推导出来,客户端拿到的就是一个带完整类型的调用对象。
拿我实际感受做个对比:
| 方案 | 类型维护成本 | 学习成本 | 查询灵活性 | 上手速度 |
|---|---|---|---|---|
| REST + OpenAPI | 中等,要维护多份产物 | 低 | 一般 | 快 |
| GraphQL | 低,自动生成 | 高 | 强 | 慢 |
| tRPC | 极低,自动推导 | 低 | 中等 | 快 |
对 t3code 这种业务逻辑为主、查询形态相对固定的工具站来说,tRPC 的“低负担”优势非常明显。它的查询写法看起来像本地函数调用,但实际走的是真实网络请求,这个抽象让前后端协作的脑负担小了很多。
1.3 诚实的边界:T3 不适合什么
边界也得说清楚。如果你要做的是公共 API 开放平台,需要给完全陌生的第三方客户端调用,我仍然建议走 REST 或 GraphQL,因为 tRPC 的调用形态是 TypeScript 函数调用,对非 TS 消费者不友好;如果你的团队完全没有 TypeScript 基础,学习曲线会集中堆在最开始两周;如果项目需要大量复杂的动态查询和灵活字段裁剪,GraphQL 还是更合适。t3code 选择 T3,是我在“既要开发效率、又要类型安全、又不想维护协议层”的前提下做的一次明确取舍。
2. t3code 的项目骨架:脚手架勾选与目录规划的决策记录
2.1 create-t3-app 初始化时组件怎么选
我直接用了 create-t3-app 初始化,勾选组件时把 Tailwind、Prisma、NextAuth、tRPC 全部打开。这里有个小建议:如果是团队第一次跑,建议把 ESLint 和 Prettier 也勾上,T3 脚手架默认生成的 lint 配置会在你写出隐患代码时提前报错,比如未使用的变量、危险的any类型。
初始化完成后,我特意翻了一遍生成的项目结构,发现它把服务端逻辑和客户端调用切得很干净:src/db.ts负责 Prisma 客户端,src/trpc/trpc.ts负责 tRPC 初始化,src/server下面放 router。这种结构不需要自己发明,脚手架已经给了最佳实践,我们要做的就是别破坏它。
2.2 src/server 目录是怎么组织的
t3code 的 router 目录最后长这样:
src/server/ auth.ts # 权限相关 middleware api/ root.ts # 汇总所有子 router code.ts # 代码片段相关 tag.ts # 标签相关每个子 router 只暴露自己的 procedures,不越界访问别的模块。当时我对“一个业务域一个 router 文件”这个约定没有太深体会,但当代码片段、标签、用户三个模块各自长出独立逻辑时,这种划分直接决定了后续维护体验。一个模块要改,打开对应文件的成本是零,不用在几个巨型文件里来回翻跳。
另外,我在src/trpc/trpc.ts里除了保留脚手架默认的createTRPCRouter和publicProcedure,还加了protectedProcedure——它内部通过 middleware 判断 session,没有登录直接抛UNAUTHORIZED。这个设计后面在核心链路里帮了大忙,所有需要登录的接口都只需要换一个前缀。
2.3 环境变量与 context 的类型细节
create-t3-app 生成的.env.example里有DATABASE_URL、NEXTAUTH_SECRET等。这里有个容易忽略的关键点:默认生成的 trpc 初始化方式是initTRPC.context().create(),在 App Router 模式下 context 的构建位置必须写对。如果 context 里读不到 session,后面所有受保护的路由都会静默报错。
我当时为了这个问题查了很久,最后发现是 NextAuth 的 secret 没配置,导致 Server 端 session 一直为空。这个问题的典型特征是:开发环境偶尔正常,部署后概率性失效。建议第一次跑通项目时,先写一个测试接口把ctx.session打印一遍,确认 context 通了,再做业务开发。
3. 核心链路拆解:Next.js 页面、tRPC query、Prisma 读写怎么串起来
这一章是重头戏。t3code 用户的核心路径是:登录,浏览自己的代码片段列表,点开片段,编辑,保存。这条链路里,数据模型在schema.prisma里定义了CodeSnippet表,Prisma 客户端负责查询,tRPC router 里写一个getAll的 procedure,前端页面通过自动生成的apihelper 调用useQuery,拿到的 data 是完整类型推导过的。
3.1 从数据库到 UI 的全链路类型安全
这里我贴一段真实写法。服务端 router 的代码片段如下:
// src/server/api/code.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const codeRouter = createTRPCRouter({ getAll: protectedProcedure.query(async ({ ctx }) => { return ctx.db.codeSnippet.findMany({ where: { authorId: ctx.session.user.id }, orderBy: { updatedAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string(), tagIds: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.codeSnippet.create({ data: { title: input.title, content: input.content, language: input.language, authorId: ctx.session.user.id, tags: input.tagIds ? { connect: input.tagIds.map((id) => ({ id })) } : undefined, }, }); }), });前端调用是这样:
"use client"; import { api } from "~/utils/api"; export function CodeList() { const { data, isLoading } = api.code.getAll.useQuery(); if (isLoading) return <div>加载中...</div>; return ( <ul> {data?.map((item) => ( <li key={item.id}>{item.title}</li> ))} </ul> ); }注意protectedProcedure后面,ctx.session.user一定是存在的,不需要再做空值判断。这在代码里看起来只是少了几行防御,但整个项目加起来省出的分支判断非常可观。
3.2 mutation 之后列表如何自动刷新
说一个最爽的体验。t3code 里我创建片段后,列表要立刻出现新数据。以前写 React 应用,我得先改本地 state,或者手动 refetch。用 tRPC 的 react-query 集成后,我只需要在 mutation 成功回调里做一件事:
const utils = api.useUtils(); await api.code.create.mutateAsync(input); utils.code.getAll.invalidate();invalidate()会把所有code.getAll的缓存标记为失效并自动重新拉取。这意味着增删改后列表同步的逻辑从应用代码里彻底消失了,前端状态管理可以做得极简:服务端数据永远是来源,本地只留 UI 临时状态。
3.3 校验、错误处理与通用模式
tRPC 的 input 用 zod 做验证后,有个很棒的附带收益:前端可以复用同一套 schema。t3code 里我建了src/common/schemas.ts,前后端共同引用。每个 procedure 的入参约束都写清楚,前端代码就不需要再做一次重复验证,提交时直接透传,错误交给 tRPC 的处理层统一拦截。
错误处理我习惯在每个页面级 query 里加一个onError回调,把TRPCError的 code 映射成用户能看懂的中文消息。比如UNAUTHORIZED对应“登录已过期,请重新登录”,CONFLICT对应“记录已存在”。底层错误不要直接抛给用户,这是经验之谈。
4. 我在 t3code 里踩过的坑:逐条排查的心路历程
4.1 从 tRPC v10 升 v11 时的“类型爆炸”
项目初期我跑的是 tRPC v10,后来为了用上 App Router 更顺畅的 server integration,把依赖升级到了 v11。结果一升级,几乎每个页面都爆出几十个类型错误。排查之后发现问题集中在初始化模式的变更上——v11 里initTRPC变成了initTRPC.context().create(),并且推荐在server.ts里用createTRPCContext统一管理 context。这里我建议升级时一定要先读官方迁移指南,别直接改版本号。我花了两天处理这个问题,最后把 trpc 初始化部分整体重写才解决。
这里复盘一下,最坑的不是 API 名字变了,而是旧的初始化方式不会立刻报错,只是类型推导全部变成any,很多调用处的错误信息会指向无关的地方。遇到这种情况,先查trpc.ts和context.ts,不要漫无目的地去改业务代码。
4.2 Server Component 里调 tRPC 的限制与正确做法
Next.js 的 App Router 下,Server Component 不能直接用 hooks,但这不代表服务端不能访问数据库。我用的是 tRPC server 的createCaller方式,在服务端直接调用 procedure。这里有个细节很容易忽略:createCaller每次都要传 context,如果 context 依赖当前请求的 session,就必须在请求作用域内创建 caller,不能把它放到模块顶层。
我第一次写的时候把 caller 放在文件顶层,结果线上所有请求都拿到了同一个 context,session 永远为空。排查思路是加日志,打印每次调用的 session 值,然后发现所有请求共用一个实例。这个问题的本质是“模块级别缓存”,在 Node 服务里很隐蔽,建议把它写进团队的注意事项里。
4.3 Prisma 在 serverless 环境的连接问题
开发环境下一切正常,一部署到 Vercel,偶尔会出现查询非常慢的情况。后来看日志,发现是 Prisma 连接数在 serverless 冷启动时被耗尽。我用的是托管 Postgres,本身有 serverless 兼容,但连接池没有配置好。最后通过在 datasource 上调整连接参数,并在数据库端开了连接池才解决。
这个问题在本地几乎不可能复现,属于典型的“本地跑得好好的,一上生产就打脸”。建议部署前先做一次并发小压测,不然用户量一大,冷启动加上连接瓶颈会直接拖垮页面。
4.4 Tailwind 类和自定义组件的样式拉锯战
Tailwind 用起来很快,但如果你在封装自定义组件库,会遇到“加了!important也覆盖不了”的尴尬。我在 t3code 里封装了一个Input组件,在某个页面用 className 传了颜色类,怎么都不生效,排查半天发现是 CSS 顺序和 specificity 的问题。解决办法很简单:自定义组件库的样式不要放在 Tailwind 的@layer components里,否则会被 utilities 层的原子类以更靠后的优先级吃掉。
| 坑点 | 表现 | 根因 | 解决 |
|---|---|---|---|
| tRPC 版本升级类型爆炸 | 大量无关报错 | 初始化模式变更 | 重写 trpc 初始化 |
| Server Component 会话为空 | 受保护接口全部失效 | caller 模块级缓存 | 请求作用域内创建 caller |
| Prisma serverless 慢查询 | 冷启动后偶发超时 | 连接池配置不足 | 调整连接参数、开启连接池 |
| 自定义组件样式覆盖失败 | !important 无效 | layer 优先级问题 | 样式移出 @layer components |
5. 类型安全的“售后”体验:重构、维护与测试
5.1 改一个数据库字段,编译器帮你兜底
t3code 迭代到第二版时,我把CodeSnippet的authorId从必填改成可选,为了支持匿名导入功能。这个字段在十几个页面里都被使用。以前写项目,遇到这种改动估计得全局搜索来回确认。这次我改了schema.prisma,运行prisma migrate dev,然后启动 dev server,编译器把每个使用user.id的地方标红,我照着错误一个个改,四十分钟完成,没有遗漏。这种体验一旦试过,就很难回去了。
prisma migrate dev生成的 SQL 还可以审查一下,特别是涉及字段类型变更的,比如String改成Int?,里面可能包含数据清理逻辑。不要无脑执行迁移脚本,这是我在生产环境吃过亏后养成的习惯。
5.2 React Query 自动缓存失效,状态管理大幅简化
前面提到invalidate()的做法,这里展开讲一下它带来的连锁影响。因为每个 query 都自动生成 key,mutation 成功后只要对对应 key 做一次失效,列表就会自动重新拉取并渲染新数据。这意味着我在 t3code 里完全没有手动管理“增删改后同步列表”的逻辑。对一个单人维护的项目来说,省掉的代码量比想象中大得多。
我甚至把“加载更多”这种分页逻辑也交给 React Query:修改getAll的 input 参数,传入page和pageSize,query key 会自动带上这些参数,翻页时新数据自然缓存成独立条目。
5.3 测试 tRPC route 的轻量方案
测试上我没有上重框架,用的是 vitest 直接调 tRPC 的 caller 跑 procedure。核心 mock 点是 context 里的 db 和 session。tRPC 的测试成本低在机制上——procedure 只是普通函数,不需要起 HTTP 服务。想对某个 route 做接口测试时,直接构造 context 再调用 caller:
import { createCaller } from "~/server/api/root"; const caller = createCaller({ session: { user: { id: "test-user" } }, db: mockDb, }); const list = await caller.code.getAll(); expect(list).toHaveLength(2);这个方式比启动整站快得多,适合做核心业务链路回归。界面交互部分我另用 Playwright 补了少量端到端用例,只覆盖登录、建片段、搜索三条关键路径,维护成本可控。
6. 从 t3code 到生产环境:部署记录与性能观察
6.1 Vercel 部署时的构建坑
项目上线遇到了两个问题。第一个是 Prisma 在构建时找不到DATABASE_URL,需要在 Vercel 的项目设置里把环境变量配上,并且把prisma generate放在构建命令之前。第二个是某些依赖需要编译原生代码,在 lambda 环境容易失败,最后给 serverless 函数调整了 Node 版本才稳定。
还有一点:如果数据库改动引入了新一轮 migration,Vercel 构建不会自动跑prisma migrate deploy。我是把迁移操作放在 CI 里,发布前手动执行一次。这个步骤漏掉的话,线上代码和数据库 schema 对不上,页面一样会 500。
6.2 真实性能面板数据
部署后我打开 Vercel 的监控面板:首页 SSR 响应时间大约 120ms,纯客户端页面的 TTI 在 600ms 上下。对一个小型工具站来说这个数据够用。后来我发现,把依赖 tRPC 数据获取的页面标记为 client navigation,能减少大量 SSR 开销。细节上:列表页用useQuery是标准做法,详情页则可以改成服务端createCaller,让首屏直接有数据,同时不丢失 SEO。两者并不冲突,按页面属性分流就好。
t3code 里还有个隐藏的小性能点:tRPC 的getAll查询如果被多个页面复用,React Query 会把它们合并成一个请求,这是自动行为,不用额外配置。数据量大之后,我再考虑加无限滚动和下钻查询优化。
6.3 后续演进方向
t3code 还会继续迭代。目前计划加两个能力:一是 tRPC 的 subscription,把协作编辑时的变更实时推给其他在线用户;二是给 AI 辅助生成代码做一个服务端调用接口。这两个需求在现有架构下都不会引入额外的技术复杂度,属于渐进式扩展。
最后说两句实在话。我做完 t3code 最大的感悟是:T3 这套组合并不酷,但它是那种能让你把“业务逻辑以外的事情”无限压缩的工程方案。真正爽的不是脚手架,而是改一个字段之后编译器把所有漏洞都标出来的确定性。如果你也在考虑入坑,我建议第一次用 create-t3-app 时,把你项目里最核心的五六个接口老老实实跑通一遍,感受一下“从数据库到 UI 全程一个类型来源”到底意味着什么。另外给 tRPC 的 route 命名定个规范,比如“模块名+动词”,像code.getAll、tag.getAll,这样项目里 route 数量破百之后,文件查找和代码阅卷都会轻松很多。