ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

t3code 全栈开发实战:从类型安全到生产部署的核心技术栈解析

t3code 全栈开发实战:从类型安全到生产部署的核心技术栈解析

1. 先聊聊 t3code 是什么,以及它解决了什么问题

第一次看到 t3code 这个项目代号时,我以为是某个代码生成器或者游戏模组工具。拆开名字才发现,它就是现代 TypeScript 全栈开发里那套反复被验证过的技术选型公约:TypeScript 打底、Tailwind CSS 负责样式、tRPC 负责前后端数据通道。如果再加上常驻的 Next.js、Prisma 和 NextAuth.js,就是社区里出现频率很高的 T3 Stack。把这几样工具按同一种工程思路组织成一套可复制的模板,并且保证它能在真实业务里跑得动、改得动、部署得快,这才是 t3code 真正在做的事。

这套方案不是某家公司发布的商业化框架,也不是只有资深工程师才听得懂的黑话。对于独立开发者、刚起步的小团队,尤其是被前后端接口联调折磨过的项目来说,它的核心价值非常直观:你只需要维护一份类型定义,从数据库到页面之间的数据形状就能被编译器一路检查到底。API 层不再需要手写路由、手动拼 URL、手动对齐字段名,而是像调用本地函数一样调用后端逻辑。前端拿到什么结构,IDE 里立刻就能看到什么字段,少掉的是大量的沟通成本和胶水代码。

我在多个内部管理系统和对外展示型网站上用过这套思路,整体感受是“开局顺畅,后期清醒”。它不会替你解决业务复杂度,但可以把团队里最无聊、最容易出错的接口对齐环节自动化。这篇文章会把 t3code 的完整技术栈拆开,讲清楚每个成员为什么存在、彼此如何配合、从零搭建一个项目要经历哪些关键步骤,以及线上部署后最常踩的坑。

1.1 T3 这个名字的来龙去脉

T3 最早是 TypeScript、Tailwind CSS、tRPC 三个首字母的缩写,由社区里一位非常活跃的全栈开发者在技术分享中提出,后来慢慢演变成一个带有示范性的项目脚手架。它不是一个包,也不是一个框架,而是一组选型约定:默认你用 Next.js 当应用容器,用 Prisma 接数据库,用 NextAuth.js 处理登录,用 tRPC 串起前后端调用。整个栈有一个共同特征——极度依赖 TypeScript 的类型系统。

我刚接触时有一个误解,以为 T3 是在追求“最新最炫”,实际上它的每个成员都相当克制。Next.js 负责路由和渲染,Tailwind 负责样式工具类,Prisma 负责数据库访问,tRPC 负责过程调用,这些工具各自只解决一个问题,组合起来以后,没有哪个环节突然要求你切换语言或者放弃类型检查。这一点在长期维护中很重要:团队成员可以单独理解任何一个工具,再通过 TypeScript 把它们的边界连接起来。

顺着这个命名逻辑再延伸一步,t3code 本质上就是“T3 选型的代码实践”。社区里很多工程模板都以此命名,但不同模板的默认配置会有差异,所以我通常在搭建项目时不会无脑接受全部默认选项,而是按业务规模增删组件。下面会以最常见的六个成员为基准讲。

1.2 类型安全闭环:少写胶水代码的秘密

传统前后端分离项目里,最常见的痛点长这样:后端返回一个user对象,前端为了用数据,得自己声明一个interface User,并不知道字段到底叫userName还是name,更不知道它到底是字符串还是 nullable。等接口联调时,经常因为字段不一致反复返工。我在早期项目里干过这种蠢事,一个用户信息接口来回改了四版字段名,最后发现问题出在前后端各自维护了一份理解不同的“数据字典”。

t3code 的解决方案是把类型变成通信协议本身。tRPC 服务端的每个查询或者变更操作都有明确的输入输出类型,这些类型通过 TypeScript 的泛型推断被自动传递给客户端。也就是说,后端定义getUser: procedure.query(() => user),前端useQuery拿到的东西就已经带有完整的 TypeScript 类型。字段名错了,编译器会直接报错;类型漏了,IDE 会提示。这里的“闭环”指的是:数据库 schema 生成 Prisma 客户端类型,Prisma 类型流入 tRPC 路由,tRPC 路由类型再流入前端 hooks,一条链路全部由同一套类型体系串起来。

用生活里的类比来解释,搭过乐高的人都知道,不同系列的零件如果接口不兼容,再漂亮的图纸也拼不起来。t3code 相当于把积木接口统一了,前端和后端不再各自画“接口示意图”,而是拿着同一份图纸拼到底。这不仅减少了联调时间,更重要的是把问题提前到代码编写阶段,而不是等到部署之后再靠日志排查。

1.3 谁适合直接用这套方案

先说结论,它的黄金适用范围是:中小型业务后端、内部管理系统、快速迭代的 Web 产品,以及对类型安全有执念的个人项目。如果你是一个人写全栈,或者是两到五人的前端为主的小团队,t3code 能把后端工作量压缩得非常低,因为你不需要单独为接口写文档、做版本管理、维护 SDK。

如果你的团队里后端逻辑很重,涉及复杂消息队列、大量外部系统集成、或者需要在多个语言服务之间做 RPC,这套方案就不太合适。tRPC 绑定的使用场景是 TypeScript 全栈,离开 TypeScript 它的优势就消失了。另外,如果你的产品需要面向大量第三方开放公开 API,且必须遵循严格 REST 或 GraphQL 契约,t3code 的轻量风格反而会让你补一堆协议层代码。

我个人的取舍经验是这样:区分“内部数据通道”和“外部接口契约”。系统内部模块之间,用 t3code 的函数调用式数据流最舒服;对外暴露的边界处,再包一层 OpenAPI 或 REST 适配器。这样的混合架构既保证了开发效率,又保留了外部集成的规范性。

2. 工具链逐层拆解:六个核心成员各自负责什么

明确了 t3code 的整体思路以后,自然要看这个技术栈里每个工具的具体定位。很多新手一看“集成方案”就觉得是在堆依赖,其实每个成员都有明确的不可替代性。把它们拆开看,你就会发现这套组合的设计逻辑其实很通顺。

2.1 Next.js 是容器,不是框架

T3 选 Next.js 作为底座,看中的不只是它的 SSR 能力,更是它把“业务逻辑层”和“服务端能力”放在同一份代码里的特点。在传统前后端分离架构里,前端项目是一套代码,后端服务是另一套代码,中间靠 HTTP 来回通信。Next.js 的 App Router 出来以后,一个项目里可以同时拥有服务端组件、客户端组件、API Route、Route Handler,甚至直接在服务端组件里访问数据库或调用内部服务。

这样做最直接的好处是部署模型被简化了。一个小团队的完整产品可以只部署一个 Next.js 应用,不再需要同时维护前端静态托管、后端 API 服务、鉴权中间层三套基础设施。它还带来一个隐性优势:服务端和客户端代码共享同一个类型环境和工具链,比如同一个 zod 校验 schema 可以直接被两边引用,避免“后端校验了一份参数,前端又复制了一份校验逻辑”的双份维护。

需要提醒的是,Next.js 的灵活性也意味着你要理解 React Server Components 的边界。很多人误以为“服务端组件里可以随便用 hooks”,其实不行;而后端逻辑写在 Route Handler 里与写在 tRPC 路由里的处理方式也不同。t3code 的模板通常会把 tRPC 挂载到/api/trpc这个 Route Handler 上,相当于整个应用的后端入口是收敛且一致的,不是东一个接口西一个页面。这个收敛设计是我最喜欢的部分。

2.2 TypeScript 与 tRPC:跨端类型传导的双引擎

TypeScript 是整套方案的语法基础,tRPC 则是让类型从服务端“流”到客户端的关键桥梁。tRPC 的核心想法非常叛逆:既然我们已经在写 TypeScript,为什么不能直接像调用本地函数一样调用远程函数?它不发明新的协议,底层依然是 HTTP,但把每个查询和变更包装成一个带唯一标识的 procedure,客户端自动生成对应的调用函数。

代码形态上,服务端先定义一个 router,里面放若干 procedure:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const userRouter = createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input, ctx }) => { return ctx.db.user.findUnique({ where: { id: input.id } }); }), });

一旦这个 router 被注册进根 router,并通过 Next.js 的 Route Handler 暴露出去,客户端就能通过一个类型导出的 api 对象调用它。前端写api.user.getById.useQuery({ id: "1" })时,输出的data类型会自动带出 Prisma 查询返回的结构。这里的关键点是 zod 作为输入校验层同时承担了运行时检查,单靠 TypeScript 静态类型在运行时是不设防的,有了 zod 才能保证边界处的数据真的符合预期。

我为什么要强调“双引擎”而不说是 tRPC 单点优势?因为 tRPC 如果没有 TypeScript 加持,它的开发体验会大打折扣;反过来,TypeScript 如果没有 tRPC 这样的工具把类型传递到跨进程边界,也只能停留在“项目内部的类型检查”。两者配合,才形成一套真正全栈的类型安全体系。对照 GraphQL 的 codegen 流程,tRPC 省掉了写 schema、生成客户端、同步版本的繁琐步骤,换来的是简单直接,适合内部调用;但公开第三方 API 的场景,还是 GraphQL 的契约能力更稳。

2.3 Prisma 把数据库建模变成类型源头

数据层方面,t3code 默认使用 Prisma。它在整套链路里扮演“类型源头”的角色:数据库表结构先写进schema.prisma,例如定义一篇博客文章:

model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model User { id String @id @default(cuid()) email String @unique name String? posts Post[] }

通过npx prisma generate生成的@prisma/client自带完整的推断类型。后续在 tRPC 的 procedure 里调用ctx.db.post.findMany(),返回结果自动携带title、author等字段类型。如果数据库迁移修改了字段名,Prisma Client 类型会立即变化,凡是引用旧字段的地方全部编译报错,这种连锁反应能提前拦住一大批“线上查不到字段”的问题。

有人会问,为什么不直接用 Drizzle?Drizzle 同样是一个优秀的 TypeScript ORM,查询 API 更接近 SQL,体积也更小。但 t3code 的默认选择是 Prisma,原因是它的迁移工具更成熟、模型定义更直观,对从零开始的团队更友好。我个人的建议是:如果团队 SQL 功底很强且追求极致抽象,可以考虑 Drizzle;如果更看重开箱即用和稳定的迁移流程,Prisma 是更稳妥的选择。两者都是把数据库表结构变成类型源头,这个设计思路是一致的。

2.4 Tailwind CSS 与 NextAuth.js:体验和边界

Tailwind 经常被误当作“只是少写点 CSS”。它真正的价值,是提供了一个原子化的样式约束体系。在 t3code 项目里,Tailwind 的作用是降低构建页面时的决策成本——间距、颜色、字体大小都有一组语义化变量,团队成员不需要各自发明命名规则。配合clsx或tailwind-merge做条件类名,可以写出很清晰的组件逻辑。如果你是第一次接触,记住一个原则:绝大多数样式问题都能在页面和组件里就地解决,它不强制你做模块化拆分,但也鼓励你把重复的样式提成组件。

NextAuth.js 则承担了身份认证边界。它默认提供了一套可扩展的认证框架,支持常见的 OAuth 登录、邮箱链接登录,也支持自己写 Credentials Provider。与 tRPC 的集成方式是:在服务端 context 里调用getServerSession拿到当前登录用户,然后注入到每个 procedure 的ctx.session。这样,受保护的路由就不再需要每个接口单独写鉴权逻辑,而是通过一个protectedProcedure统一拦截。

这里有一个容易被忽视的点:NextAuth.js 虽然叫“Next Auth”,但它并不限定必须把 session 存进数据库。默认情况下它使用 JWT,用户信息被签名后放在 Cookie 里,这对轻量项目来说很实用。如果你需要多个服务共享登录态,再考虑把 session 持久化到数据库。边界感很重要,我要不要把读写权限判断全部塞进数据库 Query?不是的:权限校验写在 tRPC 中间件里,数据访问写在 procedure 里,页面关注渲染,每一层做自己那件事。

3. 从零搭建一个 t3code 全栈项目

工具选型讲得再多,都不如亲手搭一遍来得直接。这一章我用一个“轻量文章系统”作为示例,从初始化到跑通前后端,完整走一遍 t3code 的实操流程。整个过程不需要外部服务,数据库先用 SQLite 起步,后期再切换成 PostgreSQL。

3.1 create-t3-app 初始化

脚手架命令只有一个:

npx create-t3-app@latest my-t3-app

按提示进入项目后,会有交互式选项,通常包括 Next.js、Tailwind CSS、tRPC、Prisma、NextAuth.js、ESLint 等。如果做登录,就把 NextAuth.js 选上;如果暂时不需要数据库,Prisma 可以先不选,但 t3code 的完整形态还是建议全选,然后慢慢学会删除不需要的部分。

初始化完成后,项目结构里最关键的几个文件是这样分工的:

  • src/server/db.ts:创建 PrismaClient 实例。
  • src/server/api/trpc.ts:创建 tRPC 实例、注入 session、定义publicProcedure和protectedProcedure。
  • src/server/api/routers/:按业务域拆分的路由文件。
  • src/server/api/root.ts:把所有子路由合并成一个根 router。
  • src/app/api/trpc/[trpc]/route.ts:Next.js 的 HTTP 入口。

我刚开始接触时最容易懵的就是“为什么有这么多 server 目录”。其实一句话就能理清:server/db是数据库基础设施,server/api是业务 API 层,两者分开是为了避免在路由文件里反复创建数据库连接。后续加业务模块时,你只需要在routers目录下新增一个文件,然后在root.ts里注册。

3.2 设计数据模型:以文章系统为例

在prisma/schema.prisma里加入文章和用户模型。我常用的基础模型很简单:

generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String? posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

执行:

npx prisma migrate dev --name init

这条命令会创建迁移文件并生成 Prisma Client。migrate dev只用于开发环境,它会自动同步数据库结构;生产环境部署时应该用migrate deploy,两者区别后面会详细讲。

这里要特别提醒一个新手习惯:不要跳过@updatedAt。很多数据更新后需要记录修改时间,如果提前声明,后续加字段还得重新迁移一次。另一个细节是主键用cuid()而不是自增数字,目的是避免在分布式环境或公网 URL 中猜测资源数量。如果确实需要按时间排序,额外加一个createdAt索引即可。

3.3 写第一个 tRPC 路由

在src/server/api/routers/post.ts里写文章查询和创建方法:

import { z } from "zod"; import { createTRPCRouter, publicProcedure, protectedProcedure, } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ listPublished: publicProcedure.query(({ ctx }) => { return ctx.db.post.findMany({ where: { published: true }, include: { author: { select: { name: true } } }, orderBy: { createdAt: "desc" }, }); }), getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id }, include: { author: { select: { name: true } } }, }); if (!post) { throw new Error("文章不存在"); } return post; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(200), content: z.string().min(1), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });

写完业务路由后,记得在src/server/api/root.ts里注册:

import { createTRPCRouter } from "~/server/api/trpc"; import { postRouter } from "~/server/api/routers/post"; export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter;

注册这一步漏掉的话,前端调用api.post.listPublished时会直接报“没有这个 procedure”,属于低级的集成疏漏。type 导出是 tRPC 客户端类型推断的依据,这一行不能删。

3.4 前端组件消费 API

t3code 脚手架默认把 tRPC 客户端封装好,前端组件直接这样写:

"use client"; import { api } from "~/trpc/react"; export function PostList() { const { data, isLoading, error } = api.post.listPublished.useQuery(); if (isLoading) return <p>加载中...</p>; if (error) return <p>加载失败:{error.message}</p>; return ( <ul> {data?.map((post) => ( <li key={post.id}> <h2>{post.title}</h2> <p>作者:{post.author.name}</p> </li> ))} </ul> ); }

创建文章的操作更直接:

"use client"; import { useState } from "react"; import { api } from "~/trpc/react"; export function CreatePostForm() { const utils = api.useUtils(); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const createPost = api.post.create.useMutation({ onSuccess: () => { utils.post.listPublished.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); createPost.mutate({ title, content }); }} > <input value={title} onChange={(e) => setTitle(e.target.value)} /> <textarea value={content} onChange={(e) => setContent(e.target.value)} /> <button type="submit">发布</button> </form> ); }

注意useUtils这一行:tRPC 的 mutation 成功以后并不会自动刷新页面列表,必须手动调用invalidate让对应查询失效并重新拉取。很多新手在这里“数据没更新”半天找不出原因,其实只是忘了刷新缓存。

如果你需要在 Server Component 里取数据,t3code 也提供了服务端调用方式。页面不用变成客户端组件,直接调用:

import { api } from "~/trpc/server"; export default async function Page() { const posts = await api.post.listPublished(); return ( <div> {posts.map((p) => ( <p key={p.id}>{p.title}</p> ))} </div> ); }

这种写法避免了向浏览器下发大量序列化请求,也减少了白屏。在 App Router 的体系里,能服务端拿到的数据就不要到客户端再拉一遍,这条原则对性能和体验都有明显帮助。

3.5 接入认证流程

认证接入最关键的是把 NextAuth 的 session 连通到 tRPC 的 context。在后台,NextAuth 配置文件里选定 Provider,然后在src/server/api/trpc.ts中:

import { initTRPC, TRPCError } from "@trpc/server"; import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; export const createTRPCContext = async (opts: CreateNextContextOptions) => { const session = await getServerSession(authOptions); return { db: prisma, session, }; }; const t = initTRPC.context<typeof createTRPCContext>().create(); export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session } }); });

protectedProcedure就是一个中间件包装:用户没登录时直接抛 401,登录后把带 user 信息的 session 继续传给后续逻辑。上面 3.3 里的ctx.session.user.id正是从这里注入的。

我踩过的一个典型坑是:在[...nextauth].ts里配置了非常多 Provider,但漏了给 NextAuth 设置NEXTAUTH_SECRET环境变量。开发环境还好,生产环境直接导致 session 无法稳定读取,表现为用户明明登录了,tRPC 请求却一直被 UNAUTHORIZED 拦下。解决方法是生成一个足够长的随机串,并确保线上环境变量与本地一致。

4. 部署与线上环境实践经验

本地把项目跑通只算完成了一半,部署上线才是真正考验工程细节的环节。基于 t3code 的 serverless 特性,部署流程会比传统服务器简单得多,但环境变量、数据库迁移和冷启动这三件事必须提前想清楚。

4.1 选部署平台与配置环境变量

t3code 项目最常见的部署平台是 Vercel,因为 Next.js 是它的原生运行时。部署流程基本是:把代码推到 Git 仓库,导入 Vercel,框架会自动识别 Next.js,然后你只需要在控制台填入环境变量。核心变量通常有:

  • DATABASE_URL:数据库连接串。
  • NEXTAUTH_SECRET:NextAuth 的签名密钥。
  • NEXTAUTH_URL:旧版本还需要填应用的完整地址,新版大多会自动推断。

这些变量如果在本地配置过.env,一定要确认是否被 gitignore 避免泄露。我习惯在schema.prisma的 datasource block 里只使用env("DATABASE_URL"),这样代码仓库里不会出现任何真实连接信息。

如果团队有自己的服务器偏好,也可以用 Docker 部署:构建 Next.js 产出物,用 Node 服务托管,反向代理交给 Nginx 或 Caddy。但这会失去 serverless 的自动扩缩容优势,需要自己处理进程管理和资源监控。对我来说,没有特殊合规要求的话,Vercel 是最省心的选择,因为它把 TLS 证书、CDN、自动部署都打包好了。

4.2 数据库迁移与连接策略

数据库迁移是线上最容易出事的环节。开发时用prisma migrate dev频繁改表无可厚非,但生产环境一定要走严谨的migrate deploy。它只会执行已有的迁移文件,不会根据当前 schema 状态擅自修改结构。部署流水线里,我建议在应用发布前先跑一次:

npx prisma migrate deploy

如果迁移过程中发现表和线上数据不兼容,至少要保留“先备份后迁移”的底线原则。另一个风险点是连接串。很多托管数据库免费层会限制连接数,而 serverless 函数每次冷启动都可能新建连接。假如一个页面同时发起多个 tRPC 请求,连接池很容易被打满。

解决方案有几个。最直接的是启用 PgBouncer 这类连接池中间件,或者使用数据库厂商提供的连接池能力。另一个方案是在 PrismaClient 的实例化处做全局单例,避免每个请求都创建一个新客户端,这能显著降低连接数。在冷启动上,尽量把 Next.js 函数运行时保持为 Node.js,而不是 Edge,因为 Prisma 对 Edge runtime 的支持还不够顺手。

4.3 性能监控与冷启动优化

t3code 应用不止要“能跑”,还要“跑得快”。Vercel 自带 Web Analytics,能看页面加载速度和访问趋势,但对接口级的耗时分析不够细。我通常再加上 Sentry 做错误监控,把 tRPC procedure 抛出的异常统一上报,配合@sentry/nextjs打包,线上出现 P2025 这类数据库错误时能第一时间在后台看到堆栈。

冷启动优化是个长期课题。传统 Node 服务没这个问题,serverless 函数却每次可能从零启动。我的经验是:尽量不要让一个页面聚合太多服务端查询,把可以并行执行的请求合并成一次 tRPC 批量调用;对不常变化的文章详情,可以加上缓存头或者用 Next.js 的unstable_cacheAPI。但要注意,缓存一旦引入,就必须考虑用户权限差异,带用户态的请求不要随便做全量缓存,避免用户 A 看到用户 B 的数据。

最省心的做法其实是把“真正动态的页面”和“接近静态的页面”分离。文章列表如果是全站公开的,可以静态化或增量生成;只有依赖登录状态的“我的文章”才走动态查询。这样大多数请求不需要冷启动数据库访问,成本明显下降。

5. 常见问题与排查技巧实录

任何项目跑久了都会遇到配置和运行时问题。这里我把 t3code 项目里最常出现的几类问题整理出来,附上我自己的排查思路。有些问题看似诡异,其实原因可能非常简单。

5.1 tRPC 类型和路由报错

症状一:前端调用api.xxx.yyy.useQuery()时,TypeScript 提示某个属性不存在。多数情况是后端路由确实没有导出这个方法,或者root.ts里没有注册。排查顺序是先看routers目录里有没有对应定义,再看root.ts是否引入并挂在 appRouter 上。

症状二:浏览器控制台报 404,请求路径是/api/trpc/xxx.yyy。这通常意味着 Next.js 的 Route Handler 没有成功挂载。检查src/app/api/trpc/[trpc]/route.ts是否存在。如果移动过目录,路径不对就会直接 404。

症状三:开发时改完 schema 后,前端类型没有同步变化。执行npx prisma generate,然后重启开发服务。Prisma Client 的类型是生成出来的,不是自动的,这一步不能省。

5.2 认证与 session 问题

认证问题里最高频的是“明明登录了,protectedProcedure 还是 UNAUTHORIZED”。先检查NEXTAUTH_SECRET是否设置,再检查getServerSession的 authOptions 和 NextAuth 路由配置是否指向同一个实例。它和旧版本不同,新框架下认证模块通常放在src/server/auth.ts,如果两处引用了不同的文件,session 就会不一致。

另一个坑是 Credentials Provider 下的 CSRF 问题。如果前端直接调用signIn("credentials", ...)而请求头没带 CSRF token,登录可能反复失败。遇到这种问题不要自己去实现验证码逻辑,优先检查 NextAuth 官方示例中的事件绑定方式。还有一点很容易忽略:session.user.id并不是默认字段,如果 schema 里 User 模型有 id,需要在 NextAuth 的类型定义里扩展 Session 和 JWT,否则 TypeScript 会提示user.id不存在。这个扩展主要写在src/types/next-auth.d.ts。

5.3 Prisma 运行问题

生产环境最常见的 Prisma 错误是 P1001“数据库连接超时”和 P2025“记录不存在”。P1001 基本是网络或连接串问题,检查线上环境变量是否使用了内网地址或连接池端口。P2025 经常出现在删除或更新操作时,说明你要操作的数据已经被并发删除,或者 where 条件写错。业务里如果允许资源不存在,应该捕获异常后返回 null,而不是让错误冒泡。我习惯在 getById 这类查询里先 findUnique 后判断,找不到就抛一个业务码明确的错误,而不是直接露出 Prisma 原生报错。

还有一个部署后常见问题:@prisma/client二进制和云函数运行时版本不匹配。Vercel 构建时偶尔会缓存旧的 Prisma Engine,导致本地正常、线上报错。遇到这种情况,清理构建缓存并重新部署,或者把 Prisma 版本钉死,不要随意升级,通常能解决。

5.4 问题速查表

下面的表格是我根据项目维护经验总结的排查速查表,实际排障时可以按图索骥。

现象可能原因检查顺序
tRPC 请求 404Route Handler 路径不对检查src/app/api/trpc/[trpc]/route.ts
前端类型缺少方法路由未注册检查root.ts
Mutation 后列表不刷新未调用invalidate检查useUtils
登录后被 UNAUTHORIZEDNEXTAUTH_SECRET缺失检查环境变量
ctx.session.user.id报类型错未扩展 NextAuth 类型检查next-auth.d.ts
生产环境数据库连不上连接串或网络问题检查 DATABASE_URL 和连接池
P2025 记录不存在where 条件或并发删除捕获异常返回业务提示
冷启动明显变慢Prisma Client 每次新建做全局单例
线上环境变量未生效未重新部署重新部署并确认 env
页面数据错乱误用全局缓存区分动态与静态数据

这份表并不全面,但它覆盖了我自己接入 t3code 后遇到的大部分“看起来很难、查起来很简单”的问题。遇到新问题时,我的习惯是先看链条的哪一环断了:数据库是否有数据、服务端日志是否有错、客户端是否拿到响应、类型层是否没同步。一层层排除,通常不会卡太久。

最后再分享一个小技巧:在生产项目里,把 tRPC 的错误格式统一成一个业务码对象,例如{ code, message, details },而不是直接透出各种底层异常。这样前端拿到错误后可以按业务码做引导提示,日志系统也能更好聚合。t3code 的价值在于它搭好了一套高效骨架,但真正让它长期好用的,是你围绕业务补充的约束和规范。

RELATED READING

延伸阅读

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