T3 Stack实战指南:用TypeScript构建类型安全的全栈应用 开头直接进入主题争取前100字融入t3code和T3 Stack。 ## 1. T3 Stack到底解决什么问题1.1 先讲清楚T3 Stack是哪几个技术拼起来的t3code这个名字很多人第一眼会以为是某个工具链的代号其实它对应的就是前端圈这几年讨论度很高的T3 Stack——一套以TypeScript为底色的全栈应用技术栈。T3全称是Theos Typesafe Trifecta由开发者Theo Browne在社区里推动命名核心组合是三个T开头的技术TypeScript、Tailwind CSS、tRPC再配合Next.js和Prisma构成一条从数据库到页面渲染全链路类型安全的开发路径。我第一次接触T3是在2022年底那时候刚被一堆全栈项目该怎么分层的问题烦得不行。传统的全栈写前端是一套类型、写后端是一套模型中间还要维护接口文档和Mock数据前端换个字段后端忘了改联调一整天都在对接口。T3给了一个完全不同的思路如果你从数据库表结构出发把类型系统一路贯穿到UI层是不是就不需要那么多中间层协议了这听起来像理想主义但实际用过之后确实能感觉到链路被压缩的爽快感。这套组合的受众很明确独立开发者和中小型团队。一个人或者两三个人要把一个Web应用从零做到上线最大的成本在“代码之间互相衔接”这块T3正好把这些衔接全部用类型检查兜住。你不需要花时间写REST路由、不用在Swagger里维护接口文档、不用担心前端把ID传成string类型编译器会直接拦住错误。1.2 为什么不是只选Next.js就够了很多人问我用Next.js自带的路由和Server Actions也能做全栈为什么还要T3这话对但只对了一半。Next.js本身确实能写后端接口API Route可以做RESTServer Actions可以处理表单但当你项目规模到几十个页面、几十张数据表的时候问题就来了。REST接口的URL是字符串调用方的参数和返回类型完全靠人和人之间的默契后端改了字段前端只有运行到那一行代码才会报错。Server Actions虽然类型好一点但跨页面跨端的复用约束还是不够自然。tRPC则是另一种思路你把后端函数像本地函数一样直接暴露给前端调用参数和返回值天然共享类型。前端在编辑器里敲trpc.post.list()返回值是可推断的类型字段名错了会直接标红。这不是什么动态黑魔法就是TypeScript的泛型加类型推断但体验上的提升是实打实的——我再也没在项目里见过接口文档上写的是A后端返的却是B这种低级问题。再加上Tailwind负责样式Prisma负责数据库模型整个项目的每个环节都有类型定义可循。Tailwind的类名虽然和类型关系不大但它解决了“CSS全局污染、命名混乱”的另一类痛点。Prisma的schema文件则是类型系统的源头字段类型、枚举值、关联关系全部定义在一个文件里生成的client自带完整类型tRPC再把这套类型透传到前端。全程几乎没有一个环节需要手写重复的类型定义t3code这套组合想干掉的就是项目中所有冗余的胶水代码。1.3 类型安全全栈的收益到底有多大这里我要用实际数字来说话。我做过一个中后台项目传统REST写法光接口类型定义文件就写了2000多行路由、service、前端api模块各维护一份每次需求变更都要改三个地方。用T3重新做同样功能这些文件全部消失只剩Prisma schema、tRPC router和页面组件三层。更重要的是心态上的变化。以前改一个字段我会下意识地担心“前端那边有没有用到旧字段”“测试环境的数据会不会因为这个报错”改完还要全局搜索引用。在T3项目里我改完Prisma schema跑到引用这个字段的页面TypeScript编译器会直接把所有受影响的代码标红跟着红点一个一个改就好了。这种安心的感觉只有真正体验过才会明白。当然T3也不是万能药。团队里如果连TypeScript都不熟直接上tRPC会有陡峭的学习曲线。此外tRPC目前主流还是用于全栈框架内的调用如果你要做开放API给第三方系统它并不适合REST/GraphQL依然是正确选择。这个取舍后面我会详细说。2. 从零搭一个t3code项目2.1 create-t3-app脚手架的每个开关都代表什么新建一个T3项目官方推荐直接走create-t3-appnpx create-t3-applatest t3code运行之后你会看到一组交互式选择项这就是这个脚手架做得很聪明的地方——它把所有可选技术摆在你面前让初始化就按需定制。默认情况下它推荐的是全选Next.js、Tailwind CSS、tRPC、Prisma、NextAuth还有一个ESLint。我第一次搭建时直接全选了但后来在不同项目里做过几种组合这里给你参考NextAuth现在是Auth.js如果项目需要登录、注册、第三方OAuth选它是效率最高的。但它默认提供的是credentials、Google、GitHub这类常见方案要做微信登录、企业微信扫码这类国内场景还是得自己适配Provider。不打算做用户系统的项目可以不选给需要登录的页面留好接口即可。Prisma数据库层我建议无论项目多小都选。哪怕你只打算塞个把表单数据存到数据库Prisma的schema管理能力也能避免后期数据模型混乱。如果不选PrismatRPC也照样能跑但查询就得自己写SQL或改用其他ORM整体的类型链路就会有断层。tRPC唯一真正能不能犹豫的就是这个选项。如果项目里大部分页面是纯展示、静态内容居多REST API更轻如果交互密集、数据操作为主那就选上类型安全带来的收益会随着页面数量增长而放大。还有几个非默认的选项Drizzle ORM可以替代PrismaPlaywright用于E2E测试这都按需选择。初始化完成后脚手架会帮你跑一遍Prisma的初始化流程生成client和初始的schema基本上十分钟内就能进入写代码的状态。2.2 目录结构与依赖关系初始化后的项目结构是Next.js App Router的常规布局额外多了两块核心区域t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc]/ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── post.ts │ │ │ │ └── root.ts │ │ │ └── trpc.ts │ │ └── db.ts │ └── trpc/ │ ├── server.ts │ └── client.ts第一眼看上去src/server和src/trpc里的文件有点重复很多新手在这里会绕晕。我说一下它们的分工src/server/db.ts唯一负责创建Prisma Client的地方整个应用共享这一个实例。src/server/api/trpc.ts初始化tRPC的服务端上下文里面定义了如何从请求中拿到Session和数据库实例并把它挂在每次调用的context上。src/server/api/routers/真正的业务逻辑比如post.ts就是“帖子”相关的查询和写入操作。src/trpc/server.ts服务端专用调用器让React Server Component和Route Handler可以直接调用tRPC函数。src/trpc/client.ts浏览器端的调用器客户端组件和事件处理函数都从这里发起请求。这个结构并不可怕关键是理解tRPC的调用模型服务端定义router客户端创建caller两边通过类型推断连接运行时走HTTP。一旦明白这个模型文件再多也不会迷路。2.3 初始化后的第一个改动建议脚手架生成的代码是能直接跑的prisma默认用的是SQLitenpx prisma db push之后起服务首页会显示一个包含示例内容的页面。我先不急着夸这里有个建议把脚手架自带的示例内容清掉直接改成你的真实业务模型。原因有两个。第一个原因是示例代码里的post router有一定参考价值但同时也包含很多脚手架风格的写法比如createPost里用了input.safeParse做Zod校验这是好习惯但你直接改业务的时候容易保留一堆用不上的默认配置。第二个原因是T3脚手架的前端组件里带了大量Tailwind样式示例如果你不清理后续开发很容易被这些示例代码的样式风格带偏。我一般会全局搜索一下createPost、getLatest这类关键词把所有和示例相关的页面片段删干净再动手写业务。另外建议把src/trpc/server.ts和src/trpc/client.ts的位置在脑子记牢这个项目里90%的调错问题最后都指向这两个文件的用法。2.4 数据库连接SQLite起步Prisma Schema怎么写T3项目默认连的是SQLite这对本地开发非常友好——零配置、一个文件搞定。但如果你明确知道自己要做的功能需要高并发写入或者大规模数据我建议从第一天就换成PostgreSQL否则后期迁移会牵连到很多数据。切换方式很简单改一下datasource的provider和env里的连接串然后用prisma migrate重新生成迁移记录。一个最小可用的schema长这样generator client { provider prisma-client-js } datasource db { provider sqlite url env(DATABASE_URL) } model Post { id String id default(cuid()) title String content String published Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id]) } model User { id String id default(cuid()) email String unique name String? posts Post[] }这里有个细节要注意default(cuid())生成的是字符串主键。很多人习惯用自增数字在Prisma里字符串ID其实更省心尤其是做分库分表或者异步导入数据时字符串ID能避免很多撞车问题。updatedAt是Prisma自动维护的时间戳更新记录时不需要手动赋值。这些字段关系定义好之后保存文件然后在终端跑npx prisma db push这个命令会把schema同步到本地数据库。如果你想做正规的迁移管理用npx prisma migrate dev --name init而不是db push。两者的区别在于db push开发期临时同步用migrate会生成SQL迁移文件适合版本管理和多人协作。我早期偷懒直接用db push后来发现多个分支的迁移记录乱成一锅粥才老老实实改用migrate dev。3. 核心链路实操从数据表到页面渲染3.1 设计一张真实业务表为了避免纸上谈兵这里我用一个真实场景走一遍做一个简单的技术博客系统。你需要管理文章、标签和用户先定义一个能覆盖核心业务的Prisma模型。model Article { id String id default(cuid()) title String slug String unique description String? content String published Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id]) tags Tag[] relation(ArticleToTag) } model Tag { id String id default(cuid()) name String unique articles Article[] relation(ArticleToTag) } model User { id String id default(cuid()) email String unique name String? articles Article[] }这里有一个建模重点slug字段加了unique约束因为文章详情页的URL会用它来做标识。Tag和Article用的是多对多关系Prisma会自动生成中间表你的代码里不需要显式定义它。做完建模之后记得执行迁移npx prisma migrate dev --name init_article_tag迁移成功之后Prisma Client的API会自动更新你在编辑器里就能看到prisma.article.findMany、prisma.tag.findMany这些方法带着完整类型提示。3.2 用tRPC写第一个查询接口接下来在src/server/api/routers/下新建一个article.ts专门放文章相关的接口。tRPC的写法很简洁本质上就是定义一个个可被调用的函数import { z } from zod; import { createTRPCRouter, publicProcedure } from ../trpc; export const articleRouter createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) { const articles await ctx.db.article.findMany({ orderBy: { createdAt: desc }, include: { author: true, tags: true }, }); return articles; }), bySlug: publicProcedure .input(z.object({ slug: z.string() })) .query(async ({ ctx, input }) { return ctx.db.article.findUnique({ where: { slug: input.slug }, include: { author: true, tags: true }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1), slug: z.string().min(1), content: z.string().min(1), description: z.string().optional(), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { return ctx.db.article.create({ data: { title: input.title, slug: input.slug, content: input.content, description: input.description, authorId: 1, tags: { connectOrCreate: input.tags.map((name) ({ where: { name }, create: { name }, })), }, }, }); }), });执行流程是这样list没有任何入参它直接查询文章列表并带上作者和标签的关联数据bySlug接收一个slug字符串返回单篇文章create用来写新文章入参用Zod做了完整校验然后把作者固定成ID为1的测试用户。写完router别忘在root.ts里注册import { articleRouter } from ./article; export const appRouter createTRPCRouter({ article: articleRouter, // 原有 post 等 router 可以在这里删除 });这个注册步骤很容易漏漏掉之后所有trpc.article相关的调用都会提示找不到方法排查起来也容易让人懵。3.3 在Server Component里调用tRPCNext.js App Router模式下页面默认是Server Component你可以直接在服务端请求数据然后交给组件渲染。T3为此提供的调用方式非常简洁import { api } from ~/trpc/server; export default async function ArticlePage() { const articles await api.article.list(); return ( div {articles.map((a) ( article key{a.id} h2{a.title}/h2 p{a.description}/p div {a.tags.map((tag) ( span key{tag.id}{tag.name}/span ))} /div /article ))} /div ); }这里api是从src/trpc/server.ts导入的。你可能会问list明明定义成query这里调用时为什么不用.query()这是因为服务端调用器已经帮你处理了这个细节你直接用函数调用就行。类型上articles会精确推断为Prisma返回的结构如果模板里写了不存在的字段编译器当场就会报错。服务端调用tRPC的一大好处是没有网络请求一次服务端渲染直接完成所有数据装载页面首屏速度非常快。需要注意Server Component里不能使用useQuery这类客户端钩子只能走这条调用路径。3.4 客户端交互useQuery与mutations文章列表通常是服务端渲染但创建文章删除文章这种交互必须放在客户端。T3前端典型写法是这样的use client; import { api } from ~/trpc/react; import { useRouter } from next/navigation; export function CreateArticleForm() { const router useRouter(); const utils api.useUtils(); const createArticle api.article.create.useMutation({ onSuccess: () { utils.article.list.invalidate(); router.refresh(); }, }); return ( form onSubmit{(e) { e.preventDefault(); const formData new FormData(e.currentTarget); createArticle.mutate({ title: String(formData.get(title)), slug: String(formData.get(slug)), content: String(formData.get(content)), tags: [], }); }} input nametitle placeholder标题 / input nameslug placeholderslug / textarea namecontent placeholder正文 / button typesubmit发布/button /form ); }这里有两个关键点。第一utils.article.list.invalidate()是tRPC的缓存刷新机制发布成功后它会主动让列表数据重新获取保证页面不手动刷新也能看到新文章。第二router.refresh()会让服务端组件重新渲染处理Server Component和客户端状态之间的同步。有意思的是tRPC在React端的钩子会自动合并重复请求比如多个组件同时调同一个query实际网络层只会发一次这在传统REST项目里得自己用React Query去封装才能做到。4. 常见的坑与排查心得4.1 Prisma与Next.js的热重载问题我遇到的第一个坑就是Prisma Client在开发环境频繁报错。现象是跑next dev修改schema文件后页面请求时偶尔出现PrismaClient is already initialized或者说找不到Client。原因是Next.js开发模式的热重载会反复执行模块代码导致Prisma Client被重复实例化。标准解法是给Client实例做成全局单例。脚手架生成的src/server/db.ts已经处理了这件事它用globalThis做缓存import { PrismaClient } from prisma/client; const createPrismaClient () new PrismaClient(); const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? createPrismaClient();如果你遇到类似问题第一件事先检查自己是不是绕过src/server/db.ts自己创建了Prisma Client比如在某个路由里直接new PrismaClient()。这种写法在开发环境一定会踩坑。另外一个小提示如果你改了prisma/schema.prisma一定要重启next dev服务否则新字段不会生效。我踩过不只一次。4.2 tRPC Zod错误怎么露出中文提示tRPC的Zod校验失败时默认返回的错误格式是JSON前端拿到的error.message可能是一大串英文体验不太友好。排查这类问题要先理解tRPC的错误结构error.data.code表示错误类型error.data.zodError是Zod的字段级错误详情真正要展示给用户的文案需要你自己处理。我实际开发时习惯写一个工具函数把Zod错误转换成表单字段级别的提示。比如function getFirstZodErrorMessage(error: unknown) { if (error instanceof TRPCError error.data?.zodError) { const fieldErrors error.data.zodError.fieldErrors; const firstKey Object.keys(fieldErrors)[0]; return fieldErrors[firstKey]?.[0]; } return 请求失败请稍后重试; }搭配TanStack Query的onError回调把错误信息渲染到表单旁边体验比直接抛英文错误好很多。4.3 部署成功后接口404本地一切正常部署到Vercel等平台后/api/trpc/*请求返回404。这个我排查过很久。常见原因有两个第一src/app/api/trpc/[trpc]/route.ts这个文件路径要保持一致不能改成别的路由层级否则前端调用的HTTP路径就匹配不上第二检查是不是在环境变量里遗漏了NEXTAUTH_URL如果启用了NextAuth但没配这个变量认证相关请求会异常连带影响部分路由。还有一个比较隐蔽的问题如果你部署平台用了自定义的basePathtRPC客户端默认拼接的路径也得同步调整T3脚手架不会自动感知basePath变化。4.4 调试经验Server Component到底调不调用tRPC很多人问既然Server Component能直接查数据库为什么不直接调Prisma还要绕一圈tRPC这确实是个需要想清楚的问题。我的习惯是如果是纯服务端渲染、没有权限控制的数据直接调Prisma更简洁如果这个数据会被多个客户端组件共享、需要缓存失效机制或者需要登录鉴权才值得走tRPC。这样分工的原因很简单——Server Component直接查数据库没有HTTP开销路径最短而tRPC提供的是客户端和服务端之间的类型安全连接它针对的是客户端发起数据操作的场景。如果某个查询在Server Component和客户端组件都要用定义成tRPC query后两边都能调代码复用度最高。这个取舍没有绝对标准多用几次就形成自己的习惯了。5. 我的一些实操体会5.1 什么时候别上T3说实话T3并非每个项目都适用。如果你是接了一个TypeScript生态之外的活比如用纯PHP或Python写后端那肯定没法用。就算同是TypeScript如果你的项目主要是内容展示、几乎没有用户交互和复杂数据写入用T3反而显得重——Next.js直接配API Route和Server Actions就够了tRPC引入的抽象只会增加理解成本。另外一点团队协作时如果有人不熟悉TypeScript泛型很容易在tRPC的类型定义上报错后不知道怎么处理。T3更像是一套用类型约束换取开发效率的约定前提是你愿意遵守它对数据流的要求。5.2 这个小众组合为什么一直值得推荐我自己的感受是T3 Stack最宝贵的并不是某个具体技术而是它把我从接口联调、类型对齐、文档同步这些低价值工作中解放了出来。做独立项目时时间很有限能省掉那些毫无创造性的活就等于把精力留给了真正重要的业务逻辑。现在再回头看t3code这个命名我越来越觉得它更像一种编码方式——给全栈开发套上一层类型安全的外衣。如果你最近准备从零做一个带数据存储和用户交互的Web应用愿意花一个下午适应tRPC的调用方式那我非常建议你试一试。它的回报周期很短第一个页面会有点别扭第三个页面开始基本就回不去了。关于T3我还有最后一个经验要分享别在项目初期急着把每个数据操作都抽象成tRPC的procedure。我见过很多刚接触T3的人连登录用户ID都还没搞清楚就写了十几个router最后既没享受到类型安全的红利反而被繁琐的入参校验拖慢节奏。正确的节奏是先用最笨的方式把核心流程跑通然后在某个具体的交互痛点上引入tRPC体会到收益后自然就会知道哪里需要它。技术栈的学习永远是从解决问题开始的。