ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

T3 Stack全栈开发实战:TypeScript、tRPC与Next.js构建类型安全应用

T3 Stack全栈开发实战:TypeScript、tRPC与Next.js构建类型安全应用

t3code 是我最近一个全栈小项目的仓库名。名字起得随意,但选型过程一点都不随意。当时要做的是一个给团队内部用的“灵感收集板”:登录后可以丢一条灵感,列表展示,能删掉,还能看到谁在什么时候写的。需求不算大,但麻烦在于它必须有用户体系、要有数据库、要跑在浏览器里,而且之后很可能要加移动端页面,我不想为同一套业务再写一遍接口和类型。我在 Next.js 全栈裸写方案和 T3 Stack 之间犹豫了很久,最后还是选了 T3 Stack。这篇文章就是你将在 t3code 项目里看到的完整实操记录,包括每个选择背后的原因,以及我踩进去又爬出来的几个坑。

1. T3 Stack 的构成逻辑:为什么是这四个东西凑在一起

1.1 T3 这个名字到底指什么

T3 这个叫法最早来自 Theo 对这套组合的命名,三个 T 开头的技术:TypeScript、Tailwind CSS、tRPC。但实际用起来,你会发现还有个没进名字的成员——Next.js。Next.js 在这里承担框架职责,负责页面渲染、路由、API 路由、服务端组件这些底座能力,TypeScript、Tailwind、tRPC 则在各自的层面把日常开发里最容易出问题的地方管住。

我第一次看到 create-t3-app 生成的目录时,第一反应是:这跟我自己搭的 Next.js 项目差不太多。真正不同的是 tRPC——它把“前后端接口”这个概念几乎消灭了。服务端定义一个函数,客户端直接调用,类型从数据库一路推到按钮上,中间没有任何手写接口类型、生成类型、维护文档的成本。这才是 T3 Stack 最有价值的部分,也是 t3code 选它的核心理由。

1.2 四个成员各自解决什么问题

拆开看,这四个成员的分工非常清楚:

  • TypeScript:让状态和数据结构在编译期被检查。尤其是大型重构时能少很多“改了一个字段,三个文件同时报错”的痛苦。
  • Next.js:提供文件路由、SSR/SSG、Server Actions 等能力,是承载应用的主干。没有它,前面三个 T 没有地方落脚。
  • Tailwind CSS:把样式从“为类名想名字”里解放出来,直接在标签上组合。对快速迭代、统一设计节奏帮助很大。
  • tRPC:端到端类型安全的核心。前后端共用一套类型定义,服务端改一个返回字段,客户端编译立刻报错,而不是等用户点到了才发现。

这里要澄清一个误区:T3 Stack 不是全家桶,它更像一个“克制”的挑选结果。它不给你打包 Vue、不默认引入 Redux 这类状态管理库,也不强行绑定某个 ORM(create-t3-app 才默认给 Prisma,你自己可以换成 Drizzle)。它的态度是:选一套经过验证的默认值,让你的项目先跑起来,然后所有选项都留给你自己把关。

1.3 t3code 这个项目要做什么

t3code 是我给这个灵感收集板起的仓库名。功能不复杂:登录、提交灵感、展示列表、删除自己的灵感。但选它做 T3 Stack 的实践样本,是因为它包含了一个典型全栈应用的几乎所有环节:数据库表设计、用户认证、受保护接口、CRUD、列表展示、删除确认、部署。

我见过太多人学全栈只跑通一个 hello world,然后问“下一步怎么办”。其实最好的下一步就是做一个这样规模的小产品:功能足够少,但链路足够完整。你会在里面遇到真实项目里一定会遇到的那几个问题,比如数据没刷新、接口被绕过、本地和线上表现不一致等。这篇文章后面的踩坑部分全部来自 t3code 的真实经历,可以对应到自己的场景。

2. 从 create-t3-app 初始化到 Prisma 落地

2.1 初始化时我勾选了哪些依赖

创建项目用一条命令:

npm create t3-app@latest

