这段时间一直在整理手头的一个全栈项目,陆陆续续写了不少代码,也踩了不少坑。前几天把它归档的时候,给这套基于 T3 Stack 的实践笔记起了个代号,就叫 t3code。整个项目核心围绕一件事:用 TypeScript 把前后端的类型安全真正打通,让从前端调用后端接口这件事,从“靠文档和约定”变成“编译器替你把关”。这套方案适合正在做中小型全栈应用、内部工具、或者想从零开始尝试类型安全开发模式的朋友。今天就把这套实践里的设计思路、关键步骤、踩坑记录和排查经验完整地梳理一下。
1. 项目整体设计与思路拆解
1.1 t3code 到底是什么
先把这个事情说清楚。t3code 不是一个框架,也不是什么新语言,它是一套基于 T3 Stack 的工程实践。T3 Stack 是由 tRPC 作者 Theo 推动的组合方案,核心成员包括 Next.js(前端框架)、TypeScript(语言层)、tRPC(API 层)、Prisma(数据库 ORM)、Tailwind CSS(样式),可选加入 NextAuth.js 做鉴权。这套组合最大的卖点是端到端类型安全:从前端组件到后端数据库查询,类型定义全程贯穿,中途不会断掉。
我最初选择这套组合,很大程度上是受够了传统前后端分离模式下那种“接口文档写得挺好,联调时全是意外”的状态。REST 接口虽然简单,但前后端之间的契约完全靠人肉维护;GraphQL 虽然把类型带进来了,但 schema、resolver、前端 fragment 的管理成本又有点重。tRPC 的思路非常直接:既然前后端都是 TypeScript,那就让调用方直接获得被调用函数的类型签名,不需要生成代码,不需要额外声明路由文档,天然就是类型安全的。
t3code 这个项目里,我实现了一个带账号体系的笔记收藏应用。用户登录后可以创建笔记、管理自己的收藏;未登录用户可以浏览公开笔记列表。功能不算复杂,但恰好能把 T3 Stack 各个核心环节串联起来。整个项目的目录结构也严格按照 T3 的推荐方式组织:Next.js App Router 负责页面,server 目录放 tRPC router 和 Prisma 客户端,客户端组件通过 tRPC hooks 发起调用。
1.2 技术选型背后的逻辑:为什么是这套组合
先说 Next.js。它既承担了页面渲染,又承担了 API 服务。tRPC 的 HTTP 适配器可以挂载在 App Router 的路由处理器上,这样后端逻辑不需要单独部署一套服务,整个应用打包成一个整体扔到服务端就行。这个取舍对小团队和个人项目很友好,部署成本低,心智负担也小。
再说 Prisma。它的价值主要体现在类型生成和迁移管理。定义一个Note模型,运行prisma generate之后,查询返回的每个字段都有精确的 TypeScript 类型。配合 tRPC,Prisma 的返回类型可以直接穿透到前端,数据库里查出来什么结构,前端组件里就能拿到什么结构。这一点非常关键,等于把类型安全的链条延伸到了数据库这一层。
tRPC 在这套组合里是核心。它提供的不是 REST 风格的 URL 设计,而是直接在服务端定义可调用函数,前端以近乎调用本地函数的方式发起请求。这个模式有一个非常大的好处:新增一个接口,不需要去设计 URL、不需要写接口文档、不需要定义 DTO,只需要写一个 procedure,前端保存后立刻就能拿到类型提示。
下面用一个表把这套组合和传统方案做个对比,看完思路会更清楚:
| 维度 | REST + 手写类型 | GraphQL | tRPC + T3 |
|---|---|---|---|
| 类型安全 | 靠手写 DTO 和接口文档,易漂移 | schema 与 resolver 有类型,但前端仍需维护 fragment | 前后端类型穿透,无中间层文件 |
| 开发效率 | 每次加接口都要写客户端请求层 | 需要维护 schema、resolver、codegen | 定义 router 后前端直接调用 |
| 服务端部署 | 通常单独部署 | 单独部署 | 与 Next.js 一起部署 |
| 学习成本 | 低 | 中高 | 中 |
| 适合场景 | 团队已有规范的外部接口 | 复杂聚合查询、多端消费 | 全栈单应用、内部工具、启动项目 |
2. 从零搭建 T3 项目的关键步骤
2.1 5分钟初始化一个 T3 项目
T3 Stack 官方提供了脚手架create-t3-app,这是整个项目起步最快的方式。执行下面的命令:
npx create-t3-app@latest t3code过程中会有几个交互式选项,我的选择是:
- Next.js 采用 App Router 模式(新项目推荐这里)
- 启用 TypeScript(这是必须项)
- 启用 tRPC
- 启用 Prisma
- 启用 Tailwind CSS
- 启用 NextAuth.js(笔记应用需要用户体系)
- 启用 ESLint
这里有个细节值得说:脚手架默认把 tRPC 和 Prisma 都配好了,server/api/trpc.ts文件里已经实现了createTRPCContext和createCaller,不需要自己从零拼装。很多新手在这里容易犯的错是启动项目后看见一堆文件就盲目删改,其实官方脚手架生成的结构已经非常合理,先跑通一遍再说优化的事。
初始化完成后,直接运行npm run dev,访问本机端口能看到一个默认页面。此时整个链路还没有数据,需要先把数据库接进来。
2.2 理解 t3code 项目结构
脚手架生成的目录结构看起来复杂,但核心只有几个位置,搞清楚之后整个项目就通了大半:
app/:Next.js 的页面和路由。app/page.tsx是首页,app/api/trpc/[trpc]/route.ts是 tRPC 的 HTTP 入口。server/api/root.ts:汇总所有子 router 的地方。每新增一个功能模块,在这里挂载。server/api/routers/:具体业务逻辑。server/api/trpc.ts:tRPC 实例、上下文、公共 procedure 的定义处。server/db.ts:Prisma Client 单例。prisma/schema.prisma:数据模型定义。
需要特别注意的是trpc.ts文件。tRPC 的上下文类型会从这里一直穿透到所有 procedure 中。比如你想在路由里拿到当前登录用户,就一定是在createTRPCContext里读取 session,然后塞进 context。很多类型报错,根源就是这里没接对。
2.3 数据层建模:Prisma Schema 设计要点
以我的笔记应用为例,先设计两个核心模型:用户和笔记。用户模型可以直接用 NextAuth 的适配器扩展,笔记模型单独定义。
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] notes Note[] } model Note { id String @id @default(cuid()) title String content String visibility Visibility @default(PUBLIC) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User @relation(fields: [authorId], references: [id]) authorId String @@index([authorId]) } enum Visibility { PUBLIC PRIVATE }设计 schema 时有几个经验值得分享。
第一,主键一律用cuid()而不是自增 ID。自增 ID 的问题在于外部用户可以通过枚举 ID 遍历资源,在公开场景下容易泄露数据规模。cuid 虽然长一点,但既无序又带随机性,对 Web 应用更稳妥。
第二,凡是经常按某个字段查询的,都要显式建索引。像Note模型里@@index([authorId]),用户中心里查“我写的笔记”是高频操作,没有索引,数据量一大基本就是全表扫描。
第三,visibility字段用 enum 而不是布尔值。虽然看起来多了一个概念,但后续想扩展“仅好友可见”之类的状态时,只需要在 enum 里加一个值,改造成本远低于重构布尔逻辑。
定义好 schema 后,执行迁移:
npx prisma migrate dev --name init这一步会把schema.prisma的变化同步到数据库,同时重新生成 Prisma Client 的类型定义。迁移完成后,把DATABASE_URL配到.env里。本地开发我用 PostgreSQL,部署到 Vercel 之后直接用平台的数据库连接串替换即可。
3. 核心功能实现:tRPC 路由与调用全流程
3.1 定义第一个 tRPC Router:输入输出校验
T3 脚手架的server/api/root.ts中会预置一个postRouter示例,我们可以在routers目录下新建自己的业务路由。以笔记功能为例,先定义一个noteRouter:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const noteRouter = createTRPCRouter({ listPublic: publicProcedure.query(async ({ ctx }) => { return ctx.db.note.findMany({ where: { visibility: "PUBLIC" }, orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); }), listMine: protectedProcedure.query(async ({ ctx }) => { return ctx.db.note.findMany({ where: { authorId: ctx.session.user.id }, orderBy: { updatedAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), visibility: z.enum(["PUBLIC", "PRIVATE"]).default("PRIVATE"), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.note.create({ data: { title: input.title, content: input.content, visibility: input.visibility, authorId: ctx.session.user.id, }, }); }), });这段代码里有两个 procedure 类型:publicProcedure和protectedProcedure。它们都是在server/api/trpc.ts里基于中间件定义的。protectedProcedure会先检查 session,没有登录直接抛UNAUTHORIZED错误,后续代码根本不会执行。这个模式把鉴权从业务代码里剥离出来了,非常干净。
3.2 前端类型安全调用:useQuery 与 useMutation
服务端定义好 router 后,前端的调用体验是整个 T3 最爽的部分。首先在app/_trpc/client.ts里创建类型化的 tRPC 客户端:
import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "@/server/api/root"; export const trpc = createTRPCReact<AppRouter>();然后在根布局或局部页面挂载 provider。这里需要注意:Next.js App Router 下,provider 必须是客户端组件,所以通常会单独抽一层TRPCProvider:
"use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import { useState } from "react"; import { trpc } from "./client"; export function TRPCProvider({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()); const [trpcClient] = useState(() => trpc.createClient({ links: [httpBatchLink({ url: "/api/trpc" })], }) ); return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> </trpc.Provider> ); }之后在任何客户端组件里,调用笔记列表就变成这样:
"use client"; import { trpc } from "@/app/_trpc/client"; export function NoteList() { const { data, isLoading, error } = trpc.note.listPublic.useQuery(); if (isLoading) return <p>加载中...</p>; if (error) return <p>出错了:{error.message}</p>; return ( <ul> {data?.map((note) => ( <li key={note.id}> <h2>{note.title}</h2> <p>{note.content}</p> <span>{note.author.name}</span> </li> ))} </ul> ); }这里data的类型就是服务端 Prisma 查询返回的类型,不需要前端再去手写一个Noteinterface。新增字段、改字段名,编译器都会立即提醒前端哪里需要同步修改。这种体验一旦习惯了,你就很难再回去对齐字段名靠猜的日子。
创建笔记的 mutation 写法也很直观:
const utils = trpc.useUtils(); trpc.note.create.useMutation({ onSuccess: () => { utils.note.listPublic.invalidate(); }, });invalidate是 React Query 的缓存失效机制。mutation 成功后把列表标记为陈旧,下次读取时自动重新拉取。这一步是保证数据一致性的关键,很多页面改完数据不刷新,都是因为在 mutation 成功后没有做 invalidate。
3.3 鉴权与上下文:让 session 流到数据库查询
T3 上下文的设计是理解整套体系的关键一环。打开server/api/trpc.ts,核心逻辑很清晰:
import { initTRPC, TRPCError } from "@trpc/server"; import { getServerSession } from "next-auth"; import { db } from "@/server/db"; import { authOptions } from "@/server/auth"; export const createTRPCContext = async () => { const session = await getServerSession(authOptions); return { db, session, }; }; const t = initTRPC.context<typeof createTRPCContext>().create(); export const createTRPCRouter = t.router; 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: { ...ctx, session: { ...ctx.session, user: ctx.session.user } } }); });createTRPCContext在每次请求进入时执行,然后把db和session塞进所有 procedure 的ctx。protectedProcedure通过t.procedure.use()添加了一个中间件,检查未登录直接抛UNAUTHORIZED。
这个设计让业务路由里的代码非常干净,比如查询“我的笔记”时,不需要在路由里再判断“用户是否存在”,因为能走到这里的人一定是已登录的。
3.4 SSR 与缓存处理:服务端组件里直接调 tRPC
T3 的一个常见疑问是:服务端渲染组件要不要也走 HTTP 请求?正确答案是不用。App Router 模式下,服务端组件可以用createCaller直接在 Node 环境里调用 router,无需经过网络层:
import { createTRPCContext } from "@/server/api/trpc"; import { appRouter } from "@/server/api/root"; export default async function HomePage() { const ctx = await createTRPCContext(); const caller = appRouter.createCaller(ctx); const notes = await caller.note.listPublic(); return ( <main> <NoteList initialNotes={notes} /> </main> ); }这样做的好处是首屏数据在服务端就拿到了,前端组件可以直接用初始数据渲染,不会出现“先空白再闪现”的情况。同时类型依然全程贯通,notes的变量类型会直接让你知道每个字段是什么。
这里需要特别注意一点,如果某个页面是纯静态的,服务端调用 tRPC 查数据库会让页面变成动态渲染。需要根据业务场景决定:全局性的内容可以用缓存或静态生成,依赖用户登录态的数据建议保持动态渲染。
4. 现场实录:一个完整功能从设计到上线
4.1 实现笔记创建的交互闭环
第三节只展示了定义好的 router,这一节把完整流程串起来。先说一个常见误区:把整个页面都装进客户端组件。T3 的最佳实践类似“静态外壳 + 客户端交互岛”,列表的外层布局可以在服务端渲染,只有需要交互的表单、按钮、实时列表数据才标记为"use client"。
创建笔记的表单,我用一个客户端组件承载:
"use client"; import { useState } from "react"; import { trpc } from "@/app/_trpc/client"; export function CreateNoteForm() { const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const utils = trpc.useUtils(); const createNote = trpc.note.create.useMutation({ onSuccess: () => { setTitle(""); setContent(""); utils.note.listMine.invalidate(); utils.note.listPublic.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); createNote.mutate({ title, content, visibility: "PUBLIC" }); }} > <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="标题" required /> <textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="内容" required /> <button type="submit" disabled={createNote.isPending}> {createNote.isPending ? "提交中..." : "创建笔记"} </button> </form> ); }这个组件里值得注意的细节是createNote.isPending。用 mutation 自带的 pending 状态去控制按钮的 loading,比手动管理一个submitting状态变量要可靠得多,还能避免按钮被重复点击。
4.2 联调验证:从 dev 到构建
开发模式下,前后端在同源环境下跑,几乎没有跨域问题。当我把项目推到生产环境之前,必须先执行一次完整的类型检查和构建:
npm run buildcreate-t3-app默认把tsc检查和next build绑在一起。这一步非常重要,因为 tRPC 的类型贯穿是整个项目的价值所在,一旦某个调用方与服务端类型对不上,构建就会直接失败。在传统 REST 项目里,这种错误往往要等到运行时才会暴露。
部署到 Vercel 后,我遇到了不少朋友也常见的问题:本地npm run dev一切正常,部署后app/api/trpc/[trpc]却返回 404。排查之后发现大多是 Next.js 版本更新后,动态路由处理器需要显式声明运行环境。在route.ts文件头部加上一行即可:
export const dynamic = "force-dynamic";4.3 生产环境部署的注意事项
生产环境和本地开发最大的差异有三个:环境变量、数据库网络、Serverless 执行环境。
环境变量是最容易踩的坑。DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL必须在 Vercel 的控制台里逐一配置,而且NEXTAUTH_SECRET不能复用开发环境的默认值,必须用openssl rand -base64 32生成一个新的随机串。
数据库方面,国内开发者如果使用海外数据库服务,连接延时和连接数限制会成为瓶颈。Serverless 环境下函数的冷启动会创建新的 Prisma Client,但 Prisma Client 本身是有全局连接池的,建议在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;这个模式是 Prisma 官方推荐的,能显著降低连接数耗尽的风险。部署后如果看到Too many connections或Connection pool timeout的报错,先从这段代码入手排查。
5. 常见问题与排查技巧实录
5.1 tRPC 类型报错:泛型丢失怎么办
T3 项目里最常见的报错是前端调用时出现类似Property 'id' does not exist on type 'never'或Type 'undefined' is not assignable to type 'string'。
这类问题的根源大多是ctx类型不对。我在调试过程中总结了一条排查路径:先看server/api/trpc.ts里的createTRPCContext返回了什么,再看 router 里ctx.db是否符合预期。如果ctx类型有问题,所有下游 procedure 的返回类型都会被推断成错误或空类型。修复方法很简单,确保initTRPC.context<typeof createTRPCContext>()用的是函数类型本身,而不是返回类型。
另外一个容易踩的坑是路由里用了any做输入输出。tRPC 的类型安全依赖显式声明输入 schema,任何绕过 zod 校验的输入都会让返回推断退化成宽松类型。我的原则是:路由的输入输出永远显式声明,拒绝any。
5.2 React Query 数据不刷新:invalidate 的使用时机
页面改了数据后列表不更新,这是几乎每个用 tRPC 的人都会遇到的。原因很简单:tRPC 的useQuery依赖 React Query 的缓存,mutation 成功后没有主动失效缓存,旧数据就一直躺在缓存里。
我的习惯是,在 mutation 的onSuccess回调里统一做invalidate。如果同一个 mutation 影响多个列表(比如创建公开笔记既影响公开列表也影响我的列表),就把相关的都 invalidate 一次。React Query 的invalidate不是暴力清空缓存,而是标记为陈旧后再后台拉取,用户体验非常自然。
还有一种更精细的更新方式是直接用setData做乐观更新。比如给笔记加收藏,可以先本地把 UI 切换成“已收藏”,同时调 mutation,失败再回滚。这个模式适合追求极致交互体验的场景。
5.3 Prisma 在 Serverless 环境下的连接池问题
部署到 Vercel 之后,我遇到过一个印象深刻的错误:页面偶发性报错,提示PrismaClientInitializationError,过几分钟后自己恢复。排查后确认是连接池耗尽。
Serverless 函数并发是弹性伸缩的,每个实例都可能创建数据库连接,如果数据库连接数上限比较低,就容易出现“瞬间冲垮”的情况。解决办法除了前面说的全局单例,还有两条路:一是把数据库连接池配置调大,这取决于数据库服务商,通常 PostgreSQL 都有connection_limit参数可调;二是在 Prisma 的datasource里增加连接池超时和重试参数,让瞬时失败不会直接变成 500 响应。
datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") }directUrl与url分开配置是 Vercel 部署的常见实践:url走连接池代理,directUrl走直连。这个配置在流量不大时看不出区别,一旦遇到并发尖峰,差异非常明显。
5.4 容易忽略的运行时错误清单
我把这段时间遇到的高频问题整理成一个速查表,方便日后排查:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 前台调用报 404 | 动态路由被静态化 | 在app/api/trpc/[trpc]/route.ts加force-dynamic |
| mutation 后数据不刷新 | 缺少 invalidate | 检查onSuccess里的utils.xxx.invalidate() |
| 部署后登录态丢失 | NEXTAUTH_SECRET未更新 | 用openssl rand -base64 32重新生成 |
| Prisma 报连接池超时 | 并发连接过多 | 使用全局单例,配置directUrl |
类型推导变成never | context 类型不匹配 | 检查initTRPC.context<typeof ...>()定义 |
| zod 校验不过但报错不明确 | 输入类型与 schema 不一致 | 严格检查前端传参,前端与 schema 对齐 |
6. 进阶扩展与工程化建议
6.1 从 REST 迁移到 tRPC 的路径
如果手头已经有一个 REST API 的后端,想迁移到 tRPC,我建议不要一次全部替换,而是渐进式改造。保留现有的 REST 路由,新增功能模块直接用 tRPC 写;等一部分页面前端完成迁移后,再把旧接口逐个下线。这样风险可以控制得很低,也不会影响线上业务。
迁移过程中有一个关键点:原有 REST 接口的错误码和消息格式,在 tRPC 里通过TRPCError的code字段映射。前端的拦截器需要同时兼容两种错误格式,等旧接口完全下线后再统一收口。
6.2 大型项目里的 router 组织策略
t3code 目前只有一个note模块,但真实项目不会这么简单。当业务模块变多,root.ts里 import 的代码会越来越多,我习惯按业务域拆分成多个子 router,root.ts只做挂载,不写业务逻辑:
import { createTRPCRouter } from "./trpc"; import { noteRouter } from "./routers/note"; import { userRouter } from "./routers/user"; import { favoriteRouter } from "./routers/favorite"; export const appRouter = createTRPCRouter({ note: noteRouter, user: userRouter, favorite: favoriteRouter, });每个子 router 内部如果要再细分,可以用命名空间,比如noteRouter.create、noteRouter.list这样的层级。这样调用的语义非常清晰,前端自然就是useNote.xxx.useQuery()的调用方式,不用记任何 URL。
6.3 测试策略:直接在 Node 层打穿
tRPC 带来的一个独特好处是测试变得很轻。因为业务逻辑都在 router 的 procedure 里,测试时不需要启动 HTTP 服务,直接创建 caller 调用即可:
const caller = appRouter.createCaller({ db: testDb, session: { user: { id: "test-id" } }, }); const result = await caller.note.create({ title: "测试标题", content: "测试内容", visibility: "PUBLIC", });这样测试的速度非常快,不需要 mock HTTP 层,也不需要处理序列化,直接调用函数。配合模拟数据库(或者测试数据库实例),可以覆盖绝大多数业务逻辑。前端组件层的测试再单独用 React Testing Library 处理,两层测试职责分离,维护成本很低。
6.4 性能优化与缓存策略
最后聊一下性能。tRPC 默认使用httpBatchLink,它会把一个事件循环内的多个请求合并成一个 HTTP POST,减少请求次数。这个机制对低流量的应用效果不明显,但页面同时调用三五个 query 时,网络开销能省一大截。
缓存方面,React Query 的staleTime参数值得认真配置。默认是 0,意味着每次组件挂载都会重新请求。对变化不频繁的数据(比如公开笔记列表),可以调大:
const { data } = trpc.note.listPublic.useQuery(undefined, { staleTime: 60_000, });这样一分钟内重复进入页面,会直接走缓存,点击体验会流畅很多。需要注意,涉及用户隐私或实时性要求高的数据(比如余额、订单状态),staleTime不要调太大,否则会出现显示滞后。
我在实际使用中的体会是,t3code 这套实践给自己带来的最大收益不是某个具体功能,而是开发节奏的改变。以前写完接口还要写文档、定义类型、来回对字段名。现在改一个数据模型,类型差错在编译期就暴露了。整理这个项目的过程里,我把每一步踩过的坑都用注释留在代码里了,比如为什么加索引、为什么用 cuid、为什么 invalidate 放在 onSuccess。这些看似细碎的点,恰恰是文档里查不到、只有自己动手做一遍才能真正理解的。如果你也想转型到类型安全的全栈开发模式,我建议别急着追求复杂架构,就从一个小项目开始,跑通 create-t3-app,写出第一个带鉴权的 mutation,然后逐步添加模块。等基础建立起来后,你会发现自己对“前后端配合”这件事的理解会完全不一样。