t3code 这个名字乍看像某个编辑器插件的代号其实是我给自己一个全栈实验项目起的内部名称。它的核心技术栈是 T3 Stack——Next.js、TypeScript、Tailwind CSS 和 tRPC再加上 Prisma 与 NextAuth.js 作为数据层和鉴权层。整个项目跑通下来我最深的感受是类型安全不是效率的敌人反而是长期维护里最可靠的同事。这篇文章我会把 t3code 从选型理由、脚手架搭建、业务模块开发到部署上线的完整链路都摊开来讲适合正在纠结全栈项目到底怎么选技术栈的人也适合那些已经用 T3 Stack 但总在类型边界和缓存上栽跟头的人。1. t3code 是什么一个全栈类型安全实验项目的自白1.1 这个项目本来想解决什么问题我手头一直有一个内容管理类的业务想法用户登录后可以创建短笔记给笔记打标签按标签筛选还能分享给匿名访客查看。听起来简单但真正动手时容易陷入一个经典困境——前端写接口联调时后端说字段名我改一下前端拿到返回数据才发现类型对不上数据库表加了一列API 层忘了暴露前端又开始猜。t3code 就是冲着这个问题去的。我当时给项目定下三条硬性指标第一从数据库到页面组件的类型必须全程打通中间任何一环都不能有手写 interface 碰运气的情况第二鉴权逻辑必须和业务代码解耦不能每个接口都重复判断用户是否登录第三项目要能一键部署最好不用单独维护一台服务器。T3 Stack 几乎是为这三条指标量身定做的。Next.js 负责页面和 API 路由的托管tRPC 负责端到端类型安全的 RPC 调用Prisma 负责数据库模型和迁移NextAuth.js 负责 OAuth 和会话管理Tailwind 负责样式。这套组合最大的特点不是某个单一框架有多强而是它们之间几乎没有类型缝隙。1.2 T3 Stack 家族成员与分工很多人听到 T3 Stack 第一反应是又多了一个新框架实际上它不是什么新东西而是一套经过社区验证的默认推荐组合。这里的 T3 来自 Theo 的 T3 社区核心哲学是类型安全优先、约定优于配置、减少样板代码。具体到我 t3code 项目里的分工是这样的Next.jsApp Router负责应用的整体骨架包括页面路由、布局、服务端渲染和客户端渲染的切换。版本用 14App Router 模式下服务端组件和客户端组件的边界非常清晰但这个边界也给我挖了不少坑后面专门讲。TypeScript不说了整个 T3 的基石。严格模式从第一天就开着noUncheckedIndexedAccess这类严格选项也一并启用。Tailwind CSS样式方案。它和 tRPC、Prisma 没有直接关系但选它是因为开发效率高尤其是在快速迭代业务页面的时候。tRPC这是整个 T3 的灵魂。它让你在客户端直接调用服务端函数不需要手写 REST 路由、不需要手写请求封装、不需要定义 DTO。类型直接从服务端函数签名传到客户端 hook。PrismaORM。提供类型安全的数据库客户端schema 文件是唯一的数据模型来源迁移文件可追溯。NextAuth.js鉴权。负责 GitHub/Google 等 OAuth 登录也支持 Credentials 登录。它和 tRPC 的 context 整合后每个 procedure 都能拿到当前用户信息。这套组合里我最看重的其实是 Prisma 和 tRPC 之间的衔接Prisma 返回的类型可以直接作为 tRPC 的输出类型前端拿到数据后自动获得类型提示根本不需要在 API 层再做一层类型转换。这种体验一旦适应了再回去写传统 REST 手写类型会非常痛苦。2. 为什么类型安全值得牺牲一点“自由度”2.1 类型安全的收益边界一定有人会说类型安全好是好但写起来啰嗦小项目不值得。这话放到一年前我可能点头但 t3code 做到中期后我彻底改变看法了。小项目确实可以靠个人记忆维护接口结构但项目一旦超过三四个页面、七八个接口记忆就会开始骗你。类型安全的真正收益不在于写的时候少出错而在于改的时候敢下手。比如我在 t3code 里把笔记的content字段从String?改成String default()Prisma 类型变了tRPC 的输出类型跟着变前端使用note.content的地方如果有非空判断逻辑TypeScript 编译器会在毫秒内告诉我哪些地方需要调整。没有类型安全的话这种改动只能靠全局搜索加人肉测试。当然收益有边界。类型安全管不住业务逻辑错误管不住缓存不一致也管不住别人写了个any绕过检查。所以需要团队约定守住底线而不是把一切交给编译器。t3code 里我给自己定的规矩是禁止any除非有极其明确的理由并写注释说明禁止在客户端单独定义与 Prisma 模型重复的 interface需要展示类型时用Pick、Omit或RouterOutputs派生。2.2 一个真实的联调事故复盘我想用一个具体例子说明类型安全在 t3code 里帮我省了多少事。有一次我调整了标签筛选逻辑数据库里Tag表增加了一个color字段用于前端展示标签颜色。这个改动涉及 Prisma schema、一个 tRPC procedure、三个前端组件。如果走传统开发流程我会先改表结构再改后端接口返回再跑一次 Postman 看响应再打开前端页面验证。整个过程至少往返五六次。用 t3code 这套链路我只改了三处代码——schema、router、组件——然后让 TypeScript 编译器带我走一遍所有报错点。有一个组件差点漏掉color的样式绑定编译器在switch语句的穷尽性检查里直接给了我一个红色波浪线因为我没有处理新增的color枚举值。所以我的结论是类型安全的钱不要在初期省。它改变的不只是编译器体验而是整个项目的演进方式从小心弈棋变成大胆重构。3. 从 create-t3-app 到第一个业务模块的搭建步骤3.1 脚手架初始化与项目结构拆解t3code 的初始化没有手写配置直接用了官方脚手架。这个选择没什么好纠结的create-t3-app 本身就是社区维护的标准入口能保证所有依赖版本兼容而且可以交互式选择需要哪些模块。npm create t3-applatest t3code交互选项里我勾选了 Next.js、Tailwind CSS、tRPC、Prisma、NextAuth.js数据库先用 SQLite 跑通流程后面再切 PostgreSQL。脚手架生成后的核心目录结构是这样的t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts │ │ ├── notes/ │ │ │ ├── page.tsx │ │ │ └── new/page.tsx │ ├── pages/api/auth/[...nextauth]/route.ts │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ ├── routers/ │ │ │ │ ├── note.ts │ │ │ │ └── tag.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── react.tsx │ └── styles/ │ └── globals.css这里我建议第一次接触 T3 的人不要急着改目录脚手架生成的_app.tsx、trpc/react.tsx和server/api/trpc.ts已经形成了一套完整的运行链路。你最初只需要往routers/目录里加文件然后在root.ts里注册 router 即可。3.2 用 tRPC 写第一个后端接口在传统 REST 项目里写一个创建笔记的接口要经历路由定义、请求体校验、业务逻辑、响应格式化至少四步。tRPC 把前三步压缩成了一个函数。我在 t3code 里第一个完整的 procedure 长这样// src/server/api/routers/note.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; export const noteRouter createTRPCRouter({ list: publicProcedure .input( z.object({ tag: z.string().optional(), limit: z.number().min(1).max(50).default(20), }) ) .query(async ({ ctx, input }) { return ctx.db.note.findMany({ where: input.tag ? { tags: { some: { name: input.tag } } } : undefined, take: input.limit, orderBy: { createdAt: desc }, include: { tags: true, author: { select: { name: true } } }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().optional(), tagIds: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { return ctx.db.note.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: { connect: input.tagIds.map((id) ({ id })) }, }, }); }), });注意到protectedProcedure和publicProcedure的区别了吗前者在 context 里强制要求session.user存在未登录请求会在进入业务逻辑之前直接被拒绝。这就是我开头说的鉴权逻辑解耦——每个接口不再需要自己判断用户是谁只在真正需要用户信息的地方选用受保护的过程。3.3 Prisma 数据模型与迁移在 t3code 里Prisma schema 是唯一的数据模型事实来源。笔记和标签的多对多关系加上用户的一对多关系在 schema 里长这样model User { id String id default(cuid()) name String? email String? unique notes Note[] accounts Account[] sessions Session[] } model Note { id String id default(cuid()) title String content String? createdAt DateTime default(now()) updatedAt DateTime updatedAt author User relation(fields: [authorId], references: [id]) authorId String tags Tag[] } model Tag { id String id default(cuid()) name String unique color String default(gray) notes Note[] }改完 schema 后执行迁移npx prisma migrate dev --name init这里有个细节值得强调不要在prisma migrate dev之后手动改数据库。t3code 的开发过程中我有一次为了测试临时在 SQLite 里加了条记录导致后续迁移的 shadow database 校验失败折腾了半小时才理清。正确做法是任何表结构变化都走 Prisma Migrate哪怕只是个测试字段。4. 开发中必须注意的三个边界RSC、鉴权与缓存4.1 服务端组件与 tRPC 的协作边界App Router 模式下Next.js 默认所有页面组件是服务端组件RSC。服务端组件里不能使用 tRPC 的 React Query hooks因为它们依赖客户端状态和事件系统。这个限制第一次遇到时很让人困惑明明 tRPC 是端到端类型安全怎么在服务端组件里就用不了实际上有两种解法。第一种是把组件拆成两层外层服务端组件负责数据获取内层客户端组件负责交互。tRPC 官方推荐的是这种尽量在服务端拿数据的做法因为它能减少客户端请求次数。第二种是用 tRPC 的 server caller 在服务端直接调用 router// src/app/notes/page.tsx import { api } from ~/trpc/server; export default async function NotesPage() { const notes await api.note.list({ limit: 20 }); return NotesList initialData{notes} /; }~/trpc/server这个入口在脚手架里已经封装好了底层通过createCaller在服务端执行 procedure不需要走 HTTP。客户端组件里再配合 tRPC 的 React Query hooks 做后续的分页、筛选、乐观更新。我的建议是首屏数据尽量用 server caller 拿交互触发的数据变更用客户端 hooks。如果一刀切全用客户端请求首屏会有明显的 loading 状态如果全用服务端拿分页这种交互体验又不好。这种分层不是 T3 特有的但 t3code 让这个决策变得很自然因为两端用的同一套类型。4.2 NextAuth 会话接入 tRPC 的细节鉴权边界是 T3 项目里最容易出问题的地方。NextAuth 的 session 默认通过 HTTP 请求的 cookie 解析而 tRPC 的 context 创建发生在每个请求进来时所以要把二者桥接好。t3code 的server/api/trpc.ts里核心逻辑是import { getServerSession } from next-auth; import { authOptions } from ~/server/auth; import { db } from ~/server/db; export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerSession(authOptions); return { db, session, ...opts, }; };然后在定义 procedure 时分别导出const t initTRPC.contexttypeof createTRPCContext().create(); export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }); });这个中间件模式的优雅之处在于protectedProcedure后面的所有逻辑都默认用户已登录ctx.session.user.id可以直接用。如果哪天要加权限角色判断只需再包一层adminProcedure就够不需要改动任何业务 router。一个我踩过的坑是NextAuth 的session.user类型默认不包含id。你需要在server/auth.ts里扩展类型declare module next-auth { interface Session { user: { id: string; } DefaultSession[user]; } }否则编译器会在authorId: ctx.session.user.id这里报错。4.3 缓存失效策略与数据一致性tRPC 本身不负责 HTTP 层缓存它和 React Query 配合时缓存的核心是 query key。t3code 里我遇到过典型的创建一条笔记后列表页不刷新的问题。原因很简单useMutation成功后没有让相关的 query 失效。解法是在 mutation 的onSuccess里调用apiUtils的invalidateconst utils api.useUtils(); const createNote api.note.create.useMutation({ onSuccess: async () { await utils.note.list.invalidate(); }, });这里有个性能细节invalidate默认会使所有note.list的 query key 失效不管参数是什么。如果列表支持分页、筛选这样会全部重新请求。更精细的做法是只失效具体 key但实际项目中全量失效往往更稳妥因为列表数据之间的关联很难被精确追踪。我的经验是写多读少的场景可以精确失效读多、筛选条件复杂的场景直接全量失效配合 React Query 的staleTime控制请求频率即可。另外静态页面和服务端渲染的组合容易产生另一个坑如果某个页面用generateStaticParams做了静态生成里面用 server caller 获取的数据会被固定在构建时。内容变更后页面不会自动更新。t3code 里笔记详情页我一开始做了静态化结果发完新笔记访问旧页面还是 404排查半天发现是数据没更新。后来我改用动态渲染只在确实不需要实时更新的页面使用静态化。5. 部署和性能实测从本地到生产环境的完整记录5.1 部署方案选择Vercel 与自托管的分界t3code 这类 Next.js 全栈项目默认首选是 Vercel因为它对 Next.js 的适配最无缝。Prisma 在 Vercel 上需要配置好数据库连接串部署时环境变量一定不能漏。我的.env.production里至少要有DATABASE_URLpostgresql://... NEXTAUTH_SECRET... NEXTAUTH_URLhttps://your-domain.com GITHUB_ID... GITHUB_SECRET...选择自托管的场景我也认真考虑过如果你需要 WebSocket 长连接、需要后台常驻任务、或者有很强的磁盘读写需求serverless 平台的限制会很痛苦。t3code 这类笔记应用其实用不到这些所以 Vercel 的 serverless 函数足够。但自托管并非只是跑起来一台 Node 服务器那么简单。你需要处理日志聚合、进程守护、反向代理、证书轮转。如果你没有现成的运维经验我不建议第一个全栈项目就上自托管。5.2 性能数据与优化手段t3code 上线后我做了几轮性能测试重点看三块首屏加载、接口响应、数据库查询。基于 Lighthouse 实测数据模拟 Moto G Power 设备Slow 4G 网络优化前和优化后的对比如下指标优化前优化后LCP3.2s1.8sTBT0.7s0.2s/api/trpc/note.list 响应220ms90ms主要的优化手段有三个Tailwind CSS 的 JIT 特性让生产环境 CSS 文件只有十几 KB这块没什么额外工作但要确保NODE_ENVproduction时 Tailwind 正确清除未使用样式别在开发模式看性能数据。tRPC 查询的 React Query 缓存减少了重复请求首次进入页面后返回再进入同一页面数据直接从客户端缓存恢复。Prisma 查询优化给Note.authorId和关联表的外键加了索引分页查询从全表扫描变成了索引扫描。在数据量还不到一万条的前提下响应时间下降了约 60%。5.3 监控与错误上报部署只是起点。t3code 上线后我接入了 Sentry 做错误监控这不仅因为量多还因为未捕获的 Promise rejection在客户端很难主动发现。Sentry 的 Next.js SDK 可以自动捕获 tRPC 的报错堆栈前提是你在 tRPC 的 onError 回调里手动上报import * as Sentry from sentry/nextjs; const t initTRPC.contexttypeof createTRPCContext().create({ errorFormatter({ shape, error }) { Sentry.captureException(error); return shape; }, });这个细节很多教程没提tRPC 内部默认返回标准化的错误结构如果不接入 errorFormatterSentry 拿到的是空堆栈。加了之后Zod 校验错误、数据库异常、未知错误都能按 procedure 名聚合定位问题非常快。6. t3code 后续的扩展方向项目做到这个阶段功能本身已经很完整了但类型安全这条路还可以继续往下走几步。我在实际开发中尝试了两个方向都觉得挺有意思。第一个方向是把 tRPC 路由通过trpc-openapi暴露成 OpenAPI 文档给第三方客户端或非 TypeScript 项目提供 REST 接口。这听起来和 T3 的类型安全优先有点矛盾但在真实业务里几乎一定会遇到移动端团队用的是 Dart或者你需要给外部合作伙伴提供 Webhook。tRPC 的本地调用体验不能寄托在别人的技术栈上此时生成 OpenAPI 规范是最优雅的出口。第二个方向是引入 Drizzle ORM 替代 Prisma通过 Raw SQL 执行复杂查询。Prisma 的类型安全在简单的 CRUD 上很舒服但遇到统计报表、多级关联聚合这类场景时它的 API 会变得笨重。Drizzle 的 SQL 模板更贴近原生 SQL又依然有类型推导。create-t3-app 现在已经把 Drizzle 作为可选项了我在新的 toy project 里也尝试了这种组合感觉在复杂查询场景下确实更顺手。这两个方向都不是必须做的但它们都指向同一个结论T3 Stack 不是一个封闭的框架而是一套可以按需调整的工程习惯。你不需要对每个组件都忠诚关键是保持类型在数据链路上尽量长的流动。最后分享一个我在 t3code 里养成的小习惯每次修改 Prisma schema 之后除了执行迁移我会顺手在 t3code 目录下跑一遍npm run typecheck。这个命令是脚手架自带的它一次性检查整个项目的 TypeScript 类型。很多人在改完数据库字段后只盯着自己刚写的那些代码看结果漏掉了另一个文件里已经用旧字段的地方。让编译器帮你做全局扫描比你自己瞪大眼睛找要可靠得多。类型安全的长处就在于这里——它不让你记住所有细节而是让工具记住细节然后在你犯错的瞬间提醒你。