运行时它会依次问你要不要启用 NextAuth、Prisma、Tailwind、tRPC,以及选择 App Router 还是 Pages Router。我在 t3code 里勾选了全部四个,路由模式选了 Pages Router。

这里可能有人会问,App Router 不是 Next.js 新方向吗,为什么不用?我的理由很简单:Pages Router 加上 tRPC 的传统模式资料最全,遇到问题搜出来的答案基本都能直接用;App Router 的 Server Component 和 tRPC 配合使用时,需要额外注意 context 的边界问题,踩坑成本稍高。如果你是全新项目并且对 App Router 已经很熟,选它当然没问题。但 t3code 是个内部工具,我要的是稳定和快速,所以选择了更保守的方案。

初始化完成后,src 下的关键结构是这样的:

  • src/pages/:页面和 API 路由
  • src/pages/api/trpc/[trpc].ts:tRPC 的 Next.js 适配器入口
  • src/server/api/root.ts:tRPC 路由聚合入口
  • src/server/api/routers/:业务路由
  • src/server/db.ts:PrismaClient 单例
  • src/server/auth.ts:NextAuth 配置
  • src/utils/api.ts:客户端 tRPC React Hook

可能有人觉得这些是样板文件,不用关心。但我建议第一次接触的人还是打开每个文件看一眼,尤其是[trpc].ts这个文件,它是连接 HTTP 请求和 tRPC 路由的桥梁,默认长这样:

import { createNextApiHandler } from "@trpc/server/adapters/next"; import { createTRPCContext } from "~/server/api/trpc"; import { appRouter } from "~/server/api/root"; export default createNextApiHandler({ router: appRouter, createContext: createTRPCContext, });

你不需要改它,但得知道:后续如果要做请求日志、限流、自定义错误处理,都是在这里做。

2.2 定义 Prisma 数据模型

打开prisma/schema.prisma,我先写了 User 和 Post 两个模型。User 模型由 NextAuth 的适配器提供基础字段,我只需要补上posts关系:

model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? posts Post[] accounts Account[] sessions Session[] } model Post { id String @id @default(cuid()) content String author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

然后运行迁移:

npx prisma migrate dev --name init

这一步会在本地生成数据库文件和一些迁移记录。为什么 id 用 cuid 而不是自增整数?因为 t3code 之后大概率会部署在多环境,cuid 在分布式场景下不会撞,也不会因为删除数据后复用 id 造成诡异 bug。小项目用自增没毛病,但我个人偏好直接上 cuid,省心。

这里有个小细节:我故意没有在 Post 模型的外键关系上设置onDelete: Cascade,这会在后面坑我一次,先留个悬念。

2.3 造一点测试数据

页面还没写好之前,我想先确认数据链路通不通,于是写了 seed 脚本:

// prisma/seed.ts import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const user = await prisma.user.create({ data: { name: "demo", email: "demo@example.com" }, }); await prisma.post.create({ data: { content: "第一条灵感:咖啡不要泡太久,会苦", authorId: user.id, }, }); } main() .then(() => prisma.$disconnect()) .catch((e) => { console.error(e); process.exit(1); });

再在package.json里加一段配置:

"prisma": { "seed": "tsx prisma/seed.ts" }

这样以后随时可以用npx prisma db seed一键造数。别小看这一步,后续调试列表、测试删除逻辑时,有数据比啥都强。我第一次因为懒没写 seed,结果页面写好了,数据库空荡荡,还得现开 Prisma Studio 手动插入两条数据,效率低很多。

3. tRPC 把接口打散:一次定义、全程类型安全

3.1 router、procedure、query、mutation 分别是什么

tRPC 的核心抽象没有复杂到哪儿去。router 是一个命名空间,procedure 是一个“可调用的接口”。查询数据用 query,写操作(增删改)用 mutation,语义上对应 REST 的 GET 和 POST/PUT/DELETE。服务端还有一个 context,用来放每个请求进来时都有的数据,比如数据库实例和登录会话。

context 是我觉得 tRPC 设计得很聪明的地方。在src/server/api/trpc.ts里我做了这些事:

export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { db, session, ...opts }; };

