
前阵子我用 T3 Stack 从零搭了一个内部任务看板整个过程跑下来最大的感受是当你受够了前端写一堆fetch、然后手工定义 interface 去对齐后端接口的日子这套组合能让你舒服得有点不真实。t3code 这个名字在 TypeScript 圈子里越来越常见指的就是 Next.js TypeScript Tailwind tRPC Prisma NextAuth 这一整套全栈方案。今天这篇不是官网 Doc 的搬运而是我实际把整个项目从初始化跑到 Vercel 上线后的完整记录包括选型逻辑、目录拆解、tRPC 数据通路的真实用法、Prisma 的查询坑、NextAuth 接入细节以及部署时踩过的几个雷。内容适合已经会 React 和基础 TypeScript、想搞明白 typesafe 全栈到底怎么落地的人。1. 为什么我最终把 T3 Stack 定为全栈首选选型逻辑拆解1.1 传统全栈开发最痛的一环类型断裂先聊一个我做项目多年最头疼的问题。以前前后端分离后端返回的 user 对象里有一个nickname字段前端页面代码用的是user.name后端某个版本把字段改成了nickname。本地开发一切正常因为前后端各自类型定义都是自己维护的谁也没法保证另一端“实际发的数据”和“你以为的数据”是一致的。直到某个页面渲染出undefined你才顺着网络请求去查。这种问题在中小团队里非常常见本质原因是类型只存在于代码注释和文档里运行时没有强制约束。T3 Stack 的核心思路就是把这个断裂补上。它要求全栈都用 TypeScript并且在前后端之间建立一条“类型直通”的通道——这条通道就是 tRPC。你在后端写一个函数前端调用这个函数时返回值的类型直接通过 TypeScript 的类型推断自动带过来。字段改了前端编译直接报错根本轮不到运行时崩。1.2 每一个组件的选型理由为什么是它而不是别的T3 不是随便把几个工具拼在一起每一层替换都会破坏整个“类型闭环”。我拆开讲Next.js 而不是 Vite ExpressT3 需要服务端渲染、API 路由和前端一体化的能力。Next.js 的 App Router 天然支持 RSCReact Server Components你可以在服务端组件里直接调用 tRPC 的 server caller体验非常顺。Vite Express 当然也能搭但你要自己处理 SSR 壳、路由分层、构建部署这些 Next.js 已经帮你解决得很成熟了。TypeScript 而不是 JavaScript这没什么好辩论的T3 的整个价值都建立在类型推断上。团队如果不是 TS 技术栈建议先别上 T3后面我会单独说这个问题。tRPC 而不是 REST / GraphQLREST 的问题在于接口协议和类型是脱节的你需要额外维护 OpenAPI 文档、再生成客户端类型链路一长就没人愿意更新了。GraphQL 确实有 schema 定义但你要维护一套 schema 文件、写 resolver、处理缓存和权限复杂度上了一个台阶。tRPC 的做法最“朴素”后端直接定义一个函数前端调这个函数类型从函数签名里自动推过来没有中间商。Prisma 而不是手写 SQL / Drizzle / TypeORMPrisma 用一套 schema 文件管理模型迁移、类型生成、关联查询一站式解决和 TypeScript 配合得几乎是零摩擦。Drizzle 也设计得很好它在 SQL 层面更透明但生态和文档成熟度目前还是 Prisma 占优。Tailwind 而不是 CSS Modules / styled-componentsT3 图谱里选 Tailwind 更多是“实用主义”。写内部工具和 MVP 的时候Tailwind 的原子类可以让迭代速度快很多不需要频繁切文件。NextAuthAuth.js而不是自建 sessionOAuth、邮箱登录、JWT/Database Session 开箱即用。T3 默认把它接进 Prisma 的数据模型和 protectedProcedure 配合可以做到路由级、数据级双重保护。1.3 T3 的三条核心原则类型安全、渐进式、按需取用T3 的开源项目里反复强调三个原则类型安全优先、渐进式采用、按需添加。类型安全就是上面说的那条数据通路渐进式是指你可以在一个已有的 Next.js 项目里逐步引入 tRPC不需要一次重写按需添加则是 create-t3-app 初始化时让你勾选组件不用的功能可以不装。我的建议是第一次玩 T3初始化时把 Prisma、tRPC、NextAuth、Tailwind 全勾上哪怕你暂时用不到 NextAuth。因为 T3 的分层比你想的要深后面发现少了认证模块想再加改动会比较麻烦。我自己的项目就是一开始没勾 NextAuth后期要加登录结果把 schema、context、middleware 全翻了一遍。2. 从 create-t3-app 出发初始化与目录结构的每一层都在干嘛2.1 交互式初始化每个选项背后的含义创建项目很简单一条命令npm create t3-applatest my-app命令跑起来会问你几个问题。我逐个说下实际经验Project name项目目录名注意不要用大写字母和中文。Next.js 对目录名比较敏感后面路由映射会出幺蛾子。Packages直接全选。我第一次只选了 tRPC 和 Prisma漏了 Tailwind后面对照官方文档总觉得少了什么补装又涉及 PostCSS 配置很麻烦。tRPC or Server Action这里会问你用不用 React Server Actions 替代 tRPC。我的看法是如果你的核心诉求是数据变更和复杂查询tRPC 的 router 和 procedure 结构更清晰如果你只做简单的表单提交Server Action 也够用。但既然我们聊的是 T3 Stack老老实实选 tRPC。App Router or Pages Router新项目直接用 App RouterT3 官方已经在全面迁往 App RouterPages Router 方案的社区维护力度在降低。Proxy 设置如果你本地网络需要代理在这里配置即可不影响最终产物。初始化完成后加--no-git可以避免它自动初始化 Git 仓库我通常加上然后用自己的仓库模板再整理一次提交记录。2.2 生成项目目录的逐层拆解T3 生成的项目结构很克制但每一层都有明确职责。我按我的理解拆一遍my-app/ ├── prisma/ │ └── schema.prisma ├── public/ ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ ├── auth/ │ │ │ │ └── [...nextauth]/ │ │ │ └── trpc/ │ │ │ └── [trpc]/ │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── _trpc/ │ │ ├── Provider.tsx │ │ └── server.ts │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── post.ts │ │ │ │ └── root.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── react.tsx │ │ └── server.ts │ ├── env.js │ ├── styles/ │ └── ...server/db.ts全局唯一的 PrismaClient 实例。它用了globalThis缓存 PrismaClient防止 Next.js 开发模式热更新时反复创建实例把数据库连接数打满。这一段代码非常关键我放在第 4 章细讲。server/auth.tsNextAuth 配置getServerSession 的封装供 tRPC context 和服务端组件使用。server/api/trpc.tstRPC 的初始化文件。这里定义了 context 创建、errorFormatter、publicProcedure 和 protectedProcedure。protectedProcedure 的逻辑就是检查 session 里有没有 user没有就直接抛 UNAUTHORIZED。这是 T3 项目里所有需要登录才能调用的接口的统一入口。server/api/routers/业务 router 都放这里root.ts负责聚合所有子 router。app/api/trpc/[trpc]/route.ts这是 Next.js 的 API 路由把 HTTP 请求桥接到 tRPC 的 HTTP server。你不需要改它但需要知道它是整个数据入口。_trpc/Provider.tsx前端的 tRPC React Provider包在应用最外层负责和 React Query 连接。env.jsT3 的 env 校验模块。2.3 env.js 的强制校验三类变量最容易踩坑T3 的env.js用的是 Zod 对process.env做运行时校验。项目启动时如果缺变量或者格式不对它会直接打印缺失列表并退出。我最常踩的坑有三类DATABASE_URL 格式错误T3 默认只认postgresql://开头的连接串。你如果把 SQLite 的连接串填进去启动直接报错。NEXTAUTH_SECRETNextAuth 在本地开发时如果不填会用随机值但部署到 Vercel 后 session 会随机失效。必须到 Vercel 控制台里配置一个固定值。我一般是先在本地openssl rand -base64 32生成一个然后本地和线上用同一个。NEXTAUTH_URL本地默认是http://localhost:3000。部署到 Vercel 后如果你用了 Preview 环境回调地址会变成 preview URL这时候需要在 Vercel 的环境变量里把NEXTAUTH_URL设置为最终的正式域名。T3 定的规则是.env文件只保留本地配置不要在代码里硬编码任何环境变量。3. tRPC 数据通路实操从 Task 模型到页面按钮的完整类型流3.1 先用 Prisma 把数据模型立起来这一节我用一个任务管理看板的真实例子来讲。先写prisma/schema.prismagenerator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] tasksCreated Task[] relation(TaskCreatedBy) tasksAssigned Task[] relation(TaskAssignee) } model Task { id String id default(cuid()) title String completed Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt createdBy User relation(TaskCreatedBy, fields: [createdById], references: [id]) createdById String assignee User? relation(TaskAssignee, fields: [assigneeId], references: [id]) assigneeId String? }如果你初始化的时候选了 NextAuthT3 会在 schema 里自动生成 User、Account、Session、VerificationToken 四个基础模型。我上面的 User 模型其实是把 T3 生成的模型又加了两个“任务”相关的关联关系。注意 Prisma 里双向关系需要relation(命名)来消除歧义因为 Task 和 User 之间有创建人和指派对象两条路径。然后执行迁移npx prisma migrate dev --name add_task这条命令会生成迁移 SQL 并自动重新生成 Prisma Client。Task模型的 TypeScript 类型会立即出现在prisma/client里供 tRPC router 使用。3.2 写第一个 Task routerprocedure 的本质就是一个服务端函数tRPC 的核心概念是 procedure。你可以理解为一个服务端函数它天然拥有输入校验、鉴权和类型推断。下面这段代码是我的 task router// src/server/api/routers/task.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; import { TRPCError } from trpc/server; export const taskRouter createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(80), assigneeId: z.string().cuid().optional(), }) ) .mutation(async ({ ctx, input }) { const task await ctx.db.task.create({ data: { title: input.title, createdById: ctx.session.user.id, assigneeId: input.assigneeId, }, }); return task; }), list: protectedProcedure .query(async ({ ctx }) { return ctx.db.task.findMany({ where: { createdById: ctx.session.user.id }, orderBy: { createdAt: desc }, include: { assignee: true }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) { const existing await ctx.db.task.findUnique({ where: { id: input.id }, }); if (!existing || existing.createdById ! ctx.session.user.id) { throw new TRPCError({ code: FORBIDDEN }); } return ctx.db.task.update({ where: { id: input.id }, data: { completed: !existing.completed }, }); }), });这段代码里有几个点我特别想强调protectedProcedure的 context 里已经通过中间件注入了ctx.session.user所以你在 mutation 里可以直接取用户 ID不用再从参数传。这样就从机制上防止了“你可以把任意人的 ID 传上来然后把别人的任务改掉”的安全漏洞。z.object的校验不仅拦截非法请求还同时承担了文档职责。前端调 create 时编辑器里会直接提示title是字符串、长度范围是多少。toggle 里我做了显式归属校验先查这条任务在不在、是不是当前用户的不是就抛FORBIDDEN。这是数据级权限的最后一道闸门。TRPCError有很多错误码UNAUTHORIZED是登录态问题FORBIDDEN是归属权问题NOT_FOUND是资源不存在BAD_REQUEST是参数错误。用好这些码前端错误处理能省很多事。3.3 前端调用useQuery 和 useMutation 的体验前端代码长这样// src/app/tasks/task-list.tsx use client; import { api } from ~/trpc/react; export function TaskList() { const utils api.useUtils(); const { data: tasks, isLoading, error } api.task.list.useQuery(); const toggleTask api.task.toggle.useMutation({ onSuccess: () { // 成功后让 list 缓存自动失效重新拉取最新数据 utils.task.list.invalidate(); }, }); if (isLoading) return p classNametext-gray-500加载中.../p; if (error) return p classNametext-red-500加载失败{error.message}/p; return ( ul classNamespace-y-2 {tasks?.map((task) ( li key{task.id} classNameflex items-center gap-3 border p-3 span className{task.completed ? line-through : }{task.title}/span button classNameml-auto rounded bg-blue-600 px-3 py-1 text-white onClick{() toggleTask.mutate({ id: task.id })} 切换完成状态 /button /li ))} /ul ); }这里最神奇的地方在于api.task.list.useQuery()返回的tasks数据类型是 tRPC 根据后端 router 的.query()返回值自动推导出来的。你在后端findMany里加了include: { assignee: true }前端task.assignee.name立刻就有类型提示。你后端改了一个返回字段前端编译直接报错根本不会出现运行时undefined。api.useUtils()是 tRPC 暴露出来的缓存操作工具utils.task.list.invalidate()意思是让task.list这条查询失效并重新请求。这是写 mutation 后刷新列表最标准的方式React Query 的 cache 机制帮我们省掉了手写刷新逻辑。3.4 服务端组件和客户端组件的两条路server caller 到底怎么用在 App Router 下tRPC 有两条调用路径客户端组件用api.task.list.useQuery()hooks走的是 HTTP React Query。服务端组件用api.task.list()直接调 server caller走的是进程内函数调用没有 HTTP 往返。T3 的src/trpc/server.ts里已经帮你封装好了 server caller// src/trpc/server.ts import { createTRPCContext } from ~/server/api/trpc; import { appRouter } from ~/server/api/root; import { createCallerFactory } from trpc/server; export const serverClient createCallerFactory(appRouter); export async function caller() { const ctx await createTRPCContext({ headers: await headers() }); return serverClient(ctx); }服务端组件里这么用// src/app/page.tsx import { caller } from ~/trpc/server; export default async function HomePage() { const tasks await caller().task.list(); return TaskList initialTasks{tasks} /; }这种写法很优雅但我提醒一句不要在服务端组件里调用需要多级嵌套的 mutation比如提交表单。服务端组件更适合做初始数据的预取交互仍交给客户端 hooks。另外server caller 没有 React Query 的缓存机制它每次都直接查库所以只在初始化页面数据这种场景下用不要当成万能方案。4. Prisma 实战课程schema 设计、seed 脚本和查询层的三个坑4.1 schema 设计从“能用”到“好用”的关系建模我见过不少初学者在 Prisma 里一上来就给所有字段加optional理由是会报错少。这其实是本末倒置。Prisma 的 schema 就是你的数据合约字段的可空性直接影响 tRPC 前端推断出来的类型。String?推到前端就是string | undefined你的页面代码就必须处理这个 undefined反而更麻烦。我设计模型的习惯是业务必需的字段一律非空默认值用default()给好。用户可选的补充信息才用?比如assigneeId这种确实是可空的外键。多对多关系用隐式中间表即可除非中间表需要额外字段比如关联的创建时间、角色那才需要显式定义中间模型。软删除字段命名统一用deletedAt DateTime?查询时统一加where: { deletedAt: null }。Prisma 没有原生的软删除支持得靠约定。4.2 migrate dev、db push 和 seed三个命令的正确用法Prisma 官方有一条命令区分很多人不清楚npx prisma migrate dev生成迁移文件按顺序应用到开发数据库并生成 Client。适合团队协作时使用迁移文件进 Git。npx prisma db push直接把 schema 同步到数据库不生成迁移文件。适合本地原型迭代数据库内容可能会被重置。npx prisma migrate deploy生产环境执行未应用的迁移。我自己的流程是本地开发时模型没定型用migrate dev每次都会给迁移起个名字方便以后回滚。等模型稳定了再拉一个 trunk 分支合并部署时用migrate deploy。seed 脚本也要提一下。T3 初始化时会在package.json里配置好prisma.seed默认指向prisma/seed.ts。如果你要自己写 seed// prisma/seed.ts import { PrismaClient } from prisma/client; const db new PrismaClient(); async function main() { const user await db.user.upsert({ where: { email: adminexample.com }, update: {}, create: { name: Admin, email: adminexample.com }, }); await db.task.createMany({ data: [ { title: 初始化项目仓库, createdById: user.id }, { title: 给 T3 项目写部署文档, createdById: user.id }, ], }); } main().finally(async () { await db.$disconnect(); });seed 文件里必须自己new PrismaClient()不要复用src/server/db.ts里的全局实例因为 seed 进程是独立于 Next.js 的。4.3 查询层的三个坑N1、分页选择、全局实例第一个坑是 N1。Prisma 默认不会自动关联查询findMany只返回当前模型字段。如果你在循环里逐个访问task.assignee.name每个任务都会触发一次额外查询。解决办法是在查询里显式include: { assignee: true }。我的习惯是列表页一次性把需要的关联include出来别指望懒加载。第二个坑是分页。Prisma 提供skip/take的 offset 分页也有基于 cursor 的游标分页。内部工具数据量小offset 无所谓但如果表会超过几万行游标分页更有性能优势。写法如下const tasks await ctx.db.task.findMany({ cursor: input.cursor ? { id: input.cursor } : undefined, take: 20, orderBy: { createdAt: desc }, });第三个坑是 PrismaClient 全局实例问题。Next.js 开发模式下每个文件热更新都可能重新执行模块如果你在模块顶层new PrismaClient()大概率会看到Connections limit exceeded。T3 的做法是// src/server/db.ts import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;开发环境把实例挂到 globalThis 上避免每次热更新重新建连接生产环境没这个必要因为 Next.js 的 prod 模式不会反复执行模块。这个模式是所有 Prisma Next.js 项目的标配自己手写项目时也记得抄上。4.4 一个容易被忽视的查询边界不要把整个表拉回来很多人写 tRPC 的.query()时直接findMany()返回全部字段前端再筛选。数据量小的时候问题不大但一旦任务表过万请求体就会非常臃肿。我通常会做两件事使用select只选需要的字段或者明确include关联。在 tRPC 的 input 里接受一个page或cursor参数把分页放在服务端。用一个 z.object 定义好查询参数list: protectedProcedure .input( z.object({ status: z.enum([active, completed]).optional(), cursor: z.string().cuid().optional(), }) ) .query(async ({ ctx, input }) { return ctx.db.task.findMany({ where: { createdById: ctx.session.user.id, completed: input.status completed ? true : input.status active ? false : undefined, }, take: 20, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: desc }, }); });5. NextAuth 认证接入JWT、session 和 protectedProcedure 的正确姿势5.1 为什么选 NextAuth以及 JWT 和 Database Session 怎么选T3 默认的认证方案是 NextAuth现在叫 Auth.js。我不打算说服你研究它的源码只讲实际怎么配。NextAuth 支持两种 session 存储策略JWTsession 信息编码进签名的 token无状态适合无数据库或者对数据库压力敏感的场景。Database Sessionsession 记录存到数据库的 Session 表适合需要服务端实时控制 session 生命周期、做多端登录管理的场景。T3 初始化勾选了 Prisma 后schema 里已经有 Session 模型。我的建议是如果你的项目有 Prisma直接用 Database Session这样后台可以随时查这个用户有多少活跃会话、可以手动踢下线。JWT 在无状态场景更轻量但不能做到实时失效。5.2 OAuth Provider 配置和 authOptions在 T3 里NextAuth 的完整配置在src/server/auth.tsProvider 的信息会暴露到环境变量。GitHub OAuth 的配置大概是import { PrismaAdapter } from next-auth/prisma-adapter; import { NextAuthOptions } from next-auth; import GitHub from next-auth/providers/github; import Google from next-auth/providers/google; import { db } from ~/server/db; export const authOptions: NextAuthOptions { adapter: PrismaAdapter(db), session: { strategy: database }, providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), Google({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }), ], callbacks: { session: ({ session, user }) ({ ...session, user: { ...session.user, id: user.id }, }), }, };这里有个关键细节sessioncallback 里把user.id放进 session。tRPC 的 protectedProcedure 后面要用ctx.session.user.id来关联数据库记录如果不加这个字段你在服务端只能拿到 name/email拿不到主键 ID。5.3 tRPC context 和 protectedProcedure 怎么配合打开src/server/api/trpc.tscontext 创建函数会调用getServerSessionexport const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { db, session, }; }; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }); });这段逻辑我解释一下所有走 protectedProcedure 的接口入口处先验 session没登录直接抛 UNAUTHORIZED并且把用户信息注入到后续流程的 ctx 里。所以你在 router 的 mutation 里直接ctx.session.user.id是安全的不用担心这个人是谁。5.4 前端怎么拿登录态SessionProvider 和 useSessionT3 的_trpc/Provider.tsx会把 SessionProvider 和 tRPC Provider 叠在一起。前端组件想显示登录用户信息时use client; import { useSession } from next-auth/react; export function UserBadge() { const { data: session } useSession(); if (!session?.user) return Link href/api/auth/signin立即登录/Link; return span{session.user.name}/span; }这里我提醒一个坑同一个组件里如果你用了useSession()别忘了它依赖 SessionProvider。T3 的 Provider 已经包好了但如果你改动 layout 结构手滑把 Provider 移出根组件所有useSession会直接报错。排查这类问题时先看 Provider 层级。5.5 路由级保护和数据级保护别搞混登录保护有两个维度路由级用户没登录时不能进入某个页面。数据级用户登录了但只能操作自己的数据。T3 推荐前者用 Next.js middleware 做白名单配置后者统一走 protectedProcedure。比如// src/middleware.ts import { getToken } from next-auth/jwt; import { NextResponse } from next/server; export default async function middleware(req: NextRequest) { const token await getToken({ req }); if (!token) { const url new URL(/api/auth/signin, req.url); url.searchParams.set(callbackUrl, req.url); return NextResponse.redirect(url); } return NextResponse.next(); } export const config { matcher: [/tasks/:path*], };注意 Next.js middleware 默认跑在 Edge Runtime不要在这里访问 Prisma 或其他 Node 专属 API。数据级的权限判断交给 tRPC 的 protectedProcedure那才是业务逻辑应该待的地方。6. Vercel Neon 部署几类最常见的构建失败和解决记录6.1 部署形态Next.js 上 Vercel数据库放哪T3 Next.js 部署到 Vercel 是最顺的组合。数据库我用的是 Neon 的 Serverless Postgres它支持连接池和分支对 Vercel 的 Serverless 场景很友好。Neon 会给你两套连接串非 pooler 连接postgresql://user:passwordep-xxx-xxx.us-east-2.aws.neon.tech/dbname写作DIRECT_URL。连接池连接postgresql://user:passwordep-xxx-pooler.us-east-2.aws.neon.tech/dbname写作DATABASE_URL。在.env里这样配置DATABASE_URLpostgresql://user:passwordep-xxx-pooler.us-east-2.aws.neon.tech/dbname DIRECT_URLpostgresql://user:passwordep-xxx-xxx.us-east-2.aws.neon.tech/dbname如果你不用 Neon用 Supabase 或者其他托管 Postgres也务必保证线上环境有一个支持连接池的连接串。因为 Vercel 的每个函数实例都可能创建数据库连接没有池化的话Postgres 最多 100 条连接很快会被打满。6.2 build 阶段最常见的四类失败我列一下我实际遇到过的问题按照出现频率排序环境变量缺失报错通常是ERROR: Environment variable not found: DATABASE_URL。原因是 Vercel 控制台的环境变量没配或者配了但没重新部署。注意 Vercel 环境变量分 Production、Preview、Development 三栏本地跑不用的变量线上也要看情况配。Prisma Client 未生成某些情况下npx prisma generate没执行构建时报Cannot find module prisma/client。解决办法是在package.json的 build 脚本里加上build: prisma generate next build类型错误导致构建终止tRPC 的端到端类型在构建时会被 Next.js 校验。你要是改了后端返回值前端某个组件忘了适配类型next build会直接失败而不是静默放行。这其实是好事但我第一次遇到时吓了一跳。这个错误信息一般会很定位到具体文件。Prisma 在生产环境迁移没跑Vercel 部署不会自动执行prisma migrate deploy需要你在 CI 或本地手跑。如果模型变了但线上库没迁移运行时 API 会报错column does not exist。6.3 Serverless 下的连接管理DATABASE_URL 和 DIRECT_URL 的区别Prisma 5 在 datasource 里可以出来两个 URLdatasource db { provider postgresql url env(DATABASE_URL) directUrl env(DIRECT_URL) }DATABASE_URL是客户端连接用的池化地址应用运行时用它。directUrl只用于prisma migrate dev和prisma db push这类需要直接连接数据库的操作。这个区分在不支持事务连接池的数据库上特别重要。如果我没记错的话Supabase 的 pgBouncer 在 transaction 模式下CREATE TABLE这种迁移语句必须有 direct 连接才能跑。总之线上部署用池化连接串迁移命令走 directUrl这是最保险的配置。6.4 部署后的会话问题NEXTAUTH_URL 和 Secret 缺失有个我差点忽略的问题本地开发一切正常部署后点击登录跳转回来却 500。排查日志发现是NEXTAUTH_URL没设置。在 Vercel 上NextAuth 有时候能自动推断域名但遇到自定义域名、Preview 环境、或者跨域回调时就必须显式配置。我的检查清单是NEXTAUTH_SECRET设为一个固定的随机字符串。NEXTAUTH_URL线上设为正式域名https://yourdomain.com。OAuth Provider 的回调地址GitHub / Google 控制台里必须把 Vercel 域名加进白名单。6.5 部署后的缓存问题本地数据库和线上数据库要分开不要图省事把本地开发库的连接串直接配到线上环境。这在前面受过很多教训本地测试数据一旦污染线上后悔都来不及。Neon 有分支功能每次发布前在分支上跑一遍migrate deploydb seed验证没问题再合并到主分支这个流程我现在的团队已经固化下来。7. 什么时候我会劝你别用 T3 Stack真实边界与替代方案7.1 不适合的场景类型T3 Stack 很舒服但它不是银弹。以下场景我不会用纯后端 API 服务没有前端页面只需要提供 REST 接口给多个客户端。T3 里那套 Next.js App Router RSC 的配置完全是累赘。直接 Fastify/Express Prisma Zod轻量得多。对外公开的 API 产品tRPC 不是一个公开协议它的类型推断是 TypeScript 专属。第三方开发者用 Python、Go、或者直接 curl 调用你的接口非常不便。你要生成 OpenAPI 文档tRPC 也有trpc-openapi之类的方案但体验不如直接用 REST。移动端 Web 多端tRPC 的客户端确实支持 React Native但类型推断、hooks、缓存机制和 React 里那套体验有差距。更现实的做法是后端用 OpenAPI 定义接口移动端从 OpenAPI 生成类型Web 端用 T3。强事件驱动 / CQRS 架构tRPC 的 procedure 偏向 CRUD如果核心业务是消息总线、事件溯源、大量异步任务这套“函数调用”模型反而阻碍了领域建模。不如老老实实按业务服务拆分。7.2 团队因素TypeScript 水平决定一切T3 Stack 最大的优点是类型安全但这对团队有一个硬性要求每个人都得能看懂 TypeScript 的类型报错。tRPC 出错时编辑器会提示类型不匹配很多人看到这一大段找不到出处就放弃了。新手团队上手 T3 的曲线比 Vite Express 要陡峭。我自己的经验是至少两个主力开发能独立排查 tRPC 类型报错再考虑全量采用 T3。否则先让团队用熟 TypeScript、Prisma、React Query再缓缓引入 tRPC。7.3 如果不全盘接受 T3可以替换哪些层即使整体不用 T3它的设计思路仍然可以单点借鉴不用 tRPC用 REST OpenAPI orval后端用 Zod 验证后直接导出 OpenAPI 文档前端用 orval 生成类型安全的客户端。依然有类型直通只是多一步代码生成。不用 Prisma用 DrizzleDrizzle 更贴近 SQL单体查询性能更好但 schema 管理和迁移工具成熟度不如 Prisma。不用 NextAuth用自建 Session JWT如果团队需要高度自定义登录流程比如企业微信、LDAPNextAuth 的 adapter 体系反而束缚自建 session 中间件更省心。7.4 我的最终取舍什么项目我仍然首选 T3说了这么多边界再说说我最顺手的场景3 人以下的小团队、MVP 验证、内部工具、后台管理系统。这类项目有几个共同点生命周期短、需求变化快、没有外部 API 消费者。T3 的类型闭环让改字段、加功能变得极其流畅React Query 的缓存让我们不用手动管理全局状态Next.js Vercel 的部署链路从 push 到上线只要五分钟。我个人最近几个内部项目的标准组合就是T3 Stack Neon Vercel GitHub Actions。GitHub Actions 负责跑prisma migrate deployVercel 负责推送后自动构建本地写代码时几乎不用关心环境配置。最后说点题外话。这几年前后端分离、微服务、Serverless 的浪潮一轮接一轮每个框架都说自己解决痛点但 T3 Stack 是我少数真的能感受到“开发体验被重构”的组合。它没有发明新的协议也没有造新的部署模型只是把类型系统贯彻到了从前端按钮到数据库表的每一条路径上。对于习惯了“前端一套类型、后端又一套类型、文档再一套类型”的人来说这种一致性一旦用上就很难回头了。如果你正准备开一个内部工具或者小 MVP给它一个周末的时间跑一个真实的小项目不要只停在 demo 阶段那才是 T3 真正发光的地方。