开头直接进入主题,争取前100字融入"t3code"和T3 Stack。 ## 1. T3 Stack到底解决什么问题
1.1 先讲清楚:T3 Stack是哪几个技术拼起来的
"t3code"这个名字,很多人第一眼会以为是某个工具链的代号,其实它对应的就是前端圈这几年讨论度很高的T3 Stack——一套以TypeScript为底色的全栈应用技术栈。T3全称是Theo's 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可以做REST,Server 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-app:
npx create-t3-app@latest t3code运行之后你会看到一组交互式选择项,这就是这个脚手架做得很聪明的地方——它把所有可选技术摆在你面前,让初始化就按需定制。默认情况下它推荐的是全选:Next.js、Tailwind CSS、tRPC、Prisma、NextAuth,还有一个ESLint。我第一次搭建时直接全选了,但后来在不同项目里做过几种组合,这里给你参考:
- NextAuth(现在是Auth.js):如果项目需要登录、注册、第三方OAuth,选它是效率最高的。但它默认提供的是credentials、Google、GitHub这类常见方案,要做微信登录、企业微信扫码这类国内场景,还是得自己适配Provider。不打算做用户系统的项目可以不选,给需要登录的页面留好接口即可。
- Prisma:数据库层我建议无论项目多小都选。哪怕你只打算塞个把表单数据存到数据库,Prisma的schema管理能力也能避免后期数据模型混乱。如果不选Prisma,tRPC也照样能跑,但查询就得自己写SQL或改用其他ORM,整体的类型链路就会有断层。
- tRPC:唯一真正能不能犹豫的就是这个选项。如果项目里大部分页面是纯展示、静态内容居多,REST API更轻;如果交互密集、数据操作为主,那就选上,类型安全带来的收益会随着页面数量增长而放大。
还有几个非默认的选项:Drizzle ORM可以替代Prisma,Playwright用于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默认用的是SQLite,npx 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里调用tRPC
Next.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 name="title" placeholder="标题" /> <input name="slug" placeholder="slug" /> <textarea name="content" placeholder="正文" /> <button type="submit">发布</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但没配这个变量,认证相关请求会异常,连带影响部分路由。
还有一个比较隐蔽的问题:如果你部署平台用了自定义的basePath,tRPC客户端默认拼接的路径也得同步调整,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,体会到收益后自然就会知道哪里需要它。技术栈的学习,永远是从解决问题开始的。