资讯详情

资讯详情

T3 Stack 实战复盘:从脚手架到生产部署的全栈类型安全开发

T3 Stack 这个词你要是常逛 GitHub 或技术社区这两年应该不陌生。它最初就是一套全栈技术栈组合核心是用 TypeScript 把前后端从数据模型到页面组件串成一个类型安全的整体。我年初那段手忙脚乱的项目空窗期正好用一套基于 T3 Stack 的小应用把这套东西从零到上线完整跑了一遍过程中踩了不少坑也攒了不少心得。这篇文章就是围绕那次实践写下来的完整复盘标题就叫 t3code——你可以把它理解成一个围绕 T3 技术栈的实战记录也可以直接当成一份从脚手架到生产部署的操作手册。t3code 到底解决什么问题往大了说它把 Next.js、TypeScript、Tailwind CSS、Prisma、tRPC、TanStack Query 这些本身就各自能打的工具按照“端到端类型安全”的原则拧成了一个整体。你写接口不再靠翻文档改字段不再担心漏掉调用方数据库模型一变前端组件层立刻在编译期报错。这套方案尤其适合三类人想快速搭出生产级应用的独立开发者、被前后端类型不一致折磨的全栈工程师、以及想系统学习现代 TypeScript 实践的人。接下来我按实际开发顺序把每个环节拆开讲重点放在别人教程里不会写的那些坑上面。1. T3 Stack到底是什么为什么它能成为全栈开发的热门底座1.1 T3 Stack的核心成员与它们的分工先理清一个概念T3 不是某个单一框架而是一套组合拳。它最早因为社区里的 create-t3-app 脚手架而被广泛知晓核心口号就一句话Build typesafe, production-grade web apps。T3 这个名字的来源也很朴素因为组成它的主力包都顶着字母 T 开头——TypeScript、Tailwind CSS、tRPC、TanStack Query后来加入的 Testing 工具也都是 T 系。具体到一份标准 T3 项目里各成员的分工大概是这样的成员职责解决的核心痛点Next.js服务端渲染、路由、部署承载缺少约定俗成的全栈框架骨架TypeScript全链路类型系统运行时和编译时的数据类型割裂Tailwind CSS原子化样式方案编写样式时的上下文切换成本PrismaORM 与数据库迁移手写 SQL 容易出错、模型变更不可控tRPC类型安全的 API 调用层前后端接口定义与实现不一致TanStack Query服务端状态管理数据获取、缓存、重试逻辑重复造轮子我见过不少人对 T3 的第一反应是“这不就是把热门库拼一起吗”。话是没错但拼法有讲究。T3 链路里有一个关键特征Prisma 生成的 model 类型能直接流进 tRPC 的 routertRPC 的 output 类型推导出来后又无缝喂给 TanStack Query最后在页面组件里拿到带完整类型的 data。类型在整条链路上是“同一种东西”它不是每层单独约定接口协议而是数据库到 UI 共用同一套约束。1.2 为什么类型安全是全栈开发的第一价值很多人第一次接触 T3 时会嘀咕前后端接口本来就是约定好的写类型有什么了不起我给你讲个真实场景。我之前做一个后台管理系统后端同学把接口返回的一个字段名从user_id改成了userId前端代码里全是字符串拼参数完全搜不到这个字段。结果线上页面悄悄出现 undefined排查了两个多小时才发现是字段被改了。在 tRPC 的链路里这种问题基本不可能发生。你调用的函数、传入的参数、返回的结果全都有从数据库一路推导出来的类型。字段名改了Prisma schema 一改重新生成 client编译期就会告诉你“这里访问不存在的属性”。问题在 dev server 启动的那一秒被拦截下来而不是等到用户发现页面空白。这就是 T3 的核心心法把错误拦截在编译期而不是爆发在运行时。对独立开发者来说这意味着你不需要靠记忆来维持前后端一致性对团队来说这意味着省掉了一整层接口文档维护的隐性成本。文档当然还得写但“文档和实际不一致”这个风险被类型系统彻底兜住了。2. 从零搭建T3项目create-t3-app的完整初始化流程2.1 初始化前的环境准备动手之前先把基础环境弄利索。我的环境是三件套Node.js 18 或更高版本、任意包管理器npm、yarn、pnpm 都行我用的是 pnpm安装速度确实快很多、一个数据库实例。数据库这里多说一句PostgreSQL 是 T3 生态里用得最多的选择本地没条件的话可以用 SQLite 先把流程跑通后面再无缝切到 PG。新人最容易栽的第一个坑就是 Node 版本。create-t3-app 初始化时会自动检测当前环境如果你还在用远古 Node 14装完依赖启动项目时会直接报各种语法异常因为新版本脚手架生成的关键依赖已经不再兼容老 Node。建议跑一下node -v低于 18 就先升个级再继续。2.2 交互式选项的选择逻辑初始化命令很简单pnpm create t3-applatest my-app终端会进入交互式选择我当时选的是 App Router TypeScript Tailwind Prisma tRPC TanStack Query NextAuth。这里单独说一下每个选项背后的取舍逻辑别无脑全勾NextAuth面向用户的公开站点且短期内不打算自建登录体系就勾上纯内部工具或 API 服务可以先不勾省掉一层配置负担。TanStack Query页面里有客户端交互式数据获取的场景比如搜索、筛选、分页、实时刷新就勾上纯 SSR 或静态页面不勾更轻。Prisma 与 Drizzle 二选一第一次跑通 T3 建议用 Prisma模型定义和 migrate 流程更直观T3 生态也是围绕 Prisma 长起来的Drizzle 适合对 SQL 和表结构已经很有把握的情况。Tailwind默认勾上就行T3 的样式实践基本以它为基准。交互选择结束后脚手架会自动安装依赖并生成.env、.env.example等文件。我第一次跑完还愣了一下以为自己漏了什么步骤其实项目骨架、trpc 目录、server 目录全都铺好了。2.3 初始化完成后的目录结构初始化完成后你会看到一个高度约束化的目录这也是 T3 项目好上手的原因my-app/ ├── prisma/ │ └── schema.prisma # 数据库模型定义 ├── src/ │ ├── pages/ # Next.js 路由页面 │ ├── server/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── router.ts # 根路由聚合 │ │ ├── auth.ts # NextAuth 配置若启用 │ │ ├── db.ts # Prisma Client 单例 │ │ └── trpc.ts # tRPC 上下文与中间件基础 │ ├── trpc/ │ │ ├── react.tsx # React Hooks 封装 │ │ └── server.ts # 服务端调用封装 │ └── styles/ │ └── globals.css # Tailwind 样式入口 ├── .env # 环境变量 └── package.json这个结构最妙的地方在于它把“数据层”和“接口层”的位置固定了下来新成员进项目不用猜代码放哪。src/server这个目录名也讲究所有和数据库、认证、API 路由相关的代码统一归到这里和src/pages这种偏 UI 的目录天然隔离。你不太会在一个大型 T3 项目里看到“代码放哪都行最后谁都找不到”的混乱局面。3. 核心链路实操Prisma数据层 tRPC接口层 Next.js页面层为了让这篇文章不只是讲概念我基于这套栈写了一个任务管理小应用。选任务管理是因为它足够简单、数据关系一目了然同时能把 tRPC 的 query 和 mutation 两种操作都覆盖到。下面的步骤和实际开发保持完全一致包括每一步我踩过的坑。3.1 用Prisma定义数据模型打开prisma/schema.prisma把示例里自带的模型删掉改成任务模型generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model Task { id String id default(cuid()) title String description String? status String default(todo) createdAt DateTime default(now()) updatedAt DateTime updatedAt }定义完成后跑同步命令npx prisma db push这里展开说一下db push和migrate dev的区别这事几乎每个 T3 新人都要纠结一次。db push把当前 schema 直接推送到数据库不生成迁移记录文件适合开发初期模型频繁变动、还没开始多人协作的阶段migrate dev会根据 schema 变化生成带时间戳的 SQL 迁移文件并记录完整迁移历史多人协作和上生产时应该优先使用。我第一次图省事用了两周db push后来另一个同学也要连同一个库跑开发结果我俩的 schema 对不上最后不得不重做基线。我的建议是单人本地玩可以db push一旦涉及多人或上生产立刻切到migrate dev。跑完同步之后Prisma 会自动为Task模型生成对应类型。这一步很多人没注意但它是整条类型安全链路的源头——之后 tRPC 里返回的类型、页面里读到的数据本质都是这个 model 类型在传递。改一次 schema、重新生成全链路都会被 TypeScript 盯住。3.2 tRPC Router类型安全接口的设计T3 脚手架在src/server/api/routers里放了一个示例 router我直接在它旁边新建task.ts。tRPC 用法上就三个关键词router 定义一组操作procedure 表示一个可调用端点input 用 zod 做运行时校验。一个最基本的任务接口长这样import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const taskRouter createTRPCRouter({ getAll: publicProcedure.query(({ ctx }) { return ctx.db.task.findMany({ orderBy: { createdAt: desc }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1), description: z.string().optional() })) .mutation(({ ctx, input }) { return ctx.db.task.create({ data: { title: input.title, description: input.description, }, }); }), });这里有两个细节值得展开。第一publicProcedure意味着这个接口无需登录也能调用。项目启用 NextAuth 后你可以在src/server/api/trpc.ts里写一个protectedProcedure内部先检查 session没登录直接抛错这个模式在真实项目里几乎必用。第二z.object({ title: z.string().min(1) })不是摆设它既承担了入参的运行时校验又给 TypeScript 提供了 input 的静态类型推导。你不需要手写一套 RequestDTO 再手写一套 ResponseDTOzod 定义一次校验和类型两件事都搞定。写完 router 之后记得去src/server/api/root.ts挂载import { createTRPCRouter } from ~/server/api/trpc; import { taskRouter } from ~/server/api/routers/task; export const appRouter createTRPCRouter({ task: taskRouter, }); export type AppRouter typeof appRouter;AppRouter这个类型导出非常关键前端靠它做 Hooks 的类型推导。我第一次抄代码时漏了这行export type结果前端api.task.getAll的类型直接变成 any页面倒是能用但完全失去了类型检查的价值。排查了半天发现就是类型出口没开补上之后立刻恢复正常。3.3 页面层的接入与TanStack Query的用法页面层接入方式取决于场景。对 App Router 下的服务端组件脚手架已经给了~/trpc/server这个封装import { api } from ~/trpc/server; export default async function HomePage() { const tasks await api.task.getAll(); return ( main classNamep-6 h1 classNametext-2xl font-bold任务列表/h1 ul {tasks.map((task) ( li key{task.id} {task.title} — {task.status} /li ))} /ul /main ); }服务端组件里直接 await 一个 query数据在服务端处理完再吐给 UI用户拿到的已经是渲染好的 HTML首屏和 SEO 都友好。但如果有按钮点击、表单输入这些交互就必须要客户端组件加 TanStack Query 的 Hooksuse client; import { api } from ~/trpc/react; export function TaskCreator() { const utils api.useUtils(); const createTask api.task.create.useMutation({ onSuccess: () { utils.task.getAll.invalidate(); }, }); return ( button onClick{() createTask.mutate({ title: 新任务 })} classNamerounded bg-blue-600 px-4 py-2 text-white 新建任务 /button ); }utils.task.getAll.invalidate()是这行代码里最有含金量的部分mutation 成功后TanStack Query 会把“任务列表”这条缓存标记为无效下次请求时自动重新拉取保证新任务立即可见。想更激进一点可以直接在onSuccess里调用utils.task.getAll.setData做乐观更新先把数据插进列表再让后台刷新兜底。客户端模式下还有一个值得现在就配好的默认值。在src/trpc/react.tsx找到 queryClient 的创建处改成这样import { QueryClient } from tanstack/react-query; export const queryClient new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, retry: (failureCount, error) { if (error instanceof Error error.message.includes(UNPROCESSABLE)) return false; return failureCount 2; }, }, }, });默认配置里refetchOnWindowFocus是开着的开发时你切一下浏览器窗口页面就发起一堆请求后台日志刷得密不透风。关掉之后你立刻会感到神清气爽。retry的默认次数也建议收敛一下参数错误这种问题重试多少次也没意义网络抖动这种临时错误重试两次就够。3.4 加一个认证与权限的小例子前面提到protectedProcedure这里实际落一下。假设任务应用需要登录后才能查看任务先在src/server/api/trpc.ts里增加一个基于 NextAuth session 的中间件import { initTRPC, TRPCError } from trpc/server; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session } }); });然后在 router 里直接使用getMine: protectedProcedure.query(({ ctx }) { return ctx.db.task.findMany({ where: { userId: ctx.session.user.id }, }); });这么一改前端没登录时调用这个接口会直接收到标准化的UNAUTHORIZED错误配合 TanStack Query 的错误处理可以做“弹出登录框”或“跳转登录页”的联动。认证、授权、数据隔离由类型安全贯穿起来基本不需要再手写手动检查逻辑。4. 常见问题与排查技巧实录4.1 环境变量与数据库连接类问题我建过十几个 T3 项目十个里有八个在数据库连接上栽过跟头。先把高频坑列成速查表报错或现象原因解决办法DATABASE_URL未定义.env没配或没重启 dev server配置后重启pnpm devPrismaClientInitializationError数据库服务没启动或连接串写错用 psql 手动连一次确认连接类型推导成 anyRouter 没挂载到 root 或类型导出缺失检查 root.ts 里是否挂载 routerNextAuth 启动报错启用了认证但没配置 NEXTAUTH_SECRET用openssl rand -base64 32生成密钥填入这里有个容易被忽略的细节.env文件的加载时机。T3 项目用的是 Next.js 的环境变量加载机制如果你改了.env里的连接串但 dev server 还在运行Prisma 用的还是旧值。我以为.env会热加载被坑了一次之后养成了“改完 .env 必重启 dev server”的习惯。另外提一句数据库选型。很多人第一次跑 T3 图省事用了 SQLite后面上生产切 PostgreSQL 时发现有些字段类型对不上。如果项目最终确定用 PG建议一开始就在.env里写postgresql://开头的连接串别等到部署前一天再临时切换。4.2 tRPC类型推导失效的三种排查场景类型推导失效是 T3 里最让人头疼的一类问题表面看代码完全正常但类型就是不对。我实际遇到过的场景有三种第一种是ctx类型没正确注入createTRPCContext里忘了返回db那所有 procedure 里的ctx.db都会报错而且错误信息往往指向类型本身而不是具体代码。第二种是复制了旧版本的 tRPC 示例代码createTRPCRouter或publicProcedure的导入来源不对从trpc/server直接导入而不是通过项目封装的~/server/api/trpc导致类型没有接入 context。第三种是改了.ts文件但 TypeScript Server 没重启类型缓存停留在老版本。这种情况在 VS Code 里特别常见CtrlShiftP搜 “TypeScript: Restart server” 就能解决。遇到类型推导问题我的排查套路是先确认链路最下游的类型有没有问题。打开prisma/schema.prisma跑一次npx prisma generate。生成成功后再进 taskRouter 把函数返回值 hover 出来看类型。从底层往上逐层排查基本能在十分钟之内定位问题所在。4.3 部署Vercel与生产环境优化T3 项目的部署思路和普通 Next.js 项目基本一致我推荐 Vercel它对 Next.js 的集成度很高。部署时几个高优检查项数据库连接串走环境变量不要硬编码不要在 git 仓库里提交.env。Prisma Client 要在构建阶段生成。Vercel 的构建流程里可以在postinstall脚本加一行prisma generate确保服务端的 Client 和当前 schema 同步。NextAuth 的密钥和数据库 session 表要在生产环境提前配好否则上线后登录直接白屏。性能优化上最核心的心法很简单能 SSR 的别 CSR能静态的别动态。T3 默认的 App Router 路由自由度很高但自由也意味着要自己掌控边界。我的经验是数据接口如果不需要实时性优先在服务端组件里直接调用 tRPC 的 server 端 API减少一次客户端请求的往返只有真正需要交互的组件才切到客户端 Hook。这里再分享一个小优化trpc/server.ts里已经导出了一个 server caller你在服务端组件里直接await api.task.getAll()是可行的。但每次请求都会重新创建 caller如果多个页面都在做同样的数据拉取可以在路由层加缓存而不是每次都打到数据库。5. 一些实际体会跑完整个 T3 项目之后我最深的感受反而不是“类型安全”这个技术概念本身而是它对我编程习惯的重构。以前写接口代码我会下意识在返回值里加各种?.或者|| {}因为不知道数据类型到底是什么担心后端哪天悄悄改了结构。在 T3 的链路里类型系统把这种防御压力全部接管了我的代码反而更干净、更直白。如果你正准备用这套栈去做自己的项目建议先别贪多。第一次就用 Next.js TypeScript Tailwind Prisma tRPC 这五件套跑通已经足够体验到端到端类型安全的爽感。等真正遇到痛点比如认证、服务端状态管理再逐步加 NextAuth 和 TanStack Query。T3 的可贵之处就在于它给的是完整底座而不是一堆需要自己拼装的零件。最后分享一个我整理成默认实践的小技巧任何 T3 项目里src/server目录是核心中的核心。动模型定义或 router 逻辑之前先想想“这条数据从数据库出来之后要经过几个文件再渲染到页面”。链路想清楚了类型大概率不会背叛你。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →