我最近把一个个人项目推倒重写,发现真正让我头大的不是业务逻辑,而是 TypeScript 类型没法从数据库一路焊接到页面。后端改了一个字段名,前端接口文档早忘了更新,跑起来全是运行时才暴露的错误。后来我干脆用 T3 Stack 的思路重做了这套东西,项目代号就叫 t3code。这次我把整个搭建和排坑过程完整记录下来,从技术选型到部署上线,所有代码都是我在 t3code 里实际跑过的。
t3code 说白了就是一个以 T3 Stack(TypeScript + Next.js + tRPC + Prisma + Tailwind)为底层的全栈实验项目。它能帮你用最小的成本,把前端、后端、数据库三者之间的类型关系统一起来,写代码的时候改一处,其他位置立刻跟着变,不需要手工维护接口定义。适合正在学全栈、或者被多服务联调折磨过的人参考。这篇文章不会只讲概念,我会把 t3code 从初始化到部署的每一步都拆开,照着做就能复现一套类型安全的全栈应用。
1. 为什么我会折腾 t3code:先搞清楚 T3 Stack 在救什么
1.1 前后端分离的“类型断层”痛点
很多项目的开发模式是后端写 REST 接口,前端用 axios 去请求,然后自己手写 interface。这在一两个接口的时候没问题,一旦业务到十几个接口、几十个字段,维护成本就开始失控。后端改了字段,前端声明的类型还是旧的,编译不报错,运行时数据结构和预期不一致,排查半天发现是类型不同步。
这种问题的本质是前后端之间有一条“类型断层”。REST 接口只是传输 JSON 文本,接口文档和 TypeScript 类型都是人工维护的,任何一边忘了更新,系统不会主动告诉你。我之前做过一个筛选功能,后端把status从字符串改成了联合类型,前端忘了同步,结果页面直接渲染出错误状态。这类问题在运行时才暴露,调试成本远高于编译期报错。
t3code 的核心思路就是把这条“类型断层”填平。不是靠文档,而是靠 TypeScript 的类型推断能力。前后端共享同一套类型定义,改一个字段,整条链路上的代码都会跟着变,编译期就能发现错误。这就像两个同事对接工作,与其靠口头传话,不如共享同一张任务表格,表格改了,双方看到的都是最新状态。
1.2 T3 Stack 的组合逻辑:为什么是 Next.js + tRPC + Prisma + Tailwind
很多人第一次听说 T3 Stack,会觉得它只是在堆砌热门框架。其实这四个东西各司其职,组合在一起恰好覆盖了一条完整的全栈链路。
Next.js负责应用框架,既是前端 React 的渲染层,又能通过 Route Handlers 提供后端能力。选择 App Router 是因为服务端组件可以直接调用后端逻辑,省略一层 HTTP 请求,响应更快。tRPC是用来替代 REST 的接口层。它的特点是无需定义 URL 路由,前端像调用本地函数一样调用后端方法,类型完全由 TypeScript 推断。Prisma负责数据库访问,用 schema 文件定义数据表,生成类型安全的查询客户端。Tailwind负责样式,原子化 CSS 让 UI 开发速度更快,最关键的是它和 Next.js 天然兼容,不会在打包时出幺蛾子。
这套组合最大的魅力在于类型安全是端到端的。从数据库的表结构开始,Prisma 生成类型,tRPC 继承类型,Next.js 组件消费类型,前端 UI 和数据库字段由同一套类型连接,任何一环修改都会被 TypeScript 检查到。这比传统的 “前端定义一套 interface + 后端定义一套 schema” 的方案爽太多。
2. 落地第一步:用 create-t3-app 初始化 t3code 项目
2.1 初始化命令与交互选项
搭建 t3code 最快的方式是用官方脚手架。以 create-t3-app 为例,一条命令就可以生成完整的项目骨架:
npx create-t3-app@latest t3code执行后会有交互式选项,我实际勾选的项目是这样的:
- Next.js:选择 App Router,tRPC 和 Prisma 的集成模板默认就是为 App Router 设计的,不需要额外配置 Server Actions。
- TypeScript:必须启用 strict 模式,这是端到端类型安全的前提。
- tRPC:勾选,脚手架会自动创建
server/api目录和.ts文件。 - Prisma:勾选,并选择 SQLite 作为起始数据库,本地开发零配置。
- Tailwind CSS:勾选,样式方案没什么异议。
- ESLint + Prettier:建议勾选,团队协作时格式化争议会少很多。
脚手架生成后,会有一个极简但完整的示例程序,包含一个示例post相关的 tRPC router。我第一次跑npm run dev时最直观的感受是:首页里展示的数据,确实是直接从数据库查出来的,但是代码里看不到 axios、fetch 这些调用,它是直接调用了一个函数。
2.2 目录结构逐层拆解:每个文件夹存在的理由
脚手架生成的目录不是随便放的,每个文件夹都在 T3 的约定里有明确职责。我以 t3code 的目录结构为例拆一下:
t3code/ ├── app/ # Next.js App Router 页面 │ ├── api/trpc/ # tRPC 的 HTTP 入口 │ └── page.tsx # 首页 ├── prisma/ │ └── schema.prisma # 数据库表定义 ├── server/ │ ├── api/ # tRPC 相关代码 │ │ ├── routers/ # 各业务 router │ │ ├── root.ts # root router 汇总 │ │ └── trpc.ts # tRPC 初始化文件 │ ├── auth.ts # 认证上下文(如果启用了) │ └── db.ts # Prisma Client 实例 ├── styles/ │ └── globals.css # Tailwind 入口 └── package.jsonserver/db.ts是整个应用的数据库入口,它创建并导出了一个 PrismaClient 单例。为什么需要单例?因为在开发模式下,Next.js 的热更新会重新加载模块,如果每次加载都创建一个新的 PrismaClient,会耗尽数据库连接。t3code 里做的处理是全局缓存:
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; }这段代码解决了开发时数据库连接数暴涨的问题,也是很多人忽略的细节。server/api/trpc.ts则是 tRPC 的初始化文件,定义了上下文、错误格式化等基础能力。理解了这个文件,基本就理解了 tRPC 的扩展方式。
3. 首条业务链路:从 SQLite 表到页面渲染的类型安全闭环
3.1 Prisma Schema 设计:先定数据才能定类型
类型安全的源头在数据库。t3code 里我做了一个简单的文章列表功能,先定义Post模型:
model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }写完 schema 后执行:
npx prisma migrate dev --name init这个命令会做两件事:创建迁移文件并应用到 SQLite 数据库,同时更新@prisma/client的 TypeScript 类型。也就是说,db.post.findMany()的返回类型会根据schema.prisma的字段定义自动生成。
我特别强调“先定数据才能定类型”,是因为很多前端出身的开发者习惯先写页面,再去补后端。T3 的方式是反过来,先把数据模型定清楚,后续代码才能获得正确的类型提示。如果表里没有title字段,写代码时 TypeScript 会在post.title上直接画红线,不用等到运行时才发现。
3.2 在 tRPC Router 里写数据库查询:publicProcedure 与 protectedProcedure 的区别
tRPC Router 是后端逻辑的容器。t3code 的/server/api/routers/post.ts里这样写的:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "@/server/api/trpc"; import { db } from "@/server/db"; export const postRouter = createTRPCRouter({ getAll: publicProcedure.query(async () => { const posts = await db.post.findMany({ orderBy: { createdAt: "desc" }, }); return posts; }), create: publicProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), })) .mutation(async ({ input }) => { return db.post.create({ data: { title: input.title, content: input.content, }, }); }), });publicProcedure表示不需要登录就能调用。如果做需要登录的功能,就要用protectedProcedure,它会先校验会话,再把用户信息注入到 ctx 里。同一个 router 里可以混合使用不同权限的 procedure,开发起来非常灵活。
这里的zod输入校验值得多说一句。用z.string().min(1)定义了输入格式,TypeScript 会自动把这个 schema 推导成input的类型,后端不需要再手写interface CreatePostInput。前端调用时,传错类型会在编辑器里直接报错,交互双方拿到的输入类型完全一致。
写完 router 之后,需要把它注册到根 router 里。t3code 的/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;这个AppRouter类型是整个链路的关键。它会作为泛型传给前端 tRPC 客户端,让前端拥有和后端完全一致的类型信息。忘掉这一步的话,前端永远无法知道后端有哪些方法。
3.3 在 Next.js 端渲染数据:Server Component 与 Client Component 的取舍
t3code 的首页采用两种方式来消费 tRPC API。如果数据主要用于初始渲染,而且不依赖用户交互状态,我选择在服务端组件里直接调用 tRPC 的 caller:
import { api } from "@/trpc/server"; export const dynamic = "force-dynamic"; export default async function HomePage() { const posts = await api.post.getAll(); return ( <main> {posts.map((post) => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </article> ))} </main> ); }这里用到的是 tRPC 的 Server 端直接调用能力,不发送 HTTP 请求,等于在后端函数内部执行查询。设置export const dynamic = "force-dynamic"是让页面在每次请求时重新执行,避免 Next.js 默认的缓存行为让数据不新鲜。
需要交互的部分,比如创建文章的按钮,我用客户端组件配合 React Query hooks:
import { api } from "@/trpc/react"; function CreatePostForm() { const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: async () => { await utils.post.getAll.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); createPost.mutate({ title: String(formData.get("title")), content: String(formData.get("content")), }); }} > <input name="title" placeholder="标题" /> <textarea name="content" placeholder="内容" /> <button type="submit">发布</button> </form> ); }utils.post.getAll.invalidate()是 tRPC v11 里用于刷新数据的标准方式。它会把 React Query 里对应 key 的缓存标记为失效,后台自动重新请求。整个流程里,前端没有出现任何接口地址字符串,没有手写类型,也没有手工处理错误码,前后端出了问题 TypeScript 会第一时间告诉我。
4. t3code 上线记录:部署规范与高频踩坑
4.1 生产环境数据库迁移:从 SQLite 换成 PostgreSQL
本地开发用 SQLite 很顺手,因为它就是一个文件,不需要安装额外服务。但部署到生产环境时,问题就来了:很多托管平台的文件系统是只读的,或者部署实例重启后文件丢失,SQLite 数据根本留不住。t3code 在部署前我做了一个关键迁移:把数据库从 SQLite 换成 PostgreSQL。
首先在schema.prisma里修改 datasource:
datasource db { provider = "postgresql" url = env("DATABASE_URL") }然后把本地开发时的数据迁移过去。我的做法是写一个脚本,从 SQLite 读取记录,再写入 PostgreSQL。如果你还没有复杂数据,可以直接跑一条命令:
npx prisma migrate reset --force npx prisma migrate dev --name init这里有个容易踩的坑:如果之前本地已经执行过prisma migrate dev,数据库里会有迁移记录。换成 PostgreSQL 后,旧的迁移文件虽然还在,但新数据库没有执行过,直接部署时 Prisma 会提示数据库与迁移状态不一致。正确做法是在生产环境执行npx prisma migrate deploy,它会按迁移文件顺序执行,保证 schema 与本地一致。
另一个坑是DATABASE_URL的环境变量。Prisma 的env("DATABASE_URL")要求环境变量名完全一致。我在 Vercel 上部署时,第一次忘了把数据库连接串配到环境变量里,结果构建报错。记住:生产环境要单独配置,不要用本地的默认值。
4.2 tRPC 类型报错与 Router 合并高频问题
t3code 开发过程中,我遇到的 tRPC 类型报错主要有三类。
第一类是忘记把子 router 注册到 root router。写完了postRouter,但root.ts里没有post: postRouter。前端调用api.post.getAll时 TypeScript 报错说post不存在。这个错误很直观,但越是靠类型推断的开发方式,这类“少挂载”的问题越容易被忽略,因为编译要到最后才知道发现。
第二类是输入 schema 与前端调用不一致。比如后端create用了z.object({ title: z.string().min(1), content: z.string() }),前端传了content: undefined,tRPC 会在编译期通过类型提示给出警告,在运行时会抛出TRPCClientError。处理方式是把 zod schema 放在一个共享文件中,前端和后端都用同一个 schema,避免两端各自维护一份拷贝。
第三类是ctx 为空导致 Cannot read properties of undefined。如果启用了protectedProcedure,但没有正确注入会话,代码里取ctx.session.user就会报错。解决方法是先查ctx.session是否为空,或者直接用脚手架提供的getServerAuthSession函数初始化上下文。
做一个速查表,方便以后快速排错:
| 错误场景 | 典型报错 | 检查思路 |
|---|---|---|
| router 未合并 | Property 'post' does not exist on type | 检查root.ts的appRouter是否包含该 router |
| 输入类型不匹配 | Argument of type 'X' is not assignable | 检查 zod schema 与前端传参是否一致 |
| ctx 注入失败 | Cannot read properties of undefined | 检查trpc.ts的createContext是否返回了会话 |
| Prisma 模型未更新 | Type 'Post' is not a valid value | 执行npx prisma generate重新生成类型 |
| 缓存数据不刷新 | 页面数据一直是旧的 | 检查是否设置了force-dynamic或使用invalidate() |
4.3 页面数据不新鲜的排查:Next.js 缓存与 tRPC 的交互
部署 t3code 之后,我发现一个很隐蔽的问题:新增文章后,页面没有立即显示最新数据。一开始我怀疑是 tRPC 的问题,其实根源在 Next.js 的缓存策略。
Next.js App Router 默认会对动态数据做缓存,如果页面没有显式声明动态行为,它可能被渲染成静态页面。解决方式是加一行:
export const dynamic = "force-dynamic";或者使用revalidatePath在数据变更后主动刷新:
import { revalidatePath } from "next/cache"; export async function createPostAction(input: { title: string; content: string }) { await db.post.create({ data: input }); revalidatePath("/"); }使用revalidatePath的方式更适合有 Server Actions 的业务。但如果选择在客户端组件里通过 tRPC mutation 写数据,就依靠 React Query 的invalidate()来刷新缓存。两者不要混用,否则会出现“服务端数据已经更新、客户端缓存还有旧数据”的错觉。
t3code 最终方案是:纯展示页面用服务端直调 tRPC,并显式声明force-dynamic;所有写操作放在客户端组件,mutation 成功后调用invalidate。这样两条链路各管各的,不会互相干扰。
最后的实操体会
我做完 t3code 之后最大的感受是:T3 Stack 不是某种魔法,它只是把 TypeScript 的能力在时钟链条上用到了极致,让编译期就能挡住大量低级错误。可能有读者会觉得 tRPC 不如 REST 通用,确实如此,如果要做开放 API 给第三方,REST 或 GraphQL 才是更好的选择。但只要前后端都是 TypeScript 项目,tRPC 的开发效率和安全性远高于传统接口联调。
最后再分享一个我在实际中用得很顺手的小技巧:尽量把每个 tRPC procedure 的输入校验写成 zod schema,并且放在后端文件里直接导出。这样不只在调用层能复用同一个类型,写单元测试时也能直接引用,省掉很多重复声明的体力活。做完这一套,你再回头看原来那种“接口文档靠嘴、类型靠猜”的开发方式,应该很难再回去了。