ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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 的 router,tRPC 的 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-app@latest 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 再手写一套 ResponseDTO,zod 定义一次,校验和类型两件事都搞定。

写完 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 className="p-6"> <h1 className="text-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 的 Hooks:

"use 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: "新任务" })} className="rounded 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 dev
PrismaClientInitializationError数据库服务没启动或连接串写错用 psql 手动连一次确认连接
类型推导成 anyRouter 没挂载到 root 或类型导出缺失检查 root.ts 里是否挂载 router
NextAuth 启动报错启用了认证但没配置 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 里特别常见,Ctrl+Shift+P搜 “TypeScript: Restart server” 就能解决。

遇到类型推导问题,我的排查套路是先确认链路最下游的类型有没有问题。打开prisma/schema.prisma,跑一次npx prisma generate。生成成功后,再进 taskRouter 把函数返回值 hover 出来看类型。从底层往上逐层排查,基本能在十分钟之内定位问题所在。

4.3 部署Vercel与生产环境优化

T3 项目的部署思路和普通 Next.js 项目基本一致,我推荐 Vercel,它对 Next.js 的集成度很高。部署时几个高优检查项:

  1. 数据库连接串走环境变量,不要硬编码,不要在 git 仓库里提交.env。
  2. Prisma Client 要在构建阶段生成。Vercel 的构建流程里可以在postinstall脚本加一行prisma generate,确保服务端的 Client 和当前 schema 同步。
  3. 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 逻辑之前,先想想“这条数据从数据库出来之后要经过几个文件再渲染到页面”。链路想清楚了,类型大概率不会背叛你。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进