每个请求进来时,这个函数都会执行,把数据库实例和当前用户会话塞进 context。之后所有 procedure 都能从ctx里拿到它们,不需要在每个接口里重复初始化 Prisma Client 或重新查一遍登录状态。

3.2 服务端定义三个 procedure

在src/server/api/routers/post.ts里,我定义了三个接口:列表查询、创建、删除。

import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); }), create: protectedProcedure .input(z.object({ content: z.string().min(1).max(200) })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { content: input.content, authorId: ctx.session.user.id, }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.post.delete({ where: { id: input.id }, }); }), });

注意create和delete用的是protectedProcedure,这个中间件长这样:

const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);

它做的事情是:如果 context 里没有登录用户,直接抛 401 错误;如果有,就把用户信息放进后续的上下文。你不需要在每个接口里重复写“如果没登录就拒绝”这种代码,中间件做一次,后面所有受保护接口自动带上校验。这个模式在 T3 Stack 里随处可见,算是标准写法了。

3.3 客户端直接用 React Hook 消费

服务端定义完,客户端的调用代码短得有点不真实:

const { data: posts, isLoading } = api.post.list.useQuery(); const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: async () => { await utils.post.list.invalidate(); }, }); const deletePost = api.post.delete.useMutation({ onSuccess: async () => { await utils.post.list.invalidate(); }, });

这段代码最妙的地方是:posts的类型、createPost.mutate的参数类型,全部来自服务端postRouter。服务端返回结构一旦改动,这一页的 TypeScript 会立刻报错。不是运行时才发现,是编译时就拦住你。

页面渲染部分,我直接用 Tailwind 搭了个简陋但能用的表单和列表:

<main className="mx-auto max-w-2xl p-8"> <h1 className="text-2xl font-bold">灵感收集板</h1> <form onSubmit={(e) => { e.preventDefault(); const form = new FormData(e.currentTarget); const content = String(form.get("content") ?? ""); createPost.mutate({ content }); e.currentTarget.reset(); }} className="mt-4 flex gap-2" > <input name="content" placeholder="丢一条灵感" className="flex-1 rounded border border-gray-300 px-3 py-2" /> <button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white"> 提交 </button> </form> <ul className="mt-6 space-y-4"> {posts?.map((post) => ( <li key={post.id} className="rounded border p-4"> <p>{post.content}</p> <div className="mt-2 text-sm text-gray-500"> {post.author.name} · {post.createdAt.toLocaleString()} </div> </li> ))} </ul> </main>

从数据库到页面,中间没有手写一个请求函数、没有定义一份接口类型、没有复制粘贴一个 DTO。数据模型变了,类型跟着变;类型变了,编辑器立刻告诉你哪里要改。这种体验用习惯了之后,很难再退回手写 REST 接口风格。

3.4 和 REST、GraphQL 的对比

有朋友问我,这不就是 GraphQL 吗?严格说不是,但目标上有重叠。我用表格直接对照一下:

方案类型安全程度学习成本典型适用场景
REST接口层弱,类型靠手写或工具生成低公共 API、多语言客户端、第三方开放接口
GraphQLSchema 层强,客户端类型需代码生成中高复杂查询、多端复用、需要自定义字段选择
tRPC端到端原生推断,零代码生成低(TS 项目)全栈 TypeScript 应用、内部系统、快速迭代产品

但是 tRPC 不是银弹。如果客户端不是 TypeScript,或者你需要把接口开放给第三方,tRPC 就不太合适了。t3code 是内部工具,没有这个顾虑。真遇到对外需求,也有trpc-openapi这类方案,可以在 tRPC 路由外面自动生成 OpenAPI 规范,把内部接口包一层对外暴露。所以我现在的态度是:内部先用 tRPC 拿到开发速度,对外再按需补标准接口,两条路都留得通。

4. t3code 里的三次踩坑记录:从白屏到删库报错

4.1 白屏:客户端 provider 没包全

有一次改代码,我为了梳理结构,把列表组件抽到了components/Board.tsx,然后在里面直接用了api.post.list.useQuery()。结果页面白屏,控制台报No TRPCClient instance provided。

