如果你和我一样,一个人同时扛着前端和后端两摊代码,大概率经历过这种尴尬:接口文档的更新速度永远追不上代码变动,前端联调时发现返回字段少了一个,后端直到线上报错才发现问题出在类型对不上。我最近完整做完的 t3code 这个项目,最初就是为了解决这个持续折磨我的问题。t3code 是我用 T3 Stack(Next.js + tRPC + Prisma + Tailwind 的组合)从零搭建的一个全栈应用,核心思路是一套 TypeScript 贯穿前后端,接口契约由编译器替你守住,而不是靠人肉同步。整个项目做下来,最大的体会是:全栈开发真正缺的不是框架,而是把类型安全延伸到数据库层面的协作方式。这篇文章会把从选型、初始化、核心功能实现到部署上线的完整过程,以及中间踩过的坑都写出来,适合正在选型全栈方案的独立开发者,也适合想搞懂 tRPC 和 Prisma 如何配合的前端工程师。
1. 为什么我把项目押在 t3code 这套组合上:一次接口重构引发的崩溃
1.1 从"维护接口文档"到"让编译器当裁判"的思路转变
事情得从我做前一个项目说起。当时前端用 React,后端用 Node + Express,前后端之间靠一份手写的 REST API 文档维持默契。有一次产品要求给用户列表增加一个"最近登录时间"字段,我改了数据库表、加了接口逻辑、更新了接口文档,但前端的 TypeScript 类型定义忘了同步。结果就是:前端调用接口拿到的新字段,类型层面根本不存在,编辑器不报错,运行时不报错,直到做数据统计时发现字段全是 undefined,花了半天查日志才定位到是因为类型定义没更新。
这种问题一次两次还能接受,项目一大人就麻了。我意识到,REST + 手写文档的模式里,接口契约是"文本约定",不是"代码约束"。文档写得再详细,人和人之间、人和代码之间都会产生偏差。t3code 这个项目做选型时,我给自己定了一个硬性指标:接口的输入输出类型必须由代码本身生成,前后端共享同一份类型定义,编译器在开发阶段就能发现契约断裂。这个指标直接把我推向了 tRPC。
tRPC 的工作方式很直白:后端定义一个 router,每个路由就是一个带输入输出类型的过程(procedure),前端可以直接调用这些过程,就像调用本地函数一样。因为两端跑的是同一套 TypeScript 类型,后端返回什么结构、前端能拿到什么结构,编译器一清二楚。我第一次体会到"改完后端路由,前端代码立刻报错提醒"的感觉时,说实话有点感动——原来接口对齐可以不需要任何沟通成本。
1.2 技术选型里最关键的三个判断标准
t3code 最终选定的组合是 Next.js、tRPC、Prisma、Tailwind CSS,外加 NextAuth 做认证。这套组合有一段时间被社区统称为 T3 Stack,它的核心哲学是"约定优于配置、简单优于花哨"。但我不太喜欢直接照搬某个"全家桶"的说法,选型毕竟要贴合自己的项目场景。我当时的判断标准有三条:
第一,类型安全必须覆盖到数据库层。以前我用的 ORM 是 Sequelize,模型定义和 TypeScript 类型是两套东西,数据库字段改了,类型定义经常跟着漂移。Prisma 不一样,它的 schema 文件是唯一事实来源,模型定义天然生成 TypeScript 类型,数据库字段、查询结果、创建入参全部类型统一。这一条直接砍掉了我大半的类型维护工作。
第二,全栈最好只有一个语言、一个部署单元。我是典型的前端背景出身,如果要同时维护一个 TypeScript 前端和一个 Node 后端,语言一致但构建、部署、环境变量管理全是两套。t3code 用 Next.js 作为唯一的应用承载,tRPC 的路由挂在 Next.js API 层,整个应用一次构建、一个服务部署,运维成本直接降了一半。
第三,生态要成熟到"踩坑有答案"。这一点经常被技术选型的人忽略。选一个新技术栈,不只是选它的优点,还要看它的坑有没有被前人踩平。我当时查了一圈,tRPC 在 GitHub 上有相当活跃的社区讨论,Prisma 的文档和 migration 工具已经很完善,NextAuth 虽然在配 OAuth 时有些恼人的细节,但方案是成熟的。下面这张表是我当时做的对比:
| 对比维度 | tRPC | REST + OpenAPI 生成 | GraphQL |
|---|---|---|---|
| 类型安全程度 | 端到端自动推断 | 依赖代码生成,流程复杂 | 需要手工写 schema 和 resolver 类型 |
| 学习成本 | 低,本质是函数调用 | 中高,需要维护文档和生成步骤 | 高,需要理解解析器、缓存、规范化 |
| 调试体验 | 网络面板直接看请求,和 REST 类似 | 同类 | 需要 GraphQL 调试工具,略繁琐 |
| 适合场景 | 前后端同仓库的全栈应用 | 多客户端、公共服务 | 复杂数据聚合、多端查询差异化明显 |
对比下来,tRPC 对 t3code 这种前后端同仓库、以业务 CRUD 为主、追求快节奏迭代的项目来说,是性价比最高的方案。GraphQL 的能力上限更高,但它的复杂度对我这个体量的项目是负担而非助力。
2. 初始化 t3code:脚手架命令、交互选项和目录结构的逐层拆解
2.1 create-t3-app 的选项怎么选才不后悔
初始化一个 T3 项目最简单的方式就是脚手架命令:
npm create t3-app@latest t3code这一步没有任何悬念,问题是交互式选项那一串。create-t3-app 会问你使用哪个路由方案、要不要 Tailwind、要不要 tRPC、要不要 Prisma、要不要 NextAuth、要不要 Drizzle,我逐个说说当时的取舍。
路由方案:App Router vs Pages Router。这是第一个需要认真想清楚的选择。App Router 是 Next.js 13 之后的主推方案,用了 React Server Components,性能表现和嵌套布局能力都很强;Pages Router 是老方案,API Routes 的写法非常直白,社区资料也更多。如果你是一个完全没接触过 App Router 的人,第一次上手 tRPC 时很容易被服务端组件和客户端组件之间的数据传递绕晕。我当时选择 App Router,因为 t3code 规划了很多需要嵌套布局和流式渲染的页面,但我必须承认初期学习曲线确实陡了一些。如果你只是想跑通一个全栈示例,新手阶段我反而推荐 Pages Router,踩坑少一半。
数据库工具选 Prisma 还是 Drizzle。脚手架新版本会同时提供这两个选项。Drizzle 更轻量、更贴近 SQL,但它的类型推断需要你对 SQL 模型本身有足够理解;Prisma 的 schema 声明式模型对"从需求到数据库"的转化更友好,而且生态成熟。t3code 的模型不复杂但关系不少,我选了 Prisma,理由很简单:我要把精力放在业务逻辑上,不想花大量时间在写繁琐的查询映射上。
认证直接勾选 NextAuth。哪怕你现在还没想好要不要登录功能,我也建议勾上。t3code 后面要做用户维度数据隔离,认证是硬需求。脚手架集成好的 NextAuth 配置比事后手工加要省很多事。
2.2 生成后的目录结构,每个目录到底管什么
脚手架跑完后,t3code 的目录结构大概是下面这个样子,逐个说我的理解:
t3code/ ├── prisma/ │ └── schema.prisma # 数据库模型的唯一事实来源 ├── src/ │ ├── app/ # App Router 页面与布局 │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ # tRPC 路由定义所在 │ │ ├── auth.ts # NextAuth 配置 │ │ └── db.ts # Prisma Client 单例 │ ├── trpc/ │ │ ├── server.ts # tRPC router 根定义与上下文 │ │ ├── client.ts # 前端调用用的 tRPC Client │ │ └── react.tsx # React Hooks 封装 │ ├── styles/ │ └── env.js # 环境变量校验 └── ...第一次看这个结构,最容易疑惑的是src/trpc和src/server的边界。我的理解是:src/server放的是与框架无关的基础设施(数据库客户端、认证配置),src/trpc放的是 tRPC 自身的装配逻辑(router 根、上下文工厂、前端 client)。路由的实际业务代码在src/server/api/routers里。这个分层一开始看起来有点绕,但用几天就能适应,它保证了"前端只会通过 tRPC 类型调用接口,不会直接 import 服务端模块"。
2.3 第一行代码:让项目真正跑起来的最小闭环
脚手架只带了一个最基础的postRouter示例,连数据库都能读写(它默认会生成一个示例 Post 模型)。我建议第一步不要急着删它,先跑通完整链路:
npx prisma db push # 先同步 schema 到数据库 npm run dev # 启动开发服务然后在任意页面里调用示例接口试试:添加一条 post、查询列表、看类型是否自动匹配。这一步走通意味着"页面 -> trpc client -> router -> prisma -> 数据库"这条链路是通的,后续加业务功能只是往这条路里填内容。我见过不少新手一上来就删掉示例代码自己写,结果数据库连接配置没生效、环境变量校验挂掉,卡了半天才知道是初始化基础没打牢。先让最简链路跑起来,再动手改造,这是全栈项目打开的正确姿势。
3. 核心功能实战:在 t3code 里用 tRPC 和 Prisma 实现任务清单
3.1 Prisma 模型设计:从需求到数据表的落地
t3code 的第一个业务功能是任务清单。需求很简单:用户可以创建任务、标记完成、筛选未完成项,任务可以打标签。对应到 Prisma schema,我设计了两个核心模型:
model User { id String @id @default(cuid()) name String? email String? @unique tasks Task[] } model Task { id String @id @default(cuid()) title String completed Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) tags Tag[] } model Tag { id String @id @default(cuid()) name String tasks Task[] }这里有几个 Prisma 的细节值得展开。第一,ID 用 cuid 而不是自增整数。自增整数在并发写入和分布式部署下有碰撞风险,cuid 是客户端生成的无序字符串,t3code 后续打算做多实例部署,这个选择能省掉很多麻烦。第二,外键关系一定要配onDelete行为。任务属于用户,用户删除时任务必须级联删除,否则 Prisma 会因为你没指定行为而拒绝 migrate。第三,@updatedAt是 Prisma 的魔法字段,任何更新操作都会自动维护它,不需要你在业务代码里手工赋值。
写完 schema 后执行迁移命令:
npx prisma migrate dev --name init这个命令会生成 SQL 迁移文件并应用到本地数据库。t3code 用的是本地 PostgreSQL,实际生产我用的是云数据库,迁移文件在两边是同一套,这就是 Prisma migration 的价值——环境差异由迁移工具管理,而不是靠手写 SQL 对来对去。
3.2 tRPC 路由分层:输入校验、业务逻辑、错误处理
t3code 的路由设计分了三层:最外层是路由和权限控制,中间是输入校验和业务逻辑,底层才是数据库操作。拿任务的创建和查询来举例:
// src/server/api/routers/task.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/trpc/server"; export const taskRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), tags: z.array(z.string().min(1)).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title, userId: ctx.session.user.id, tags: input.tags ? { connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })) } : undefined, }, }); }), list: protectedProcedure .input(z.object({ completed: z.boolean().optional() })) .query(async ({ ctx, input }) => { return ctx.db.task.findMany({ where: { userId: ctx.session.user.id, ...(input.completed !== undefined ? { completed: input.completed } : {}), }, include: { tags: true }, orderBy: { createdAt: "desc" }, }); }), });protectedProcedure是脚手架内置的一个二次封装,它会先检查 session 是否存在,不存在就直接抛错,保证只有登录用户能调用。这个设计我非常推荐沿用:权限判断不应该散落在每个路由函数里,而应该沉淀成 tRPC 的中间件机制,这样新加一个 protected 路由就是一行声明的事。
输入校验用的是 zod,这是 tRPC 生态的标准搭配。z.string().min(1).max(100)这行声明把标题长度限制变成了前后端共享的规则:后端校验、前端同时获得类型提示和校验错误信息。我试过不写 input 直接透传前端数据,后来出现了几次脏数据入库,才意识到 zod 这层不是形式主义,它是接口防线。
3.3 前端调用链路的类型体验:一次实时校验的完整过程
前端调用上面这个 router,体验相当顺滑。在 t3code 的客户端组件里,我直接用api.task.create.useMutation()和api.task.list.useQuery()这类 Hooks:
"use client"; import { api } from "~/trpc/react"; function TaskForm() { const utils = api.useUtils(); const createTask = api.task.create.useMutation({ onSuccess: () => { utils.task.list.invalidate(); // 创建成功后自动使缓存失效 }, }); const handleSubmit = (title: string, tags: string[]) => { createTask.mutate({ title, tags }); }; // ... }这里的api对象是从~/trpc/react导出的类型化 Hooks 集合,所有路由的输入和输出类型都自动映射到了前端。当我把createTask.mutate的参数写成{ title: "xxx" }而漏掉 tags 字段时,编辑器会直接划红线。当我调用useQuery读取 list 返回值时,返回数组里的每个task对象自带tags字段类型提示,不再需要任何手写的 API 类型定义。
调试体验也值得一提。tRPC 的请求走的是普通 HTTP,在浏览器 Network 面板里能看到完整的请求和响应 JSON,和调 REST 接口是一样的。我遇到过同事问"tRPC 是不是黑魔法,出错都不好定位",其实不是——它本质就是 JSON over HTTP,调用的函数名会体现在请求路径里,比如/api/trpc/task.create,响应里的就是服务端返回的原始数据或错误信息。这个透明性对排查线上问题非常关键。
4. 登录与权限:NextAuth 集成中我踩过的几个真实深坑
4.1 Provider 配置与回调地址的边界情况
t3code 的认证用的是 NextAuth,同时接入了 GitHub OAuth 和邮箱登录,方便不同用户习惯。配置写在src/server/auth.ts,大框架是标准的,但有几个真实翻车点。
第一个坑是回调地址。NextAuth 的 OAuth 回调地址必须写登录页面路径,而 Next.js 在开发环境和生产环境的地址不同。本地开发是http://localhost:3000/api/auth/callback/github,上生产后改成线上域名。我一开始没注意,直接在 GitHub App 的配置里只填了开发地址,结果部署后登录一直跳转失败,先在本地跑才发现是回调地址和 Vercel 域名不匹配。这块必须做多环境配置,否则每次换环境都要改一遍。
第二个坑是 session 策略选择。NextAuth 默认用 JWT 无状态的 session,但如果你后续想从数据库查询更多的用户信息(比如头像、昵称),用 JWT 策略时session.user里默认只有 id 和 email。我当时在 session callback 里手动挂了额外字段,但没有在类型声明里同步扩展,前端拿session.user.name一直拿不到值。解决方法是同时扩展 NextAuth 的内置类型声明,加上你自己的字段。
// types/next-auth.d.ts import "next-auth"; declare module "next-auth" { interface Session { user: { id: string; name?: string | null; }; } }这种类型扩展是 NextAuth 集成的必修课,不做好,你会在"明明存了字段但取不到"这个诡异问题上卡上半天。
4.2 中间件保护页面时 session 丢失的排查
t3code 的/dashboard页面要求登录才能访问,我用 Next.js 中间件做路由守卫。最初写的中间件里用了getSession,结果发现中间件里经常拿不到 session,明明浏览器有登录状态,却天天被踢回登录页。
后来查文档才发现,NextAuth 默认的 session 存在 httpOnly cookie 里,中间件在 edge runtime 环境下解析 JWT 的方式和 Node 环境不同。getSession需要数据库或 API 调用上下文,在中间件里并不可靠。正确的做法是用getToken直接从 cookie 里解析 JWT:
// src/middleware.ts import { getToken } from "next-auth/jwt"; import { NextResponse } from "next/server"; export async function middleware(req: Request) { const token = await getToken({ req, secret: process.env.AUTH_SECRET }); if (!token) { return NextResponse.redirect(new URL("/login", req.url)); } return NextResponse.next(); }这个坑的根源是"用错了工具":中间件场景下解析 JWT 是最轻量可靠的方式,getSession适合在真正需要完整 session 对象的地方用。我后来把策略定成:路由守卫一律在中间件用 getToken,业务内部需要用户信息时在 tRPC context 里取完整 session。两个场景各用各的工具,再也没有出现过 session 丢失问题。
4.3 服务端与客户端会话不一致的根因与修复
线上还出现过一种更隐蔽的情况:用户在 A 页面登录了,切到 B 页面就变成未登录状态,刷新一下又好了。一开始以为是中间件问题,排查半天发现是缓存策略问题。
t3code 的 App Router 页面默认有数据缓存行为,我在某些页面组件里依赖服务端读取的 session 来做条件渲染,但页面的某些部分被框架缓存住了,导致用户在客户端更新了 session(比如登录、退出),服务端组件拿到的还是缓存里的旧数据。解决方法是给登录状态相关的内容标记动态渲染:
export const dynamic = "force-dynamic";或者更细粒度地,只在真正依赖 session 的组件里声明动态渲染。这个问题的教训是:App Router 的缓存模型和传统服务端渲染差别巨大,凡是涉及登录态、用户专属数据的页面,必须先想清楚缓存的边界。否则就会出现"逻辑上正确、运行上时好时坏"的诡异现象。
5. 部署上线:Vercel 与 Prisma 迁移的磨合过程
5.1 环境变量与多环境配置的规划
t3code 部署在 Vercel 上,整个部署过程比较省心,真正需要认真规划的是环境变量。项目里有这么几类变量:数据库连接串(DATABASE_URL)、NextAuth 密钥(AUTH_SECRET)、OAuth 提供的 client id 和 secret。
我在 Vercel 的项目设置里分别配置了 Development、Preview、Production 三套环境变量,生产环境的 DATABASE_URL 指向生产数据库,Preview 指向一个独立的数据库副本。这一步非常关键:如果 Preview 也用生产数据库,合并请求时跑迁移就可能污染线上数据。我之前因为偷懒只配了一套变量,结果一次预览分支的 schema 迁移差点把生产表结构改了,幸好 Prisma 的 migration 文件是显式的,否则后果不堪设想。
环境变量校验还有一个细节。t3code 脚手架自带一个env.js,用 zod 在服务启动时校验必填变量,这能帮你拦截"部署成功但功能全挂"的情况。初次部署时我把生产环境的 AUTH_SECRET 配错了,结果构建时直接报错,而不是上线后出现离奇的认证问题——这种早期失败其实是很友好的。
5.2 线上数据库迁移的正确姿势
本地开发时我用的是prisma migrate dev,它会自动生成迁移文件并应用到开发库。生产环境绝不能用这条命令,正确姿势是两步走:
npx prisma migrate deploy # 应用已有迁移文件到生产库 npx prisma generate # 生成对应版本的 Prisma ClientVercel 构建时默认会运行prisma generate,但数据库迁移需要在构建前触发。我在 package.json 里加了部署钩子:
{ "scripts": { "postinstall": "prisma generate", "build": "prisma migrate deploy && next build" } }这个配置能保证每次部署先同步数据库结构,再进行 Next.js 构建。需要注意的是,多个实例同时触发 migrate deploy 时可能会有锁冲突。Vercel 同一时刻只会构建一个版本还好,但如果哪天换了多实例部署平台,就得考虑把迁移步骤从构建流程里拆出来单独跑。
5.3 部署后最常见的两类线上报错及排查
t3code 上线后遇到的第一类报错是 tRPC 请求 500,错误体是 "Unexpected token in JSON"。排查后发现是 zod input 校验顺序的问题:前端传参时多了一个未定义字段,zod 默认是 strip 模式会把多余字段丢弃,但某个 router 里我手动用了.strict(),导致这个 router 外部正常、内部严格拒绝额外字段。这种问题在本地开发时很难复现,因为本地前端和服务端版本是同步的,线上则可能因为缓存、旧版本页面调用新版本接口,出现字段不一致。经验是:对外暴露的接口尽量用 zod 默认的 strip 模式,内部接口才考虑 strict,否则你会在版本错位上给自己制造麻烦。
第二类报错是数据库连接数爆掉。t3code 的 Prisma Client 在 Serverless 环境下,每个实例都会创建连接池,如果连接串配置了较高的连接数上限,大量实例同时启动会造成数据库连接耗尽。解决方式是降低连接池上限并加上空闲连接回收:
datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") }配合连接串参数?connection_limit=5&pool_timeout=2。另外 Prisma 在 Serverless 环境有一个常见建议:用directUrl指向数据库直连地址,因为 Prisma 加速引擎(Accelerate)或连接池代理在无服务环境下可能不稳定。这些细节都是上线后才逐渐暴露的,理论上如果能提前做一次压测会更好,但现实里总是先上线再补救。
6. 项目复盘:t3code 后续的优化方向与我的几条经验
6.1 从"能跑"到"抗造":性能与代码组织的再思考
t3code 功能跑通之后,我回头看代码组织,发现还有几个明显的优化空间。
第一个是 tRPC 的请求粒度。页面加载时如果同时调用五六个 query,tRPC 会发五六个独立的 HTTP 请求。虽然 HTTP/2 下并行请求的开销没那么大,但网络往返还是多了。t3code 的 Dashboard 页面后续改为在protectedProcedure里提供一个聚合的getDashboardData接口,一次请求带回应多个模块的数据。这个思路和 GraphQL 的动机是一样的:减少网络往返、按页面聚合数据。但实现起来简单得多,因为 tRPC 接口本身就是 TypeScript 函数,聚合逻辑就是"同时查两张表再合并返回"。
第二个是缓存策略。react-query 是 tRPC 底层的缓存框架,但默认配置比较保守。t3code 的任务列表接口,数据变化频率不算高,我给 query 配置了staleTime: 30_000,30 秒内的重复请求直接走缓存,既不依赖前端轮询,也不必做 WebSocket,体验已经足够好。任务创建、完成切换这些 mutation 再接上invalidate,页面数据基本实时贴合服务端。
6.2 团队协作时的类型规范
t3code 后期如果有同事加入,我最想强调的第一条协作规范是:不要在 router 之外手写接口类型。有了 tRPC 之后,前端类型完全通过RouterOutputs和RouterInputs这类工具类型动态生成。手写一份"看起来差不多"的类型,本质上是重新引入了文档同步的问题。我把这个规范直接写进了仓库的 README 里,并且给 CI 加了一条检查:任何人提交的代码里,只要出现interface TaskItem之类的重复定义,就会被 eslint 规则标记。
第二条规范是 zod schema 的复用。t3code 的输入校验里有些公共规则,比如分页参数、排序字段,这些应该抽成共享 schema,放在src/server/common/schemas.ts,路由之间复用。否则你会看到好几个 router 各写一份类似的z.object({ page: z.number(), pageSize: z.number() }),后续要加校验逻辑时需要同时改多个文件,很容易漏。
6.3 我下一步打算做的事情
t3code 目前已经能承担日常任务管理的工作,但我心里已经列出了下一步迭代的清单。第一件事是加上服务端分页:现在 list 接口是一次性把全部任务返回,数据量上来之后必须要分页,配合 zod 的分页参数合理校验。第二件事是为任务增加定时提醒的能力,这个需要引入消息队列或者 cron 服务,目前打算先用数据库轮询 + Serverless 定时触发,等真正有量再考虑上队列。第三件事是做一个简单的数据导出功能,把任务清单导出成 CSV 或 Markdown,这个功能对用户的价值反而比很多花哨的图表更高。
回头复盘整个 t3code 项目,我最深的感触是:全栈开发的最大成本从来不在写代码,而在前后端之间的"翻译层"。REST 时代你翻译的是接口文档,GraphQL 时代你翻译的是 schema 和 resolver,tRPC 时代翻译层直接被编译器吃掉了。如果你也正在被前后端类型不同步折磨,试着拿一个小项目体验一下这条路。另外提醒一句,t3code 的部署以 Vercel 为主,如果你的部署平台是自建服务器,Serverless 相关的坑就不存在了,Prisma 连接池和 migrate 策略可以适当简化——不同的运行环境会带来完全不同的排查体验,这个也只能实际踩过才知道。