如果你和我一样,把大量时间花在“前端写接口文档、后端改接口、前端再联调”的循环里,那你对 t3code 这个话题应该会有共鸣。t3code 是我给一套基于 T3 Stack 的全栈代码库起的代号——Next.js 加 TypeScript 打底,Tailwind 负责样式,tRPC 替代传统 REST,Prisma 管数据库,NextAuth 管登录。把它从脚手架一路跑到生产部署之后,我最深的感受是:以类型为契约的开发方式,是真的能把全栈联调的时间压缩一大截。
这篇文章不是 T3 Stack 官方文档的复述,而是我手头这个 t3code 项目的完整实战复盘。它包含我为什么放弃“前端一套、后端一套”的分离式架构,脚手架初始化时每个选项背后的含义,一条数据从数据库到页面再回到数据库的完整链路,以及部署阶段踩过的具体坑。如果你正准备用 TypeScript 做一个全栈项目,或者已经用 Next.js 但还在纠结要不要上 tRPC,这篇复盘应该能帮你省下不少试错时间。我也会把运行中遇到的六个典型问题单独拉出来讲,每个问题都附上完整的排查思路和最终解法。
1. 为什么押注 t3code:一次全栈重构带来的选择
1.1 被 API 联调磨掉耐心之后的反向选择
我手头有一个内部管理项目,最早是经典的“React 前端 + Node API 服务”两套代码分开部署。功能不复杂,但每次加字段都要经历一套固定流程:后端改数据库模型,写迁移脚本,改 API 返回结构,更新 Swagger 文档,然后前端照着文档改类型定义。运气好的时候接口十六个字段全对得上,运气不好的时候一个字段名拼错能来回耽误半天。
真正让我下定决心换方案的是有一次重构用户权限模块:后端改了接口返回结构,前端有七处调用,其中三处没同步更新类型,构建期居然没报错,直到用户反馈“明明有权限却看不到菜单”才被发现。这种问题本质上不是某个人的粗心,而是架构层面把前后端的类型契约弄断了。t3code 就是想用一套代码、一套类型定义,把从数据库到页面之间的所有类型全部串起来。
1.2 这套方案的四个角色和一条数据主线
T3 Stack 名字里的 T3 是 Theo's TypeScript Trifecta(Theo 的 TypeScript 三件套)的缩写,最初指的是 Next.js、TypeScript、Tailwind CSS 这三个基础件。实践层还会加上 Prisma、tRPC、NextAuth,这些在 create-t3-app 里都可以按需勾选。
我在 t3code 里明确分了四个角色:Next.js 负责页面渲染和路由,tRPC 负责前端到后端的方法调用,Prisma 负责数据库模型和迁移,NextAuth 负责认证会话。样式层面的 Tailwind 更多是锦上添花,它不影响架构,但确实让写页面快了不少。
这条数据主线的逻辑是这样的:Prisma 从数据库表结构生成 TypeScript 类型,tRPC 的 procedure 调用 Prisma 后直接返回带类型的对象,前端通过 api 对象调用这些 procedure 时,输入和输出类型都由 TypeScript 自动推导。也就是说,用户表新增一个字段,从数据库到页面响应全部类型自动跟上,改完不用对着文档反复对字段。
1.3 选择 t3code 前后的边界约束
当然,这套方案不是银弹。选择它之前需要接受几个约束:
- 前端和后端代码必须在一个仓库里,哪怕是 monorepo,tRPC 的跨端类型推导也需要两边能共享同一份类型定义。
- 服务端逻辑和 Node 运行时绑定得比较紧,如果未来要单独拆出给非 TypeScript 客户端调用(比如原生小程序、物联网设备),tRPC 协议不是不能被其他语言消费,但引用服务端定义的类型就没有那么顺滑。
- 团队需要有 TypeScript 基础,特别是泛型和类型收窄的概念。不然遇到类型报错会很难受。
我当时的判断是:类型安全带来的收益远大于单仓约束带来的不便,所以最终选择了 t3code。接下来就进入搭建环节。
2. 搭好骨架:create-t3-app 的初始化决策
2.1 交互式创建与每个选项的取舍
初始化 t3code 用的命令很简单:
npm create t3-app@latest t3code跑起来后它会问你几个问题,包括使用哪个路由器框架(App Router 还是 Pages Router)、要不要 Tailwind、要不要 tRPC、要不要 Prisma、要不要 NextAuth。我当时选择的是:App Router、Tailwind、tRPC、Prisma、NextAuth 全部勾上。
有一点值得多说:如果你不确定要不要 NextAuth,可以先不勾,因为后续手动接入的成本不算高;但 tRPC 和 Prisma 建议一次勾上,因为它们是 T3 数据链路的核心,脚手架生成的文件(比如_app.ts、trpc.ts)会帮你把初始化样板代码全部铺好,比手动搭省很多力气。
生成之后,脚手架还会提示数据库类型。我用的是 PostgreSQL,因为 Prisma 对 PostgreSQL 的支持最好,关系数据、数组类型、枚举都支持得很完整。如果你只是本地做原型,SQLite 也可以,但我会更建议直接上 PostgreSQL,避免后期换库。
2.2 生成后的目录结构藏着哪些约定
脚手架完成后,src 目录是典型的按功能组织的方式:
src/app/api/trpc/[trpc]/route.ts:tRPC 在 App Router 下的 HTTP 入口src/server/api/root.ts:根 router,所有子 router 在这里合并src/server/api/routers/:业务 router 所在目录src/server/db.ts:PrismaClient 单例src/trpc/server.ts和src/trpc/react.tsx:服务端调用和客户端调用的封装
这个结构最大的好处是约定清晰:你只管在 routers 目录下加文件、在 root.ts 里注册,其他都不用动。我后来新增了post.ts、user.ts等文件,全程没有改过路由配置。
2.3 第一个环境变量与本地启动验证
初始化完成后,第一件事是去根目录找.env文件,把DATABASE_URL配好。比如:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="本地开发随便填一段足够长的随机字符串"然后是经典的npm run db:push或者先npx prisma migrate dev做初始迁移,再npm run dev启动。如果页面能正常打开、没有红色报错,骨架就算通了。
这里有个非常容易忽略的点:脚手架生成的.env里可能没有NEXTAUTH_SECRET,如果你勾选了 NextAuth 但没配这个变量,启动时浏览器虽然能打开,但一访问登录相关页面就会报错。开发环境可以先用一段随机字符串,生产环境必须换成强随机密钥。
3. 打通读写闭环:tRPC 和 Prisma 的完整数据流实践
3.1 从 schema 设计到数据库迁移
t3code 里我建了一个最简单的业务模型:项目(Project)和任务(Task),一个项目下挂多个任务。Prisma 的 schema 是这样定义的:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Project { id String @id @default(cuid()) name String tasks Task[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Task { id String @id @default(cuid()) title String done Boolean @default(false) projectId String project Project @relation(fields: [projectId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }写完 schema 后跑:
npx prisma migrate dev --name init这会生成迁移 SQL 并应用到数据库,同时自动生成最新的 Prisma Client。在 t3code 这种全栈项目里,Prisma Client 的类型是后面一切类型推导的源头,所以每次改完 schema 记得跑迁移,不要只改文件不执行命令。
3.2 一个受保护 procedure 的诞生
tRPC 的核心概念是 procedure(过程)。一个 procedure 就相当于传统 REST 里的一个接口,但它包含输入校验、鉴权逻辑、业务逻辑和输出类型。
在我的 routers/project.ts 里,第一个给自己用的 procedure 是创建项目的接口,这里刻意用上了受保护 procedure(protectedProcedure),意思是未登录用户无法调用:
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const projectRouter = createTRPCRouter({ list: publicProcedure.query(({ ctx }) => { return ctx.db.project.findMany({ orderBy: { createdAt: "desc" }, include: { _count: { select: { tasks: true } } }, }); }), create: protectedProcedure .input(z.object({ name: z.string().min(1).max(50) })) .mutation(({ ctx, input }) => { return ctx.db.project.create({ data: { name: input.name, createdBy: ctx.session.user.id }, }); }), });这里ctx.session.user.id是 NextAuth 注入的登录用户信息,只有受保护的路由才有,公共路由里直接访问会类型报错。这个设计非常直观地体现了“鉴权是接口的一部分”。
3.3 客户端消费:从查询到变更的常规写法
前端消费这些 procedure 的方式,比传统 fetch 干净太多。在 React 组件里:
import { api } from "~/trpc/react"; function ProjectList() { const { data: projects, isLoading } = api.project.list.useQuery(); const utils = api.useUtils(); const createProject = api.project.create.useMutation({ onSuccess: async () => { await utils.project.list.invalidate(); }, }); if (isLoading) return <p>加载中...</p>; return ( <div> {projects?.map((p) => ( <div key={p.id}> {p.name}({p._count.tasks} 个任务) </div> ))} <button onClick={() => createProject.mutate({ name: "新项目" })}> 新建 </button> </div> ); }这段代码的重点在invalidate()。它启动一个背后逻辑很漂亮的机制:当 create 成功后,所有正在使用project.list.useQuery()的组件都会自动重新请求数据,不需要手动改 state、不需要刷新页面。如果你用传统 fetch 方案,这个行为至少要想半天:是重新拉全量数据,还是把新增对象塞进本地数组?在 t3code 里,这只是一个方法调用的事。
3.4 服务端组件里怎么优雅地“掺沙子”
App Router 下的服务端组件可以直接异步调用 tRPC 的 server 端封装,不需要经历 HTTP 往返。我写过类似这样的一段代码:
import { serverClient } from "~/trpc/server"; export default async function ProjectsPage() { const projects = await serverClient.project.list(); return ( <main> {projects.map((p) => ( <div key={p.id}>{p.name}</div> ))} </main> ); }但是这里有个陷阱:服务端组件里不能useMutation,也不能直接放交互按钮。你的做法应该是:页面主体用服务端组件渲染静态数据,交互部分(表单、按钮)拆到单独的“客户端组件”文件里,再把查询结果通过 props 传下去。我第一次写的时候试着在服务端组件里导入了一个带onClick的子组件,结果 Next.js 直接报错提示我加"use client"。这不是 bug,而是 App Router 的边界设计,理解它之后写起来反而更顺畅。
3.5 查询失效机制:invalidate 的正确打开方式
invalidate 虽然好用,但也不建议无脑全量失效。tRPC 支持按输入参数精确失效:
// 只失效 id 等于某值的详情查询缓存 await utils.project.byId.invalidate({ id: projectId });这个细节在高频更新场景下收益挺明显。我之前在任务列表页每次勾选完成就调用utils.all.invalidate(),结果导致整页所有 query 全部重新拉取,页面明显卡了一下。改成只失效task.list之后,体感流畅了很多。
4. 登录态怎么接:NextAuth 与会话类型的完整拼图
4.1 配置 Provider 和 JWT 策略的取舍
t3code 里我选的是 GitHub Provider,开发环境还加了一个假的 Credentials Provider 方便本地调试。配置写在src/server/auth.ts里,核心是给 NextAuth 传 providers、session、callbacks 三个字段:
export const authOptions: NextAuthOptions = { session: { strategy: "jwt", }, providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, }), ], callbacks: { jwt: ({ token, user }) => { if (user) token.id = user.id; return token; }, session: ({ session, token }) => ({ ...session, user: { ...session.user, id: token.id as string, }, }), }, };JWT 策略在无服务器环境下比数据库 session 更友好,因为它不需要在数据库里建 session 表,也不需要单独部署 session store。代价是你无法在服务端主动吊销一个还没过期的会话,但多数管理类项目可以接受这个权衡。
4.2 扩展 Session 类型的 module augmentation
接下来这一步最容易踩坑。如果你在 callbacks 里给 session.user 加了一个 id 字段,但 TypeScript 报错说user上没有id,那是因为 NextAuth 的默认 Session 类型只有 name、email、image 三个字段。解决办法是在项目里做一个类型扩展声明文件:
import { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }这个 file 放在项目的src/types/next-auth.d.ts下即可,不需要导入任何地方。之后在 tRPC 的ctx.session.user.id里就不会再报类型错误了。
4.3 路由保护:中间件校验和页面兜底各干各的
NextAuth 提供中间件方案来做整批路由保护,你可以在项目根目录下加middleware.ts:
export { default } from "next-auth/middleware"; export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"], };中间件的作用是“未登录则跳转到登录页”,但这只是第一道闸门。因为中间件主要做的是 URL 层面的跳转,真正到数据层时你需要依赖 tRPC 的 protectedProcedure 做第二道闸门。两个闸门的职责不同:
- 中间件负责用户体验层面:未登录就尽早跳到登录页,避免用户看到半个页面。
- protectedProcedure 负责数据安全层面:即使有人直接调后端接口,也拿不到受保护数据。
我见过一些项目只做了中间件、没在 procedure 上加保护,结果保护的是 UI 不是数据。这点在 t3code 里一定要两条腿走路。
5. 实测中翻过车的地方:六个典型问题的排查链路
5.1 tRPC v11 的 input 参数位置调整
我最初参考的是 tRPC v10 的文档,写法是useQuery({ input: { id } })。升级到 v11 之后,input 变成了第一个普通参数:
// v10 风格 api.project.byId.useQuery({ input: { id: 1 } }); // v11 风格 api.project.byId.useQuery({ id: 1 });这个改动很小,但如果你看的是旧文档,类型报错会一直提示“参数类型不匹配”。排查方式也很简单:点进useQuery的类型定义看参数签名,基本几秒就能定位。我一开始以为是自己项目配置问题,折腾了好一会儿才发现版本差异。
5.2 新增数据不刷新:Next.js 缓存的干预
有一个我在 t3code 里测试时反复出现的情况:调用 create mutation 后 invalidate 了,但页面上的列表就是不更新。后来我意识到这是 Next.js App Router 的路由缓存(router cache)在起作用。服务器数据已经更新了,但客户端页面层的缓存还没失效。
解决方案通常有三种:第一种是 mutation 的onSuccess里调用router.refresh();第二种是服务端数据更新后调用revalidatePath("/projects");第三种是强制让页面退出缓存(不推荐)。
我现在最常用的是router.refresh(),因为它语义清晰:数据变了,刷新当前路由。用的时候只需要在onSuccess中加一行:
const handleSuccess = async () => { await utils.project.list.invalidate(); router.refresh(); };5.3 Prisma 在无服务器环境的连接池焦虑
部署到 Vercel 这类无服务器环境时,PrismaClient 会被频繁创建销毁,连接数很容易打到数据库上限。脚手架生成的db.ts已经帮我们做了全局单例,但如果你手动写new PrismaClient()放在模块顶层,开发环境热更新时会不断创建新实例,日志里全是连接告警。
我的验证是:开发环境下必须用globalThis缓存 PrismaClient,生产环境用连接池。标准写法脚手架已生成,千万不要手贱去改它。如果你用 PostgreSQL,还可以考虑 Supabase 或 Neon 的连接池兼容端点,DATABASE_URL 里的pgbouncer=true参数也可以降低连接压力。
5.4 疯狂热更新中的类型卡死
t3code 开发过程中我遇到过一次非常郁闷的情况:明明已经改了 tRPC router 里的返回字段,前端组件里的类型提示却还是旧的,页面也不报错。这不是代码逻辑错了,而是 Next.js 开发服务器的类型缓存偶尔会卡住。
我的排查节奏是:先等几秒让编译器重新跑完,再不行就完全重启npm run dev。经验来说,改 schema、改 router 返回结构这类属于“类型级变更”,重启开发服务器的概率远高于普通组件改样式。这种时候不要怀疑自己写错了,先重启再观察。
5.5 router 合并遗漏时的报错定位
如果你新建了postRouter但忘记在root.ts里注册,客户端api.post.xxx会在构建时报错。错误信息比较绕,但定位思路很明确:打开src/server/api/root.ts,把新 router 加进createTRPCRouter({ ... })对象里。
我后来养成了一个习惯:每次新建 router 文件后的第一件事,就是去 root.ts 里注册,再回来写业务逻辑。这样可以避免把“忘记注册”和“业务写错”两件事搅在一起排查,省了很多时间。
5.6 zod 校验与 TypeScript 类型的双向奔赴
tRPC 默认用 zod 做输入校验,而 zod 的 schema 本身也能推导出 TypeScript 类型。很多人会在 zod schema 之外再手写一份 interface,结果是两份定义维护容易不一致。正确做法是让 zod 作为单一来源:
const createTaskSchema = z.object({ title: z.string().min(1).max(100), projectId: z.string().cuid(), }); type CreateTaskInput = z.infer<typeof createTaskSchema>;这样改校验规则时,类型自动跟着变。我在 t3code 里把这条定为约定:凡是通过 tRPC 的输入,一律用 zod 定义,不允许单独写 interface。这样做之后,前后端字段不一致的问题在项目里基本绝迹了。
6. 部署的最后一公里:Vercel 和自托管的取舍
6.1 环境变量和密钥的规范化管理
t3code 部署时我做了两套环境:一套是 Vercel 云平台,一套是自建服务器。有两件事必须先说清楚:
DATABASE_URL必须指向生产数据库,且不能提交到 Git 仓库。NEXTAUTH_SECRET/AUTH_SECRET必须用强随机值。我的做法是用openssl rand -base64 32生成一段 32 字节随机串。
一个常见的错误是直接把本地.env文件整体同步到服务器,结果本地调试用的 GitHub Provider 回调地址在生产环境全部失效。正确做法是每套环境建独立的.env文件(比如.env.production),并在部署脚本里指定加载哪一套。
6.2 数据库迁移的部署时机
大多数全栈项目出问题都出在这里:代码先部署了,数据库结构还没迁移,新代码一访问新表就 500。我的部署顺序固定如下:
- 先备份生产数据库(哪怕是云数据库的控制台快照)。
- 执行
npx prisma migrate deploy(注意,是deploy而不是dev,deploy不会尝试创建新的迁移,只会应用仓库里已有的迁移文件)。 - 确认数据库迁移成功后,再部署应用代码。
这个顺序反转过来的话,轻则启动报错,重则用户直接看到错误页。我专门写过一条部署检查清单贴在项目 README 第一行:先迁移,后发布。
6.3 自托管下的进程守护与反向代理
如果你像我一样在某些场景下选择自托管(比如内网系统、客户要求数据不出域),Vercel 那套一键部署就不适用了。我在一台 2C4G 的云服务器上做了一套组合:
- 用
pnpm build构建 Next.js 生产包,然后用 Node 直接跑next start。 - 用 PM2 做进程守护:
pm2 start "npm run start" --name t3code- 用 Nginx 做反向代理和 HTTPS 终结:
server { listen 443 ssl; server_name t3code.example.com; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; } }注意proxy_set_header Connection "upgrade"这行:Next.js 开发环境的热更新和生产环境的 server action 都依赖 WebSocket,如果没有这行配置,会导致部分请求失败或者页面交互异常。
6.4 一套体感良好的性能表现
t3code 核心链路压测下来,在同一内网环境下:tRPC 查询单条列表接口的响应时间在 80ms 左右,包含了数据库查询和序列化;页面首屏 TTFB 在 300ms 到 500ms 之间。这个数字在公网会高一些,但对一个内部管理系统来说已经非常够用。
有一点体验上值得注意:tRPC 的响应体是 JSON,体积一般比 GraphQL 小,比 REST 大一些(因为字段是服务端显式返回的)。如果你对带宽或者响应体大小有极端要求,可以在 query 里用select精确挑选字段。
最后再分享一个我在这个项目上养成的小习惯:每次改完 Prisma schema 之后,先跑npx prisma migrate dev,再跑npx prisma generate,最后重启 dev server。很多人会漏掉 generate 这步,于是 Prisma Client 的类型一直停留在旧版本,后面写所有代码都会在类型上踩坑。把这个三步动作刻进肌肉记忆之后,t3code 的开发过程明显顺了很多。这套方法在你自己接手任何 T3 项目时同样管用。