资讯动态

T3 Stack 全栈实践:从 create-t3-app 初始化到生产部署的完整指南

发布时间:2026/10/26 17:13:57 来源:尧图企业网站定制
在团队里聊到全栈技术选型时我经常会提起一个代号“t3code”。这是我的一个真实项目仓库名也是不少人第一次接触 T3 Stack 时的入门样本。很多人一听“t3”就以为是 Three.js 的 3D 场景其实完全不是也有人以为它是某个新框架上手之后才发现它更像一套“默认偏好集”。这篇文章我会把 t3code 从初始化到部署的完整过程拆开讲t3code 的命名来源、技术栈组成、脚手架每个选项背后的取舍以及我在 tRPC 路由、Prisma 迁移和上生产环境时踩过的真实坑。适合正在学习 Next.js 全栈开发、或者想在项目里落地端到端类型安全的开发者参考。1. t3code 是什么解开 T3 缩写背后的那层误会1.1 它不是框架而是一套“组队式”技术栈约定先澄清一个最常见的误解T3 不等于 Three.js也不是某种新出现的运行时框架。T3 Stack 是由知名开发者 Theo Browne网名 t3.gg提出的组合方案T3 这个名字取自他的网名 Tyler3而不是三个以 T 开头的技术首字母。不过恰巧这套栈里的核心成员——TypeScript、Tailwind CSS、tRPC——确实都是 T 开头所以后来很多人也把 T3 解读成“三个 T”这个解读不算错误但根源还是指 Theo 本人。t3code 项目在初始化时完全采用了这套组合方案它的核心成员包括技术职责定位在 t3code 里解决什么问题Next.jsReact 全栈框架负责页面渲染、路由、API 处理层TypeScript静态类型语言全链路类型安全的语言基础tRPC端到端类型安全 RPC替代传统 REST 接口的 API 调用层PrismaORM 与数据库工具定义数据模型、迁移与查询Tailwind CSS原子化样式方案快速构建界面不维护零散 CSS 文件NextAuth.js身份认证处理登录会话与权限控制这套组合解决的问题非常明确全栈开发者最头疼的不是写业务代码而是在前后端之间维护一份永远可能过期的 API 契约。REST 接口需要手写 OpenAPI 文档或者类型声明GraphQL 需要维护 GraphQL Schema 和前端生成类型这些工作一旦工程变大很容易出现“后端字段改了前端还在用旧字段”的尴尬。T3 Stack 的答案则是用 TypeScript tRPC把类型定义从数据库一路推到浏览器组件里全链路同一个类型源头改一个字段编译期就能把所有断点暴露出来。1.2 为什么这套组合能成为很多团队的全栈默认选项我说一个比较实在的观察近几年新开的全栈项目里T3 Stack 的出现频率高得吓人。原因不是它功能堆得多恰恰相反是它的克制。create-t3-app 的设计理念是“保留最少但最顺手的工具”它不强行塞入状态管理库、不默认接 GraphQL、不强制 CSS 方案所有选型都以“能让端到端类型安全最大化”为第一优先级。以 t3code 的实际体验来说前后端类型共享带来的好处是实打实的。比如在 pages/api 或者 route handler 里写传统接口时前端要等后端返回后才知道字段叫什么、类型是什么然后在组件里写一堆类型断言而在 tRPC 模式里后端定义好 procedure 的输入输出类型前端调用时编辑器直接给我补全字段名写错了当场报红。这种体验用过一次就回不去了。当然它也不是万能的。t3 这套组合最适合的是中小型全栈项目、内部工具、快速原型和产品落地阶段的应用。如果是一个几十人的大团队前后端人数对半分API 需要同时供给第三方客户端那 REST 或 GraphQL 的开放契约模型仍然更合适。t3code 的定位我心里一直很清楚它是把全链路做强类型约束的方案前提是前后端都在同一个 TypeScript 世界里。提示如果你所在团队的后端是 Java、Go 或者 PythontRPC 那一套就直接没法用了。T3 Stack 的适用边界就是“前后端都在 Node/TS 生态内”。2. 初始化 t3code 的脚手架选择几乎每个选项都在替你省掉后续返工2.1 创建命令与交互式选项逐项拆解创建 t3code 用到的命令很简单就是 create-t3-app 脚手架pnpm create t3-applatest t3code脚手架会让你选择需要的模块。我第一次跑这个命令时觉得所有选项都直接勾上就行但后来在多个项目里反复初始化之后才意识到这里的每一个勾选都会在之后的工程里变成一层隐性的约定。我把选项和适用场景整理成了一张表选项什么时候勾选什么时候别勾Next.js必须勾这是全栈基础无Tailwind CSS几乎都勾界面层效率高团队有成熟组件库并且不想引入原子化样式时Prisma需要写数据库业务的都勾纯前端静态项目tRPC前后端同仓库且需要数据请求有独立第三方 API 客户端NextAuth.js需要登录、会话、权限控制自己手写认证逻辑Drizzle团队熟悉 Drizzle 且已有相关代码默认 Prisma 更省心新版 create-t3-app 还有一个值得注意的变化Drizzle 也进入了选项列表。这说明脚手架团队意识到 ORM 选择不能一家独大T3 的组合并不是死板不变的。2.2 我在 t3code 里的选型逻辑App Router 与 Prismat3code 创建时我选择了 App Router、Tailwind、Prisma、tRPC 和 NextAuth.js没有选 Drizzle。选 App Router 的原因很直接Next.js 官方已经明确 App Router 是未来方向虽然 2024 年之前 Pages Router 的生态资料更多但新项目直接上 App Router 可以减少一次从旧范式迁移的成本。Tailwind 我选了因为我不想起一个项目就维护一份全局 CSS 文件Tailwind 的类名约束反而帮我减少了思考“这个 class 叫什么”的时间。Prisma 和 Drizzle 的选择我斟酌了一阵。Drizzle 的查询写法更接近 SQL性能也更轻但它的 migration 流程和类型推导手感比 Prisma 硬一些。t3code 是一个有较复杂数据关联的项目我需要快速建模和可视化管理数据库Prisma 的 schema 文件对我来说更直观。选 Prisma 的另一个考量是生态社区教程、招聘需求、AI 工具的参考素材Prisma 的积累明显更多。技术选型不能只看技术先进性还要看团队能搜到多少可参考的实践样本这是我做决定的真实理由。2.3 初始化后的目录结构为什么这样组织create-t3-app 初始化完成后t3code 的 src 目录是这样组织的src/ ├── app/ │ ├── api/ │ │ └── auth/ # NextAuth 专用 API 路由 │ ├── layout.tsx │ └── page.tsx ├── server/ │ ├── api/ │ │ ├── routers/ # 各业务模块的 tRPC 路由 │ │ ├── root.ts # 根 router 汇总 │ │ └── trpc.ts # procedure 类型与中间件 │ ├── auth.ts # NextAuth 配置 │ └── db.ts # Prisma 客户端实例 ├── trpc/ │ ├── server.ts # 服务端 tRPC 入口 │ └── react.tsx # 客户端 hooks 封装 └── lib/ └── utils.ts # 通用工具函数这个目录设计有两个核心意图。第一把服务端逻辑和页面逻辑分离server 目录下的代码严格运行在服务端不会被打进浏览器 bundle这避免了很多新手把含数据库连接的逻辑混进组件里导致的安全问题。第二tRPC 的 router 集中管理所有业务接口都以模块形式放在 routers 目录下通过 root router 聚合导出客户端调用时只需要引用一个统一入口。注意千万不要为了省事直接把 Prisma client 初始化在 app 目录下的任意文件里。t3code 在重构之前就踩过这个坑Prisma 实例被页面组件间接导入导致打包体积异常增大。务必按照脚手架生成的 daisy chaindb.ts - server router - trpc server来组织。3. 第一个 tRPC 路由为什么类型安全能在全栈链路里“传染”3.1 从 procedure 说起一个带鉴权的 hello 接口tRPC 里最核心的概念叫 procedure可以把它理解成“一个能让前后端共享类型的函数”。t3code 的根路由创建之后我写的第一个带鉴权的 procedure 是 hello// src/server/api/routers/example.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; export const exampleRouter createTRPCRouter({ hello: publicProcedure .input(z.object({ text: z.string() })) .query(({ input }) { return { greeting: Hello ${input.text}, }; }), getSecretMessage: protectedProcedure.query(({ ctx }) { return { message: 登录之后才能看到的内容, user: ctx.session.user.name, }; }), });这个文件里的 procedure 分成两种publicProcedure 允许任何人调用protectedProcedure 会先在中间件层面检查 session未登录直接抛出 UNAUTHORIZED 错误。这种定义方式的价值在于权限控制和业务逻辑在同一个地方声明而不是散落在每个页面里写 if 判断。t3code 里几乎所有接口都默认走 protectedProcedure只有在确实需要公开数据时才改成 publicProcedure这种默认安全的习惯帮我们挡掉了不少疏忽。3.2 Server Client 与输入校验的配合方式写完了 router 还不够要让前端调用到它需要在根组件里挂载 Server Client。t3code 用的写法是// src/app/_trpc/server.ts import { createTRPCClient, httpBatchLink } from trpc/client; import { type AppRouter } from /server/api/root; import { headers } from next/headers; export const api createTRPCClientAppRouter({ links: [ httpBatchLink({ url: /api/trpc, headers() { const heads headers(); return { cookie: heads.get(cookie), }; }, }), ], });注意这里的两个细节第一服务端组件里的 tRPC 客户端使用 httpBatchLink它会把多个请求合并成一个 HTTP 调用减少服务端的函数并发第二通过 headers() 把 cookie 透传给 tRPC这样服务端渲染时也能拿到当前登录用户的 session。很多从 REST 转过来的同学会问请求 URL 在哪里其实 tRPC 的调用路径不靠 URL 定位而是靠函数对象本身。客户端引入的 api 对象已经绑定了 AppRouter 类型所以调用时能自动提示每个 procedure 在哪个 router 下。输入校验方面tRPC 和 zod 是固定搭档。procedure 的 input 一旦声明了 zod schematRPC 会在运行时自动解析、校验并推导类型。前端输入的类型、后端接收的类型、最终返回的类型全部来自同一段 schema 定义不存在“前后端各写一份校验”的情况。3.3 tRPC 相比 REST 和 GraphQL 的本质差异我在 t3code 里同时维护过一段传统 REST 接口作为过渡那段经历让我对 tRPC 的优势体会得更加具体。REST 的问题不在于概念而在于类型信息无法网络传输。后端接口返回的 JSON 到浏览器里就成了一堆 any前端同事为了保证类型正确只能手工维护 d.ts 文件一旦后端调整字段缺少 CRUD 接口的地方就会出现运行时才发现问题。GraphQL 解决了字段选择和类型定义的问题但引入了新的复杂度Schema 定义、resolver 拆分、客户端代码生成、缓存策略一套下来至少多出四五个概念。tRPC 走了一条更简的思路既然前后端都是 TS为什么不直接调用函数把网络边界彻底抹平。当然它牺牲了“非 TS 客户端”的兼容能力Web 端之外的移动端、小程序就没法直接复用同一套 API 契约。t3code 的场景本来就是桌面端 Web 应用这个代价可以接受。对比项RESTGraphQLtRPC类型共享需手写维护需生成代码编译器自动推导客户端限制任意语言任意语言必须 TypeScript调试工具浏览器/PostmanGraphiQL/PlaygroundtRPC 面板 React Query学习曲线低中高中低适用边界开放 API复杂聚合查询同栈全栈应用3.4 实测注意React Query 版本差异与无限查询分页tRPC 和 React Query 的配合也有一段容易翻车的经历。create-t3-app 默认安装的 tanstack/react-query 版本不同tRPC 的 hooks 会有些许差异。早期版本里 isLoading 字段在 v5 之后改成了 isPending如果照抄老教程里的写法会发现在请求未完成时页面渲染的是错误状态。t3code 里做无限滚动列表时用到了 useInfiniteQuery这里有一个关键点tRPC 的 procedure 中如果用 infiniteQuery它的输入必须包含 cursor 字段并且返回格式必须满足{ items: T[], nextCursor?: string | null }。我最初直接返回了一个数组React Query 在拼接页数据时直接报错。后来查看类型提示才意识到infiniteQuery 的输出结构是固定的不能按普通 query 的思维来写。这些细节都是文档里有、但初看不会注意的类型约束真正用起来才懂。4. Prisma 模型和迁移t3code 实践中最容易翻车的三个场景4.1 schema 里的一对多该怎么写才能减少后续关系名冲突t3code 的数据模型足够复杂用户可以有多个项目每个项目下挂多个文档文档有标签标签多对多关联。第一次写 Prisma schema 时一对多关系看起来很简单model User { id String id default(cuid()) projects Project[] } model Project { id String id default(cuid()) user User relation(fields: [userId], references: [id]) userId String }问题出在当同一个模型之间出现多个关系时。比如 Project 有两个成员owner 和 editor它们都关联到 UserPrisma 就需要 relation name 来区分model Project { id String id default(cuid()) owner User relation(OwnerProjects, fields: [ownerId], references: [id]) editor User relation(EditorProjects, fields: [editorId], references: [id]) ownerId String editorId String }这个案例看起来简单但在一个五六个模型互相引用的 schema 里relation name 的命名规范一旦没有约定后续 migrate 时很难一眼看出两个字段之间的关系是什么。我在 t3code 里最终制定了一条规则relation name 一律用“主模型名 在对方模型里的字段名”比如 User 这边叫 ownerProjectsPrisma 那边就叫 OwnerProjects。这样翻 schema 文件的时候每个 relation 的语义一目了然。4.2 prisma migrate dev 与数据丢失问题这是我在 t3code 里踩过最疼的一次坑。当时为了调整一个字段类型我执行了prisma migrate dev --name change-deadline-type结果终端提示“Database has drift, resetting database”。我下意识输入了 y然后整张表的数据全部清空。alert 才发现这次的 drift 警告是因为我之前手动改过数据库列schema 和数据库信息库记录不一致Prisma 只能用 reset 的方式强制对齐。从此我养成了一个固定习惯任何可能触发 reset 的 migrate 操作之前先备份数据库pg_dump postgresql://username:passwordhost:5432/dbname backup_$(date %Y%m%d_%H%M%S).sql另外如果只是想快速拉通本地开发不关心历史数据的场景下可以直接用prisma db push同步 schema 而跳过 migration 文件。这个命令不会创建迁移记录适合开发初期数据模型还不稳定的时候。但 t3code 走到正式环境后就很少再用 db push 了迁移历史必须保持完整因为生产数据库的升级要靠这些迁移文件执行。还有一点seed 脚本在新版本 Prisma 里的执行方式是prisma db seed需要在 package.json 里配置prisma.seed字段。很多教程还在用旧写法导致恢复数据库后没有基础数据可用。t3code 的 seed 脚本我一般放两类数据一类是字典表数据比如标签、分类另一类是带有固定 ID 的测试账号方便本地登录调试。4.3 多环境数据库连接串配置的流转细节Prisma 在多个环境之间流转时最让人头疼的是 datasource url。默认情况下 Prisma 读的是.env里的 DATABASE_URL但部署到生产环境时环境变量来自云平台本地.env文件里的值不会跟着上传。t3code 用的是 Vercel 部署我在环境变量配置页面单独设置了 DATABASE_URL指向云数据库的连接串本地开发继续用本地 PostgreSQL。这里有一个容易忽略的细节每次改了 datasource 的 provider 或者 model 定义之后需要重新执行prisma generate否则 Prisma client 的类型还是旧的。我在 CI 流水线里加了一步npx prisma generate npx prisma migrate deploymigrate deploy 和本地用的 migrate dev 不同它不会对你开发环境做任何修改只是把迁移文件按顺序应用到目标数据库专用于生产部署。如果团队里有人直接用 migrate dev 在共享数据库上跑很容易把迁移记录搞乱这个教训我写在团队文档里了。5. 上生产前的检查清单环境变量、认证密钥与边缘部署细节5.1 NextAuth Secret 的生成与轮换机制t3code 在上生产之前差点栽在一个细节上NextAuth 的 AUTH_SECRET。脚手架在本地开发时会自动生成一个临时 secret但部署之后如果你没有显式设置这个环境变量线上 session 签名就会不一致表现为“登录后刷新页面就掉登录”。生成一个安全的 secret 用这行命令openssl rand -base64 32得到的字符串至少要 32 字节把它填到 Vercel 的环境变量里。这里有个容易被忽略的伴随问题如果你在公司内部网络通过代理访问外部服务环境变量里千万不能混入代理相关配置尤其是 session 生成依赖的随机源。我见过一次线上偶发登出的案例最后定位到是部署环境里误设了一个不可达的 HTTP_PROXY 导致 NextAuth 的随机数请求超时。这类问题排查起来特别隐蔽如果你们团队的网络基础设施比较复杂建议在环境变量清单里明确剔除所有代理变量。另外secret 的轮换也要有策略。直接删掉重新生成会让所有已登录用户的 session 失效相当于一次全员强制下线。t3code 的轮换方式是先设置新的 NEXTAUTH_SECRET同时保留旧 secret 在“次要 secret”字段中让已有 session 可以平滑过渡。这个做法需要你的 Auth.js 版本支持好在 v5 都支持多 secret 解码。5.2 边缘运行时与 Prisma 的冲突规避t3code 在 App Router 下遇到一个很实际的问题某些 route handler 默认会尝试在 Edge Runtime 上运行而 Prisma 客户端依赖 Node.js 的某些原生能力没法直接放进 Edge。也就是说如果你在 middleware.ts 里使用了 Prisma或者在某一个标注了 runtime edge 的 route 里 import Prisma构建时不会报错但运行时大概率直接挂。我的处理方式是一条规定middleware.ts 里绝不碰任何数据库相关逻辑。middleware 只做 session cookie 的解析和路径重定向真正的用户信息查询全部放到 tRPC 服务端处理。如果你确实需要在 Edge 环境下读取用户信息用 Session 本身携带的轻量字段就够不要执着地在 Edge 查询数据库。t3code 有一个页面需要在服务端拿数据库数据我给它加了强制 Node.js runtimeexport const runtime nodejs;这个声明放在页面文件的顶部即可。测试后确认 Vercel 部署的 Node.js serverless 函数对 t3code 这个量级完全够用冷启动时间在几百毫秒以内没有想象中那么夸张。5.3 我踩过的几次生产事故复盘第一次事故上线当天忘了prisma generate。本地开发时一切正常因为本地 node_modules 里的 client 是旧代码而新代码已经引用了一个新加了字段的类型本地之所以没报错是因为我改完 schema 依赖的 client 恰好是在命令行执行 migrate 时生成的。但 CI 流水线里只跑 build没跑 generate构建产物里的 client 没有新方法页面一调用就 500。排查过程绕了很大一圈最后看日志发现是 Prisma 报的 Unknown field才想起是 client 版本落后。结论CI 里必须在 build 前固定加一步 prisma generate不要依赖开发机上的手动执行。第二次事故DATABASE_URL 指向了已有的生产数据库但连接串里缺少连接数参数。t3code 使用了 PgBouncer 作为连接池本地连接串是直连 URL生产连接串是 pgbouncer URL两者格式稍有不同。结果我在配置环境变量时把两个环境的 URL 互换了一下生产环境直接连到了本地的隧道地址导致大量死锁。这个问题让我养成了在每个环境变量备注注释的习惯如果云平台没有注释功能就在团队 wiki 里维护一份环境变量说明书。第三次事故tRPC 的 protectedProcedure 覆盖情况遗漏了一个新加的 router。我新增了一个adminRouter它创建时用的是publicProcedure当时想着反正里面逻辑自己有权限判断结果上线后任何人都能调用 admin 接口读取部分治理数据。排查过程不复杂但这个失误让我意识到一个原则默认用 protectedProcedure 和 role 中间件并不是每个接口都重写权限逻辑而是用工厂函数批量声明。t3code 后来封装了一个createRoleProtectedProcedure(admin)函数所有 admin 路由都从这里创建从源头避免遗漏。最后再分享几个实际体验t3code 这个项目从一行脚手架命令成长为一个跑在云端的全栈应用整个过程中我最大的体会是T3 Stack 的价值不只是省掉了繁琐的版本清单维护更在于它把“类型安全”从一个口号变成了开发流程里的强制约束。以前写接口改字段后总要到处搜索哪里用到现在改完类型编译器和编辑器直接在改动点旁边标红只要类型检查过了运行时一致性就有了九成保障。如果你打算用 create-t3-app 开一个新项目我的建议是第一遍初始化时先用最少的配置跑通全链路然后再逐步加 Prisma、NextAuth 这些重量级依赖第一次部署不要把目标定在生产环境先在预览环境跑一晚看看日志和性能指标。t3code 的方案不一定适合所有团队但如果你也厌烦了维护 API 文档和手工类型声明那这套栈确实值得花一个周末认真试一次。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