T3 Stack实战:用tRPC和Prisma构建全栈类型安全应用 去年年底我打算把手头散落的代码片段整理成一个能随手检索、能一键分享给自己的小工具项目名字起得随意就叫t3code。名字里的 T3 指的是社区里流传度很高的 T3 Stack——TypeScript、Tailwind CSS、tRPC 三件套外层再套一个 Next.js 负责页面和路由数据层用 Prisma 管数据库。这套组合最吸引我的地方不是新而是每个工具都只操心一件事整条链路从数据库到页面按钮都是类型安全的写完 CRUD 基本不需要在前后端之间反复对齐字段名。这篇文章就记录一下我实际搭建这个项目的完整过程包括脚手架勾选、tRPC 的调用链设计、页面渲染节奏以及部署之后踩过的几个坑。适合想用 T3 Stack 做点真实项目的人参考也适合刚接触 Next.js 但被服务端组件、API 路由整懵的朋友。1. 为什么起名 t3code一次选型对比之后的决定很多第一次听到 T3 Stack 的人会以为它是一套全家桶框架其实它的官方描述很克制——一个组合了 TypeScript、Tailwind CSS、tRPC、Next.js 和 Prisma 的起步模板。它的核心理念是最少但完整Next.js 管路由和渲染Tailwind 管样式Prisma 管数据库映射tRPC 管 API 层TypeScript 像胶水一样把类型从数据库一路粘到前端组件。我选择它之前也认真对比过传统方案。1.1 传统 REST API 和 tRPC 的本质差异如果按老思路做这个工具我会先写一套 REST 接口GET /api/snippets、POST /api/snippets、DELETE /api/snippets/:id然后在前端手动维护一套 TypeScript 接口定义再写 axios 封装、错误处理、loading 状态。一个简单项目里这些重复代码往往比业务代码还多而且一旦后端改了某个字段前端很容易忘记同步。tRPC 的方式则完全不同在服务端定义一个 router每个接口就是一个函数前端却能直接调用这个函数类型由 TypeScript 自动推导。你不需要定义接口只需要写一个函数然后前端的调用处会魔法般地出现对应的入参类型和返回值类型。一开始觉得有点反直觉实际用下来确实省掉了大量接口对齐工作。1.2 为什么 Next.js 和 Tailwind 没有重叠感T3 Stack 里没有状态管理库没有独立的 API 层框架也没有路由库。这并非遗漏而是因为 Next.js 本身就覆盖了路由、渲染和部分服务端逻辑。传统 React 项目里 redux 承担的东西在 T3 里大部分被服务端组件和 URL 参数消化掉了。Tailwind 负责的只是样式和 Next.js 完全不重叠。每个工具边界清晰学起来反而快排查问题时也能一眼定位。这个项目之所以要叫t3code就是因为它从里到外都是这一套组合的直接产物。如果你还在纠结要不要上 T3先问自己一句项目里是不是大量依赖前后端类型配合如果答案是肯定的tRPC 带来的收益会非常直观。2. 脚手架初始化create-t3-app 的选项和目录结构解读初始化 T3 Stack 项目非常无脑核心命令只有一句pnpm create t3-applatest它会交互式地询问项目名我输入t3code。接下来是一堆勾选清单这一步值得仔细想想不是全部勾选就是最好的。2.1 哪些选项值得勾选我最终勾选的是 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma。有一个选项我刻意没勾——NextAuth。原因很实际我最初只想做一个单人工具登录功能不是必须的而 NextAuth 会引入 session、数据库 account 模型等额外复杂度。如果项目后面真要开放给外部用户注册再补上不迟。脚手架支持选项对比如下选项我是否勾选理由Next.js是页面路由和服务端渲染的基础T3 的容器TypeScript是类型安全的前提不选语言层面就毫无意义Tailwind CSS是快速完成 UI不需要额外设计系统tRPC是全栈类型安全的核心API 层零重复代码Prisma是数据库建模和迁移好用类型生成非常顺手NextAuth否单人工具短期内不需要账号体系减少心智负担用pnpm而不是 npm 也是我的个人偏好依赖安装速度快硬链接省磁盘而且在 monorepo 场景下不会出现依赖提升问题。单项目用起来也完全没问题。2.2 初始化后的目录结构初始化完成后需要先理解几个关键文件的位置很多人栽在不知道代码该放哪src/app/ # 页面路由App Router 模式 src/app/api/trpc/ # tRPC 的 HTTP 入口 src/server/ # 仅服务端运行的代码 src/server/api/routers/ # 业务接口定义 src/server/api/trpc.ts # tRPC 初始化与上下文 src/server/db.ts # Prisma client 实例 src/trpc/ # 前后端共享的调用封装src/server/api/trpc.ts是最核心的文件。它做两件事创建 tRPC 实例并且定义createTRPCContext这个函数。每次请求进来时上下文会把数据库连接、session 信息塞到一个全局对象里这样每个 procedure 都能通过ctx.db直接访问数据库。这就是类型安全的第一环——数据库 client 在整个调用链中自始至终存在。2.3 我做的第一件事把 SQLite 换成 PostgreSQL脚手架默认的数据库是 SQLite本地开发完全够用但部署到线上就麻烦了——大多数 serverless 平台的文件系统是临时性的SQLite 文件无法持久化。我一开始就切换到 PostgreSQL本地用 Docker 跑一个容器docker run --name t3code-db -e POSTGRES_PASSWORDpostgres -p 5432:5432 -d postgres:16然后修改.env里的DATABASE_URL即可。这一步虽然让本地环境稍重一些但避免了本地好好的、一上线数据就丢的惨剧。3. tRPC Prisma 的端到端类型安全核心调用链完全拆解这一章是整个项目最核心的部分。用一个最简单的功能——创建代码片段并展示列表——来完整走一遍 tRPC 和 Prisma 的配合流程。3.1 先定义 Prisma 数据模型在prisma/schema.prisma中写了两个模型model CodeSnippet { id String id default(cuid()) title String description String default() code String language String default(typescript) createdAt DateTime default(now()) updatedAt DateTime updatedAt tags Tag[] } model Tag { id String id default(cuid()) name String unique snippets CodeSnippet[] }tags和snippets互为多对多关系Prisma 会自动在中间层建立一个关系表不需要手动创建。定义好后执行npx prisma migrate dev --name init npx prisma generate关键点来了npx prisma generate会基于 schema 自动生成完整的 TypeScript 类型。这意味着前端代码里如果写错了一个字段名TypeScript 会在编译阶段直接报错而不是等到运行时才发现接口返回了undefined。3.2 编写第一个 tRPC router在src/server/api/routers/snippet.ts中写接口逻辑import { z } from zod; import { createTRPCRouter, publicProcedure, protectedProcedure } from ~/server/api/trpc; export const snippetRouter createTRPCRouter({ list: publicProcedure .input(z.object({ language: z.string().optional() }).optional()) .query(({ ctx }) { return ctx.db.codeSnippet.findMany({ orderBy: { createdAt: desc }, include: { tags: true }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1, 标题不能为空).max(100), description: z.string().max(500).optional(), code: z.string().min(1, 代码内容不能为空), language: z.string().default(typescript), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.codeSnippet.create({ data: { title: input.title, description: input.description ?? , code: input.code, language: input.language, createdBy: ctx.session.user?.name ?? anonymous, tags: { connectOrCreate: input.tags?.map((name) ({ where: { name }, create: { name }, })), }, }, }); }), });这里有两个 procedure 类型publicProcedure任何人都能调用protectedProcedure会先判断ctx.session是否存在不存在就直接抛错。入参校验用的是 zod它的类型定义同时也能被 TypeScript 推断出来校验规则就是类型规则不用维护两套。connectOrCreate是 Prisma 的一个小技巧如果 tag 已经存在就连接不存在就创建。这样用户随便输入啥 tag 都不会报唯一约束错误。3.3 前端调用服务端直接调函数客户端走 hookT3 脚手架会把 tRPC 的调用封装成两个引用路径~/trpc/server和~/trpc/react。在服务端组件里调用方式类似于直接调用函数import { api } from ~/trpc/server; export default async function SnippetListPage() { const snippets await api.snippet.list(); return ( ul {snippets.map((s) ( li key{s.id}{s.title}/li ))} /ul ); }而在需要交互的客户端组件里调用方式略有不同use client; import { api } from ~/trpc/react; export function CreateSnippetForm() { const utils api.useUtils(); const createSnippet api.snippet.create.useMutation({ onSuccess: () { utils.snippet.list.invalidate(); }, }); return ( form onSubmit{(e) { e.preventDefault(); const data new FormData(e.currentTarget); createSnippet.mutate({ title: String(data.get(title)), code: String(data.get(code)), }); }} input nametitle placeholder标题 / textarea namecode placeholder代码内容 / button disabled{createSnippet.isPending} {createSnippet.isPending ? 提交中... : 创建} /button /form ); }utils.snippet.list.invalidate()是 tRPC 的缓存刷新机制。它做的事情是标记snippet.list的缓存已失效下一次读取时重新请求。不用手动去管理全局 store也不用自己写事件通知这是我最喜欢的一点——数据更新后的 UI 同步就像呼吸一样自然。3.4 改模型的连锁收益这个链路的最大价值在于当我给CodeSnippet增加一个新字段比如isPublic我只需要改 schema 和 router前端组件里立刻就能补上这个新字段的提示。整个过程中没有多余的文件需要同步修改没有 DTO 定义没有接口文档。对我来说这种体验就像是后端代码本身就是前端代码的类型说明书。4. 页面开发节奏服务端组件优先客户端组件兜底T3 Stack 搭配 Next.js App Router 后一个必须想清楚的问题是什么数据在服务端渲染什么逻辑放客户端。这个选择直接影响页面性能和代码复杂程度。4.1 列表页这种只读场景直接用服务端组件我的代码片段列表页完全不需要浏览器端 JavaScript 参与。数据请求、HTML 渲染都在服务端完成用户拿到的直接是完整的 HTML首屏速度快对 SEO 也友好。服务端组件里连接数据库、调用 tRPC 都很自然不会暴露数据库连接信息给浏览器。4.2 表单和搜索才需要 use client创建表单需要交互搜索框需要防抖这些场景必须标use client。我的习惯是默认所有组件都是服务端组件只有当组件内出现useState、useEffect、事件监听或者需要 tRPC 的 mutation hook 时才在最顶部加use client。一个页面可以有多个客户端组件它们会被单独打包、隔离不会把整个页面拖下水。4.3 代码高亮shiki 的集成细节代码片段工具肯定要展示代码高亮。我最初用的是react-syntax-highlighter后来换成了 shiki——它用的是 VS Code 系的语法解析高亮效果更精细。但接入时踩了个坑shiki 默认打包会把所有语言都带进客户端构建产物大得离谱。解决办法是只注册实际用到的语言import { createHighlighter } from shiki; const highlighter await createHighlighter({ themes: [github-dark, github-light], langs: [typescript, javascript, css, html, sql, bash, python], });这样构建时会明显缩小体积。如果用户上传了一个未注册语言的文件我会在页面里降级成纯文本展示并提示该语言暂不支持高亮。4.4 分页和骨架屏的小优化列表多了以后一次性加载全部数据肯定不行。我的处理是先用take: 20做分页前端服务端组件里接收page参数通过 URL 传参。这样翻页时可以直接用Link跳转浏览器自带前进后退。骨架屏其实不需要额外库Tailwind 的animate-pulse就够用。5. 部署上线后的三个真实故障从现象到根因的完整排查项目部署到 Vercel 之后我连着遇到三个问题每个都比较有代表性这里记录一下完整的排查链路。5.1 数据库连接超时第一次部署后页面请求时快时慢偶尔直接 504。查 Vercel 日志发现大量connection refused和timeout。排查过程如下先确认数据库地址没问题本地能够正常连接。再看 Vercel 环境变量确信DATABASE_URL已设置。最后定位到原因Vercel 函数是 serverless 架构每个请求可能启动一个新的运行实例每个实例都会尝试创建数据库连接而云数据库我用的是 Neon PostgreSQL连接数有上限当函数实例多了连接数被打满后面来的请求只能排队等超时。解决办法有两步。第一步连接串加上sslmoderequire和数据池参数DATABASE_URLpostgres://user:passhost/db?sslmoderequireconnection_limit5第二步在src/server/db.ts里调整 Prisma 客户端的连接池配置import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [error] : [], }); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;这个globalForPrisma模式非常重要在开发环境热更新时避免每次都重新创建 PrismaClient 导致连接数暴涨。生产环境下每个 serverless 实例仍然需要控制连接数所以我额外设置了connection_limit同时可以加一个pool_timeout避免无谓等待。5.2 列表页数据更新后不刷新问题特征在后台新增一条 snippet刷新页面列表里还是旧数据。一开始我以为是缓存问题清空浏览器缓存也没用。后来发现是 Next.js 的渲染层做了缓存。服务端组件里调用api.snippet.list()时框架层面有数据缓存和路由缓存。由于列表页没有任何动态操作标记Next.js 认为页面是静态的把渲染结果缓存住了。解决办法是在列表页顶部加export const dynamic force-dynamic;这会让页面每次请求都重新渲染不再读取缓存。代价是首屏性能稍微下降但对一个以内容实时性为主的工具来说这种取舍完全可以接受。如果后续需要更好的性能还可以按需用revalidate 60做 ISR不过那要配合更细粒度的缓存失效策略。5.3 表单重复提交导致重复数据还有一次我在提交按钮的 loading 状态加好之后本地测没问题但同事连点了几下按钮数据库里出现了三条一模一样的数据。根因是useMutation的isPending只控制了一个按钮的禁用但用户快速双击时第一次点击的 mutation 还没进入 pending 状态。解决方式是在onSubmit里做一层防抖同时给 service 层加幂等判断——提交时带上一个客户端生成的临时 id数据库里用这个 id 做唯一约束。这样即使网络重试也不会二次写入const createSnippet api.snippet.create.useMutation({ onSuccess: () { utils.snippet.list.invalidate(); }, }); function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (createSnippet.isPending) return; // 幂等 key const tempId crypto.randomUUID(); createSnippet.mutate({ ...input, tempId }); }Prisma schema 里给tempId加unique即可从数据库层面兜底。5.4 边缘函数的兼容性提醒如果你和我一样把整个 Next.js 应用放在 edge runtime 上跑需要注意 Prisma 官方对 edge runtime 的支持比较有限尤其是 PostgreSQL 驱动的某些原生依赖可能无法正常运作。实测下来最稳妥的方式是用 Node.js runtime 跑业务接口没必要为了追求边缘计算把数据库操作架在 edge 上。6. 最后聊几句个人心得t3code这个项目从零到上线花的时间比我预估的少很多。真正花时间的不是写代码而是反复纠结这个功能应该放服务端还是客户端以及缓存机制到底怎么和 tRPC 配合。一旦把这些底层逻辑摸清T3 Stack 的开发节奏其实很舒服——你只需要关注业务本身剩下的类型、接口、缓存联动都交给框架。有一点我强烈建议新手注意不要一上来就给自己造复杂的轮子。tRPC 的类型推导和 Prisma 的自动类型生成只有在项目结构清晰、命名一致时才能发挥最大威力。如果把前后端逻辑揉成一团再强的类型系统也救不了你。我的习惯是每个业务域一个 router每个 router 里只放和这个领域相关的 query 和 mutation宁可多写几个文件也不要在一个文件里堆几千行。如果你打算拿 T3 Stack 做自己的下一个工具项目照着上面的链路走一遍先跑通建表-写 router-页面调用-上线再逐步加入认证、分页、富文本之类的进阶功能。这套组合不会让你失望。