
t3code 这个词近两年在 GitHub 和前端社区里出现的频率非常高。你要是搜索一下九成会落到 T3 Stack 上——一套由 create-t3-app 脚手架串起来的 TypeScript 全栈方案Next.js 打底TypeScript 全覆盖Tailwind CSS 管样式tRPC 做 API 层再由 Prisma 和 NextAuth.js 补齐数据库与认证。老实说我一开始对这套组合是有点不屑的毕竟“全家桶”听起来就像“省事但锁死”直到我在实际项目里把它从开发到部署完整走了一遍才意识到它真正解决的痛点是什么全栈类型安全以及“API 层消失”之后带来的开发体验提升。这篇博文就从 t3code 的实际项目实践说起把它背后的设计逻辑、核心代码链路、部署经验和踩坑记录一次性拆干净。不管你是刚接触全栈 TypeScript 的新手还是想评估这套方案值不值得进生产环境的老手应该都能从中拿到点能直接用的东西。1. 先搞清楚 T3 Stack 在解决什么问题再决定要不要用它1.1 三个 T 是哪三个 T它们为什么能凑到一起T3 Stack 的名字源自 Theo Browne 的推广最初的含义比较“狭义”Three T指的是 TypeScript、Tailwind CSS、tRPC。后来社区里实际落地的组合通常会加上 Prisma 和 NextAuth.js于是有人开玩笑说这其实是 T5 Stack但名字已经叫开了大家也就继续沿用 T3。这个命名方式倒是很能体现这套方案的底子。TypeScript 提供的是全项目的类型系统从一开始就确定“每个变量的形状”。Tailwind CSS 解决的是样式问题它不提供什么设计哲学纯粹是让你在类名级别完成布局和组件视觉不需要维护一堆 CSS 文件。tRPC 则是整套方案里的核心角色它让前端直接调用后端函数类型能够穿透 HTTP 边界从数据库查询结果到前端组件里的变量全程类型一致没有中间商赚差价。这三个“T”组合起来表达的核心思路是在 Next.js 的单一项目内用类型系统把前后端粘合成一个整体。你不再需要像传统前后端分离项目那样后端写一份接口文档前端再根据文档手写类型定义因为这两件事在 T3 Stack 里根本不存在。1.2 从“要不要单独写 API 层”看 tRPC 的设计哲学先看传统模式。你有一个后端服务用 REST 或 GraphQL 暴露接口前端再用 fetch 或 react-query 跟它对账。这意味着每次接口改动你至少要同步更新三处后端实现、接口文档、前端的请求函数和类型声明。哪怕有 openapi-generator 这类工具帮你自动生成客户端整个链路依然很长而且一旦文档和后端代码不同步排查折磨死人。tRPC 的做法完全不同你在服务端写一个 router每个查询或变更就是一个普通函数前端引入同一个类型化实例直接调用这些函数。函数名、参数类型、返回值类型全部天然一致因为前端就是按同一个 .ts 模块来感知后端的。开发的时候改一个字段TypeScript 在编译期就告诉你哪里断了根本不会带到浏览器里去跑一遍才发现返回体对不上。这套设计在中小型项目、内部工具、以及前后端都由同一个仓库维护的场景下极其舒服。代价则是你需要接受“前后端不分家”的部署方式tRPC 基本是跟 Next.js 同进程跑做完一套 REST 风格、需要给第三方开放 API 的时候tRPC 并不是一个好的边界选择。2. 脚手架 5 分钟起步create-t3-app 的配置选项和目录结构解剖2.1 创建项目时的选项每一档该怎么选T3 Stack 官方提供了一条非常省事的路create-t3-app。直接执行npm create t3-applatest它会交互式地问你几个问题。我第二次用的时候其实就发现这些问答题背后对应的是一整套权衡值得在开工前花 30 秒想清楚选项推荐选择原因TypeScript是不选的话整套方案的意义都没了ESLint是默认配置对 tRPC、next、tailwind 的导入顺序有约束能少很多低级错误Tailwind CSS按需如果你有现成的组件库且不想改要改用语义化 CSS可以不开但我建议开tRPC是这是 T3 Stack 的核心不开就不是 t3code 了Prisma是类型安全的 ORM跟 tRPC 搭配让数据库字段直接流向前端NextAuth.js按需不做登录可以先不开但多数业务迟早要用建议开上App Router是新版本 Next.js 主角create-t3-app 默认推荐创建完之后npm run dev就是一套完整可跑的样板项目里面已经有 tRPC 的示例接口、NextAuth 的登录按钮、Prisma 的示例模型甚至内置了一个待办事项的 CRUD demo。我第一次跑起来的时候感觉有点像同时打开了 Vite、NestJS 和 Prisma Studio一开始会觉得“东西有点多”但跑通之后就能理解它为什么要把这些默认内容都塞进来让你照着示范代码改而不是从零摸索连接方式。2.2 装完之后目录结构到底在表达什么create-t3-app 生成的目录是理解 t3code 的关键我把核心部分拆开看src/ app/ _components/ api/ auth/ # NextAuth 的 API 路由入口 trpc/ # tRPC 的 Next.js 适配路由 layout.tsx page.tsx server/ api/ root.ts # tRPC 根路由所有业务 router 在这里汇总 routers/ post.ts # 示例业务路由 trpc.ts # tRPC 初始化、上下文、procedure 创建 auth.ts # NextAuth 配置 db.ts # Prisma Client 实例 trpc/ server.ts # 服务端 tRPC 调用入口 react.tsx # 客户端 tRPC hooks 封装 middleware.ts # 路由级鉴权守卫 prisma/ schema.prisma # 数据模型定义这套分层的核心逻辑很有意思。src/server底下放的是所有 Node 端代码包括数据库访问、认证配置和 tRPC 路由。src/trpc则负责处理前后端桥接react.tsx里封装了供 React 组件使用的 hookserver.ts里生成了可被 Server Component、Route Handler 直接调用的 caller。关键在于这套目录不是随便分着玩的。它默认帮你隔离了“服务端专用代码”。如果你在客户端组件里不小心 import 了prisma/dbNext.js 会直接报错提醒因为 Node 端的数据库客户端不能塞到浏览器 bundle 里。这个隔离机制从结构上就杜绝了我们曾经踩过的“浏览器里直连数据库”的大坑。3. 核心链路实操Prisma 数据模型、tRPC 路由、前端组件一次打通3.1 先定义数据库模型把类型安全的源头放在 Schema 里t3code 项目的类型链路起点在 Prisma Schema它定义的是数据库层的模型关系。为了演示我把它从默认的 demo 改成一个带多对多关系和软删除的博客模型这两点在实际业务里出现频率极高model User { id String id default(cuid()) name String? email String? unique posts Post[] likedPosts Post[] relation(PostLikedByUsers) createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String published Boolean default(false) deletedAt DateTime? author User relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String likedBy User[] relation(PostLikedByUsers) createdAt DateTime default(now()) updatedAt DateTime updatedAt index([authorId]) index([published, deletedAt]) }这里有几个值得展开的地方。default(cuid())选择 CUID 而不是自增 ID是因为它能在分布式环境下无冲突生成不用担心并发创建时拿到同一 ID。对论坛、博客这类可能做读写分离的场景CUID 也比 UUID 更紧凑索引性能更好。index([published, deletedAt])这个复合索引是我后来优化性能时加的。列表页经常要过滤“已发布且未删除”如果没有这个索引数据量上来后 Prisma 会全表扫。加索引前后在 10 万条数据下实测查询从 400ms 降到了 40ms 左右这个优化几乎是零成本。定义好 schema 后执行两个命令npx prisma migrate dev --name init npx prisma generatemigrate dev会在本地生成迁移文件并同步数据库generate则更新 Prisma Client 的类型定义。很多人只跑第一个忘了第二个导致后面 TypeScript 一直报类型错误其实不是代码问题而是 Prisma Client 没重新生成。3.2 tRPC 路由怎么写publicProcedure 和 protectedProcedure 的用法定义好模型后接下来是 tRPC 路由。T3 脚手架已经帮你初始化好了trpc.ts你只需要在routers/post.ts里写业务逻辑import { createTRPCRouter, publicProcedure, protectedProcedure } from ../trpc; import { z } from zod; export const postRouter createTRPCRouter({ listPublished: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ where: { published: true, deletedAt: null }, orderBy: { createdAt: desc }, include: { author: { select: { name: true } } }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(120), content: z.string().min(1).max(20000), }), ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), togglePublish: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) { const post await ctx.db.post.findUniqueOrThrow({ where: { id: input.id }, }); if (post.authorId ! ctx.session.user.id) { throw new TRPCError({ code: FORBIDDEN }); } return ctx.db.post.update({ where: { id: input.id }, data: { published: !post.published }, }); }), });这里有两个关键设计。protectedProcedure就是在trpc.ts里注入了一个中间件它的作用是检查ctx.session.user是否存在不存在就直接抛UNAUTHORIZED。这样你不需要在业务函数里写繁琐的 if 判断凡是需要登录的接口换一个 procedure 类型就行。togglePublish里我特意加了一层“资源归属校验”这个很多人最初会漏掉——只处理登录校验不处理权限校验。protectedProcedure只能保证“你是登录用户”不能保证“你有权操作这个 Post”。在实际项目里所有涉及删改的操作都要检查归属或权限这个逻辑写在每个 mutation 开头比做全局鉴权中间件再加上复杂的 ACL 要好理解得多。回到root.ts把新增的 router 挂上去就完成了后端import { createTRPCRouter } from ./trpc; import { postRouter } from ./routers/post; export const appRouter createTRPCRouter({ post: postRouter, }); export type AppRouter typeof appRouter;3.3 前端组件里如何消费 tRPC 接口以及 React Query 的隐藏增强前端使用就非常简单了t3code 的脚手架里已经封装了类型化 hooks直接在客户端组件里调用use client; import { api } from ~/trpc/react; export function PostList() { const { data, isLoading, error } api.post.listPublished.useQuery(); if (isLoading) return div加载中.../div; if (error) return div加载失败{error.message}/div; return ( ul {data?.map((post) ( li key{post.id} h3{post.title}/h3 p作者{post.author?.name ?? 匿名}/p /li ))} /ul ); }注意这里的api.post.listPublished你在后端 router 里怎么起的名字前端 hooks 就长什么样全链路由 TypeScript 约束。我改后端字段名时前端编译期直接标红比测试环境里接口 404 再回查代码要快太多了。不过初次用 tRPC 的人很容易忽略一个事useQuery返回的对象不是裸的 fetch 结果它背后是 React Query。这意味着你自动获得了缓存、请求去重、后台重新拉取、窗口聚焦时刷新等能力。比如你想做删除文章后的列表刷新最优做法不是重新调接口而是让 React Query 删除掉对应 key 的缓存并重新拉取。脚手架里封装了一个api.useUtils()方法const utils api.useUtils(); const deletePost api.post.delete.useMutation({ onSuccess: () { utils.post.listPublished.invalidate(); }, });这个invalidate比手动调用refetch要优雅得多它会告诉 React Query “这条缓存失效了”等组件重新订阅时再拉最新数据。我见过身边不少人刚上手 tRPC 时每操作完一次就直接router.refresh()刷新整个页面其实这种“控制反转”的小机制完全可以替代整页刷新把体验做成真正的 SPA 级别。4. 用户登录与权限控制NextAuth 接进 tRPC 上下文4.1 认证配置与 JWT 策略的选择T3 脚手架默认接入的是 NextAuth.js。create-t3-app 老版本里环境变量叫NEXTAUTH_SECRET和NEXTAUTH_URL新版本已经全面改成AUTH_SECRET和AUTH_URL用 GitHub 登录的话还需要AUTH_GITHUB_ID和AUTH_GITHUB_SECRET。项目创建后.env.example里有一份模板直接 cp 过去填就行# 生成一个强随机密钥 openssl rand -base64 32GitHub OAuth 应用的配置这里提醒一个最容易被坑的点在 GitHub 开发者设置里创建 OAuth App 时Authorization callback URL 一定要填http://localhost:3000/api/auth/callback/github本地开发和生产环境的 callback 地址必须分开配置。接下来看src/server/auth.ts的关键配置import { NextAuthOptions, getServerSession } from next-auth; import GitHub from next-auth/providers/github; import { PrismaAdapter } from next-auth/prisma-adapter; import { db } from ~/server/db; export const authOptions: NextAuthOptions { adapter: PrismaAdapter(db), providers: [ GitHub({ clientId: process.env.AUTH_GITHUB_ID!, clientSecret: process.env.AUTH_GITHUB_SECRET!, }), ], session: { strategy: jwt }, callbacks: { session: ({ session, token }) { if (token.sub) session.user.id token.sub; return session; }, }, secret: process.env.AUTH_SECRET, };这里特意选了 JWT 而不是 Database session。默认 NextAuth 走 database session 会把登录态存在 Session 表里每次请求都要查数据库。很多项目一开始数据库压力不大等用户量上来之后这就会变成一个隐藏的慢查询热点。JWT 策略则把会话信息直接签进 token 里但代价是需要额外传 user id 到 session。4.2 session 通过 tRPC 上下文传递到后端tRPC 和 NextAuth 打通的关键在src/server/api/trpc.ts文件里的createTRPCContextexport const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { db, session, }; };每次有 tRPC 请求进来这个函数都会先解析 cookie 里的 session然后把它塞进上下文的session字段。这样你在业务 procedure 里通过ctx.session就能拿到当前登录用户protectedProcedure才有判断依据。这里有个细节值得注意tRPC 客户端发起请求时默认是走fetchcookie 会自动携带你不需要额外做任何配置。但如果你的 Next.js 项目被部署到了跨子域名的环境可能需要检查 cookie 的 domain 配置否则 Session cookie 无法跨域传递登录态就“莫名消失”了。4.3 middleware 做页面级保护接口层面的校验靠的是protectedProcedure页面层面则靠src/middleware.tsexport { default } from next-auth/middleware; export const config { matcher: [/dashboard/:path*, /settings/:path*], };这个默认导出会让 NextAuth 在中间件里接管匹配路径的访问控制未登录用户会自动跳转到登录页。它和 tRPC 的protectedProcedure是两道防线中间件挡页面访问tRPC 挡接口调用。我实际体验下来两道都写绝不冗余——浏览器层面可以直接拦截一些未登录操作服务端校验则保证即使有人绕过前端发请求也拿不到数据。5. 部署到生产环境Vercel、Prisma 迁移和那些我踩过的坑5.1 环境变量与数据库迁移的执行顺序T3 Stack 最省心的部署方式是推到 Vercel。但第一次部署的人很容易在“数据库连接”和“认证密钥”上翻车。我的建议是严格执行这个顺序先在数据库服务商比如 Neon、Railway 或 Supabase建好生产库拿到连接串格式类似postgresql://user:passwordhost:5432/database然后在 Vercel 项目设置里配置环境变量除了DATABASE_URL不要忘记AUTH_SECRET和AUTH_GITHUB_ID / AUTH_GITHUB_SECRET。有个笨办法很实用把本地的.env内容逐项复制到 Vercel再删掉本地专用的 localhost 地址变量确保两套环境互不污染。关键点首次部署前要在本地先执行npx prisma migrate dev它会把不依赖真实数据库的 migration 文件生成到prisma/migrations目录提交到 Git。Vercel 构建时不会自动帮你跑 migration所以首次部署前你在本地执行npx prisma migrate deploymigrate dev和migrate deploy的区别要理清前者用于开发环境可能会重置数据后者只按迁移文件顺序执行变更不会动已有数据。生产环境务必用deploy别在线上敲migrate dev默认的 ddl 自动同步模式在多人协作时相当危险。另外很多人在 Vercel 上构建失败一查发现是 Prisma Client 没有生成。解决方案是在package.json里加一段scripts: { build: prisma generate next build }确保每次构建时Prisma Client 都基于当前 schema 重新生成。5.2 常见报错与排查速查表我整理了实际使用 t3code 期间遇到频率最高的几个问题顺手把排查思路也列出来了方便大家按图索骥错误现象触发场景排查步骤Error: P2002unique constraint创建重复记录通常是先查后建再插入之间的时间差检查 Prisma Schema 里的unique字段业务逻辑里改成 create 时 catch 错误而不是先 find 再 createUNAUTHORIZED但前端明明登录了protectedProcedure 的 session 为空检查 NextAuth 的session.strategy是否为jwt确认getServerAuthSession()能从 cookie 解析出 token检查 cookie 域名配置ERROR: relation Post does not exist生产环境没执行迁移验证迁移是否已提交在部署环境执行npx prisma migrate deployCould not find module ~/server/db路径别名没生效检查 tsconfig.json 里的paths配置是否包含~/*: [./src/*]以及是否在.env里定义了所有引用到的模块构建时提示 Prisma Client 未生成Vercel / Docker 环境把prisma generate加进 build 脚本或使用prisma generate --no-engine减小构建体积登录后跳转回首页而非原页面中间件重定向逻辑不符合预期在withAuth回调里自定义authorized逻辑把 Not 登录的跳转目标指向/login?callbackUrl...这表里的第 2 个问题最值得展开。UNAUTHORIZED但在浏览器里明明是登录状态我排查过三天最后定位到是SessionProvider只包在页面组件里但 tRPC 客户端是在_app.tsx外层创建的导致服务端渲染时拿不到 session。处理方法很简单确保SessionProvider包裹整个应用并且 tRPC 的createTRPCContext每次都重新调用getServerAuthSession()不要缓存 session。5.3 前后端分离场景下的补充思路不是所有项目都适合 t3code 这种单仓一体化模式。如果你明确知道未来要提供公开 API或者要接多端应用那么 tRPC 只适合做内部管理后台的前后端联通对外仍需单独的 REST 入口。补充一个实践过的思路Next.js Route Handler 里面可以调用 tRPC 的服务端 caller把 tRPC 路由包装成标准 REST 接口对外暴露。import { appRouter } from ~/server/api/root; import { createCallerFactory } from ~/server/api/trpc; const createCaller createCallerFactory(appRouter); export async function GET() { const caller createCaller({ session: null, db }); const posts await caller.post.listPublished(); return Response.json(posts); }这样你保留了 tRPC 的类型链路又不需要再写一次业务代码。我目前有个项目就是这个结构内部页面全走 tRPC外部接口部分手工包一层 Route Handler改动成本比维护两套独立服务低很多。6. 一些值得反复琢磨的细节与避雷建议6.1 Zod 校验不只是校验参数它还是类型来源很多人把zod当成了一个运行时校验库忽略了它在 tRPC 里的特殊地位。tRPC 的input定义如果是 Zod schema那么它的类型会被自动推导直接作为前端 hooks 的参数类型。你不需要额外写一个接口类型tRPC 的procedure本身就是类型契约。当你遇到“前端传了一个 string后端需要 number”这种经典问题Zod 会在运行时拦住并返回友好错误不会等到数据库报错才发现类型对不上。我建议把共享的 Zod schema 抽到单独文件里比如src/common/schemas/post.ts前端和后端都可以引用。这样表单校验和后端入参校验只写一份用户界面发的请求和数据库写的内容在任何一步都有类型约束。6.2 不要把服务端逻辑塞进客户端组件这个错误几乎是每个接触 t3code 的新手都会犯一次的在客户端组件里 import~/server/db或者~/server/api/root然后直接调用 Prisma 查询。Next.js 会很快给你一个“你正在引入 server-only 模块”的报错但这不只是报错的问题——就算你硬用环境变量伪造客户端也能跑你实际上已经把你的数据库凭据塞进了浏览器端 bundle。T3 脚手架的目录结构里src/server/*的访问权限本身就设计成只有服务端代码能引用。如果你把 server 代码引入到浏览器端即使能编译也可能在运行时报错。遵从这个约定前端组件只通过 tRPC hooks 或服务端组件的apicaller 拿数据这是 t3code 的第一条安全底线。6.3 关于 React Query 的缓存键和失效策略tRPC 的 hooks 把每个procedure的输入参数作为缓存键的一部分。这个设计在日常使用中表现不错但一旦 procedure 输入参数里带大数据对象比如一个复杂的筛选条件对象缓存键就会变得很长且难以维护。实战中我建议把复杂查询的筛选参数拆分成简单的字符串或 ID而不是直接丢整个对象进去。失效策略上也有一条经验不要每个 mutation 都无脑invalidate所有相关 query。如果 mutation 发生在列表页只失效列表的 key 就够如果发生在详情页只失效详情的 key。过度失效会导致低效的请求风暴尤其在 dashboard 类页面组件多的时候一整片渲染区域跟着闪烁。用utils.post.listPublished.invalidate()这种精准失效比全局utils.post.invalidate()体验好很多。写在最后关于要不要全面拥抱 t3code 的实话从我自己的实践体验来看T3 Stack 最适合的应用场景是单团队维护的全栈型项目、内部工具、以及处于快速迭代期的 early-stage 产品。你不需要在接口文档上花时间前后端联调的成本几乎被压到了最低哪怕是两个开发者协作只要双方都在改同一个仓库类型系统就会在编译期解决大部分协作问题。但如果是大团队、需要严格前后端隔离、有独立的 API 经济需求或者某个 API 要给第三方开放t3code 这套方案就不一定是首选它更擅长内部联通而不是对外暴露。最后说一个我实际操作中体会挺深的小技巧create-t3-app 生成的项目默认集成的 Tailwind、Prisma、NextAuth 等都处在较新的版本升级速度不慢。每次npx create-t3-applatest生成出来的代码结构可能和我这篇博文里的有细微差异但只要理解了 tRPC 上下文、procedure、router 这三个核心概念版本差异对你来说就只是命名和位置微调的问题万变不离其宗。你上手一个新项目真正要学的从来不是脚手架帮你生成的那些文件而是这套组合拳背后的“连起来”的思路。