我一开始以为是依赖没装对,反复检查package.json,甚至删掉node_modules重装了一遍。后来才发现,tRPC 客户端能力不是全局自动注入的,它依赖应用外层的一个 Provider。create-t3-app 的模板里,_app.tsx会用withTRPC包装整个应用,那个高阶组件才是查询 hook 能工作的前提。我把组件抽出去之后,组件还在 App 内部渲染,理论上不该有问题,但问题出在那个抽象组件是放在_app.tsx的包裹范围之外的某个位置被引用。

排查到最后,根因其实很简单:provider 必须包裹所有使用 tRPC hook 的组件。我重新检查了一下组件的引用链,把Board移回 App 页面内部,白屏立刻消失。这个坑对第一次用 tRPC 的人来说很容易踩,报错信息会把你指向@trpc/react-query但不会直接告诉你“你少包了 Provider”。

4.2 mutation 后列表不刷新:invalidate 没触发

写删除功能时,点击删除按钮,数据库里数据确实被删掉了,但页面列表纹丝不动。我第一反应是重新拉数据,于是翻代码,发现删除接口的 mutation 没有配onSuccess: invalidate。

这其实是 tRPC 配合 React Query 最常见的“坑”:mutation 成功之后,查询数据不会自动更新。你必须在onSuccess里手动失效对应查询,让它重新拉取。第一次接触时我觉得这个设计多此一举,但用久了反而觉得清晰——什么时候刷新数据、怎么刷新,主动权在自己手里,不用担心 React 偷偷做太多事情。

代码修正很简单:

const deletePost = api.post.delete.useMutation({ onSuccess: async () => { await utils.post.list.invalidate(); }, });

这里有个细节:invalidate返回的是一个 Promise,所以我在onSuccess里加了await。有人图省事直接在onSuccess里写一行utils.post.list.invalidate()不接await,在大多数场景没问题,但如果紧接着有别的操作依赖最新列表,就可能读到旧数据。

4.3 Prisma 外键约束:SQLite 和 PostgreSQL 的行为差异

本地用 SQLite 一切正常,上线到 PostgreSQL 后,删除一个用户时直接报外键约束错误。原因就是我前面留的那个坑——Post 模型里没有设置onDelete: Cascade。SQLite 默认不强制外键约束,但 PostgreSQL 强制。

这个错误的具体表现是:Foreign key constraint failed on field: post_authorId_fkey。我第一次看到这个报错时人有点懵,因为本地完全没出现过。排查方式是在线上数据库直接查看外键,然后立刻意识到 schema 定义里少了一句onDelete。

解决办法是在 Post 模型的关系上加上:

model Post { id String @id @default(cuid()) content String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

然后生成新的迁移,部署时先跑npx prisma migrate deploy,再重建应用。从那次之后,我给自己定了一条规矩:每次改 schema 后,至少要在两个不同性质的数据库上跑一遍迁移验证,不能只信本地默认配置。

4.4 useQuery 循环拉接口:别再用本地 state 镜像查询结果

最后一个值得提的坑和 setState 有关。我在一个组件里把posts数据又 set 到了本地 state,结果 useEffect 里的依赖项写着posts,导致查询完成 -> setState -> 组件重渲染 -> useQuery 重新触发 -> 又拿到新 posts,形成了一个拉接口的循环。

这个问题的排查过程比较痛苦,因为页面看起来一切正常,只是网络面板里请求在不停地刷。我一开始以为是 tRPC 的自动重试机制在搞鬼,把retry配成false也没用,后来才意识到是自己在组件里把“服务端数据”和“本地派生状态”搞混了。

解决办法是删掉那个多余的本地 state,直接消费data,只在确实需要用户输入做筛选时才额外派生状态。这个坑在从 Redux 习惯带过来的人身上特别容易出现,因为大家习惯把一切数据都放进自己的 store 再读出来。React Query 的数据本身就是“来源之一”,别再复制一层,记住这句话能少很多 bug。

5. 把 t3code 推向生产:认证、部署和性能检查

5.1 NextAuth 保护页面和 tRPC 的双层校验

t3code 作为一个需要登录的应用,我做了两层校验。页面层面用 NextAuth 的getSession在路由层做守卫,数据层面用 tRPC 的protectedProcedure。两层各司其职:页面层控制“看不看得到操作界面”,数据层控制“能不能调接口”。

如果只做了页面层,用户完全可以绕过页面 UI,直接发一个 POST 请求到 tRPC 端点修改数据。如果只做了数据层,未登录用户虽然调不了接口,但页面还是会尝试渲染一堆空状态,体验很差。所以两个都要。

NextAuth 的配置里,我最想提醒的是回调地址:

  • 本地开发是http://localhost:3000/api/auth/callback/github
  • 线上环境必须是https://你的域名/api/auth/callback/github

这个地址配置错了,登录会一直跳圈,浏览器控制台还会报redirect_uri_mismatch。我见过不少人卡在这里,其实不是代码问题,就是 provider 后台的回调地址没配对。

5.2 环境变量和 Prisma 生成客户端的两件小事

create-t3-app 会在项目根目录生成.env,里面关键变量有DATABASE_URL和AUTH_SECRET。我最常犯的错是把.env提交到 git,导致密钥泄露。正确做法是.env进.gitignore,用.env.example记录变量名,让别人 clone 之后知道要填哪些。

另外一件小事是:每次修改schema.prisma之后,记得在本地跑npx prisma generate,部署时也要确保 build 阶段执行了这一步。否则云上跑的 Prisma Client 还是旧版本,你新加的字段在线上根本查不到,而且不一定报错,很可能只是默默返回undefined。

5.3 部署到 Vercel 的体验和注意事项

在 Vercel 部署 t3code 没有太多波折,但有几个点值得记下来:

  1. 数据库连接串要填线上数据库的。我本地用 SQLite,但线上用了 PostgreSQL。create-t3-app 原生支持这个切换,关键是DATABASE_URL要指向线上数据库,并且要跑一次npx prisma migrate deploy。
  2. 环境变量必须逐个配到 Vercel 项目里。.env文件不会自动上传,我一度没在 Vercel 配NEXTAUTH_URL,导致生产环境登录跳转异常。
  3. build 阶段会执行 Prisma generate,但它不会自动跑迁移。所以数据库结构变更必须单独在部署流程里处理,不能指望 build 顺手搞定。

这几件事做完之后,t3code 部署一次成功。中间唯一一次回滚是因为我没改NEXTAUTH_URL,登录一进去就跳回登录页,花了十分钟才反应过来。

5.4 性能上的一点点取舍

对 t3code 这种规模的项目,性能问题并不突出。我做了两件事:一是给列表查询加了staleTime,避免每次切回页面都立即重新拉数据;二是把授权逻辑放在 tRPC 中间件里,减小无谓的数据库查询。

真正想说的是一个容易忽略的点:tRPC 的查询默认走 HTTP POST,有些 CDN 和浏览器缓存对 POST 请求比较保守,不会像 GET 那样自动缓存响应。所以别指望靠 CDN 层缓存来加速 tRPC 公共接口。需要公共读缓存的话,可以在服务端自己做 Redis,或者用 React Query 的staleTime在客户端控制请求频率。对小项目,staleTime往往就够用了。


最后说点个人感受。t3code 从写出第一行代码到完整跑在生产环境,前后大约两个晚上。真正耗时的不是写业务代码,而是搞明白为什么组件不渲染、为什么接口不刷新、为什么本地没问题线上却崩了。T3 Stack 把类型安全的门槛拉得很低,但它不替你解决工程素养——该写的迁移、该配的环境变量、该验证的边界条件一样都不能少。如果你也想动手试,我的建议是别只照抄官方模板,哪怕做一个像灵感收集板这样的小东西,然后把登录、增删改查、部署完整跑一遍,收获会比看十篇教程都大。仓库名就叫 t3code,希望这篇记录能让你少走一点弯路。

RELATED READING

延伸阅读

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