资讯动态

T3 Stack 实战:从 Prisma 到 tRPC 的类型安全全栈开发指南

发布时间:2026/10/26 8:25:52 来源:尧图企业网站定制
t3code这个项目名懂行的人一眼就能猜到路子T3 Stack。去年我给自己小组搭内部样板仓库时取了这个代号本意是验证“TypeScript 到底能不能从前端一路打到数据库”。结果这套组合拳打下来把团队前后端联调的时间砍掉了至少三分之一很多以前要靠文档和口水战才能对齐的接口契约现在全被编译器管住了。这篇文章就把我在 t3code 上从初始化、建模、写业务到部署的完整实操过程和踩坑记录摊开聊看完你应该能直接照着复刻一套属于自己的类型安全全栈基建。1. 为什么拿t3code当模板T3 Stack的全栈选型逻辑1.1 “端到端类型安全”到底解决了什么先聊个很多团队都有的痛接口文档和代码不同步。REST 模式下前端要调一个列表接口先得看文档、找参数名、抄类型定义再手动写一遍interface。后端改了字段名前端类型定义仍然是旧的不是运行时炸就是编译期长期不报错、上线后生产环境开始疯狂报 undefined。t3code 这套 T3 Stack 最主要的价值就是把“契约”这件事交给计算机去保证。T3 Stack 的核心玩法是端到端类型安全从数据库模型Prisma Schema到 API 层tRPC再到前端组件里调用的函数全部共享同一份 TypeScript 类型。tRPC 的机制很简单它不生成协议也不搞 SDL而是直接把你后端定义好的 router 对象导出类型前端用这个类型做createTRPCProxy调用api.post.list.query()的时候IDE 能直接提示入参和返回值。这就像你点外卖的时候菜单不是一张纸质照片而是全电子化的数据源后厨改了菜名你手机上的菜单同步变永远不存在“下单时还看到韭菜炒蛋后厨已经换成蛋炒韭菜”的错位。实际跑起来之后体感特别明显。以前写列表页我要么去看后端的 DTO要么自己猜字段现在trpc.post.list.query()一敲返回的Post[]类型直接来自 Prisma 模型前端想写错都难。t3code 仓库里的代码量并不多但没有一行是“为了补类型而补”的每一处类型都能追溯回数据库字段这就是样板间该有的状态。1.2 Next.js Tailwind Prisma tRPC 的职责划分T3 Stack 不是一把梭塞一堆库而是四件套各管一摊分工极其清楚。放一张表格感受一下技术职责类比Next.js应用框架、路由、SSR/SSG、前后端同仓托管房子的承重墙和管线TypeScript全链路静态类型约束施工图纸PrismaORM、数据库建模、迁移装修时的水电预埋方案tRPC前后端 API 调用协议房子里对讲电话Tailwind CSS原子化样式方案涂料和软装模块选这套组合前我其实也纠结过要不要用 GraphQL。GraphQL 的 schema 定义能力很强但要引入 codegen、客户端缓存、schema 漂移管理等一大堆配套而 T3 的核心逻辑是“既然前后端都是 TypeScript为什么不直接把类型当协议来用”。尤其是一个团队内部、业务闭环程度高的中小型项目tRPC 这种零代码生成、运行时零依赖的方式比 GraphQL 轻太多。当然也要说清楚T3 Stack 不是银弹。如果你的项目需要对外提供 OpenAPI 给第三方或者有多个非 TypeScript 的客户端比如 iOS/Android 原生tRPC 就不好用了这种场景还是得老老实实上 REST 文档。T3 最舒服的战场是全栈 TypeScript 团队、内部业务系统、快速迭代的 Web 应用。t3code 定位就是解决这一类问题。2. 搭好t3code的地基初始化与工程配置2.1 一条命令拉出完整工程骨架T3 Stack 官方提供了create-t3-app脚手架这一下解决了 90% 的初始配置工作。我在创建 t3code 的时候直接跑了npm create t3-applatest t3code脚手架会交互式问你选哪些模块。我从实际使用角度给一份建议勾选清单模块是否启用原因Next.js必选整个项目的宿主Tailwind CSS建议启用原子类写 UI 效率高和后端类型解耦tRPC必选类型安全的核心链路Prisma必选数据模型就是类型源头NextAuth.js按需需要账号体系就开不需要就关Drizzle备选与 Prisma 二选一当初最让我意外的是脚手架把 ESLint、Prettier、tsconfig 的 strict 全开好了省掉了大量讨价还价的精力。项目生成后的目录结构大概是这样的t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ └── root.ts │ │ ├── db.ts │ │ └── auth.ts │ ├── trpc/ │ │ ├── server.ts │ │ ├── client.ts │ │ └── react.tsx │ └── app/ │ ├── layout.tsx │ └── page.tsx └── package.json一个容易忽略的点脚手架默认用/作为 src 别名最终编译时会映射到./src/*。如果你后续要加自定义路径别名千万别忘了去tsconfig.json和next.config.js两处都改否则 TS 检查和 Next.js 打包器会存在路径解析不一致的诡异问题。我在 t3code 早期调试一个页面时遇到过明明tsc通过了但 Next.js 运行时报模块找不到排查半天就是路径别名只改了一半。2.2 数据库模型设计从 Prisma Schema 到真正的表t3code 里我搭的是一个偏内容管理的业务场景拿“文章 标签”来当示例最直观。prisma/schema.prisma里定义两个模型generator client { provider prisma-client-js } datasource db { provider sqlite url env(DATABASE_URL) } model Post { id String id default(cuid()) title String content String published Boolean default(false) tags Tag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique posts Post[] }这里有个我在实际项目里踩过然后想明白的坑主键的类型选择。很多人会直接用自增数字Int id default(autoincrement())看着简单但一旦业务涉及多个环境的数据合并、离线写入或备份恢复自增 ID 很容易发生冲突。t3code 里我统一用cuid()它既保证分布式环境下的唯一性又不像 UUID 那样把 URL 路径拉得老长字符串索引在大多数数据库里性能也够用。定义好 schema 后跑迁移npx prisma migrate dev --name init这条命令会做三件事在prisma/migrations下生成一份带时间戳的 SQL 迁移文件、把这个迁移应用到数据库、然后自动重新生成 Prisma Client。迁移文件长什么样值得翻开看一眼。以 Post 表为例T3 生成的就是标准的CREATE TABLESQL这意味着你在纳管数据库变更历史不管到第几次迭代只要有迁移文件就能把任意环境的库结构拉到最新。很多新手会图省事直接npx prisma db push同步结构完全不生成迁移文件这在原型阶段可以接受一旦上了生产历史结构就变成一团黑盒后续想审计字段变更连查的地方都没有。2.3 环境变量与基础服务启动T3 脚手架会生成一个.env.example你得复制成.env并把DATABASE_URL配好。SQLite 场景下是这样DATABASE_URLfile:./db.sqlite如果是 PostgreSQL比如部署到线上用 Neon 或 Vercel Postgres则类似DATABASE_URLpostgresql://user:passwordhost:5432/t3code环境变量这块一定要养成的习惯是敏感信息一律只放进.env不要提交进 Git。T3 默认在.gitignore里把.env排除了但从别的模板拷项目时要反复确认我见过不止一个团队把生产数据库密码提交进 GitHub 私有仓库最后泄露成安全事故。配置好之后本地起服务npm run dev访问localhost:3000如果能看到脚手架默认页面说明 t3code 的骨架已经能转了。这时候整个链路还没有业务代码但 Prisma Client 已能连上数据库tRPC 的健康检查路由也处于监听状态后面加业务就都是顺水推船。3. 打通业务链路路由、数据库、tRPC 三合一3.1 用 App Router 组织页面服务端组件直接吃数据库Next.js 从 13 开始用 App Routert3code 完全围绕新架构来组织页面。核心思路是能用 Server Component 的地方绝不上 Client Component数据拿取尽量坐在服务端完成。一个典型的文章列表页长这样// src/app/page.tsx import { api } from ~/trpc/server; export default async function HomePage() { const posts await api.post.list.query(); return ( main classNamecontainer mx-auto py-10 h1 classNametext-2xl font-boldt3code 文章列表/h1 ul classNamemt-6 space-y-4 {posts.map((post) ( li key{post.id} classNamerounded border p-4 h2 classNametext-lg font-semibold{post.title}/h2 p classNametext-sm text-gray-500{post.createdAt.toLocaleDateString()}/p /li ))} /ul /main ); }关键一步是~/trpc/server这个封装。它是 tRPC 提供的一个服务端调用器让你在 Server Component 里就像在写后端函数一样调用查询。这里我特别想分享一个体会很多人一开始会被“前端”和“后端”的思维定式困住总想着 fetch 一个 API再处理 loading 状态再 setState。但在 App Router tRPC 的模式下你根本不需要这些仪式感。但这并不代表客户端组件没有用武之地。只要页面里有交互、有状态、有用到浏览器 API就必须加use client标记。t3code 里的规则很简单展示用服务端交互用客户端。比如“新建文章表单项”它的职责是收集输入和提交所以它是一个典型的 Client Component。从 Server Component 可以直接把客户端组件当作普通标签引入数据通过 props 传入即可。我见过很多后续接手 T3 项目的人在这里犯糊涂把包含useState的组件不加use client结果启动直接报错。还有反过来全页面都标成 client服务端渲染的优势全丢了等于把 Next.js 当成了纯 SPA 框架。边界问题没有银弹唯一的判断标准就是这个组件是否需要useState/useEffect/onClick这一类的浏览器交互能力。3.2 用 tRPC 写一套类型安全的 API少写几百行接口代码tRPC 的核心是 router相当于你后端的 API 总控制器。t3code 里我把业务按模块拆分再合到根 router 上。以文章模块为例src/server/api/routers/post.ts大概是这样的import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/trpc/trpc; export const postRouter createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ include: { tags: true }, orderBy: { createdAt: desc }, }); }), byId: publicProcedure .input(z.object({ id: z.string().min(1) })) .query(async ({ ctx, input }) { return ctx.db.post.findUnique({ where: { id: input.id }, include: { tags: true }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(3, 标题至少 3 个字), content: z.string().min(10), tagNames: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, tags: { connectOrCreate: input.tagNames.map((name) ({ where: { name }, create: { name }, })), }, }, }); }), delete: publicProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) { return ctx.db.post.delete({ where: { id: input.id } }); }), });publicProcedure.query就是 GET 请求mutation就是 POST/PATCH/DELETE 这些写操作。入参校验用 zod 在服务端入口就把脏数据拦下来入参类型自动从 zod schema 推导为 TypeScript 类型这又省了一层“手写 DTO 手写校验”的重复劳动。在浏览器端调用时先建立 tRPC React Client// src/trpc/react.tsx import { createTRPCReact } from trpc/react-query; import type { AppRouter } from ~/server/api/root; export const api createTRPCReactAppRouter();然后在任意 Client Component 里直接使用use client; import { api } from ~/trpc/react; export function CreatePostForm() { const utils api.useUtils(); const createPost api.post.create.useMutation({ onSuccess: () { utils.post.list.invalidate(); }, }); return ( form onSubmit{(e) { e.preventDefault(); const form new FormData(e.currentTarget); createPost.mutate({ title: String(form.get(title)), content: String(form.get(content)), tagNames: [], }); }} input nametitle placeholder标题 required / textarea namecontent placeholder正文 required / button typesubmit发布/button /form ); }这里最值得称道的细节是utils.post.list.invalidate()。它做的事是让 React Query 知道“列表数据已经过期了”自动重新拉取最新列表。放在传统 REST 时代你得手动在onSuccess里调用fetchList()再 setState或者在全局事件总线上广播一下非常容易漏。tRPC React Query 的模式里数据层的东西被抽象成一套自带状态机的缓存系统业务代码只关心“我提交了什么”“成功后要刷新谁”大量半技术半胶水的代码直接消失。3.3 在服务端组件和客户端组件之间传数据这块是我建议每个做 T3 项目的人单独花十分钟想清楚的环节。App Router 的模型下数据传递分两种方向第一服务端组件把查询结果通过 props 传给客户端组件。这很直观比如你已经在服务端拿到了posts然后PostList posts{posts} /渲染成列表并支持客户端交互props 直接传就行。第二客户端组件需要触发数据变更比如删除按钮。这个时候不是“传数据下去”而是客户端组件自己通过api.post.delete.useMutation发请求不需要父组件帮忙。有一个细节容易踩坑Server Component 传给 Client Component 的数据必须是可序列化的。意思是它不能包含函数、Date 实例这些特殊对象虽然 Next.js 对 Date 做了特殊支持但最好不要依赖。你从 Prisma 查出来的字段都是字符串、数字、布尔和普通对象够用了。如果你发现自己把一个包含了Decimal或自定义类实例的对象往下传赶紧在服务端先 map 成普通结构否则序列化时会直接报错。从整体看整个 t3code 的业务流就是数据库表定义在 Prisma查询逻辑写在 router页面渲染在 Server Component交互逻辑在 Client Component 里通过 tRPC 完成。链路短、边界明确类型从数据库一路贯穿到onSubmit里的那行createPost.mutate()这种感觉一旦体验过会非常上瘾。4. t3code实战中一定会遇到的坑与排查技巧4.1 Prisma Client 提示类型过时或找不到这个东西几乎每个用过 Prisma 的人都会撞上。场景通常是改了schema.prisma之后npx prisma migrate dev明明执行成功了但一写代码类型提示还是旧的或者是 IDE 红波浪线提示findMany不存在。这大概率是 Prisma Client 没有重新生成或者你的开发服务器还在用旧的内存缓存。解决流程按顺序来# 1. 确认 schema 语法没问题 npx prisma validate # 2. 重新生成客户端 npx prisma generate # 3. 重启 Next.js dev server npm run dev因为 Next.js 在 dev 模式下会对模块做缓存prisma generate生成的.prisma/client可能没有被热替换识别到。有时候还需要单独Ctrl C停掉 dev server 再启动才能让新的类型被正确加载。还有一个隐蔽场景如果你在package.json的 postinstall 脚本里写死了某个 Prisma 版本而本地全局 npx 缓存里是另一个版本生成的 Client 会对不上。解决方法是给prisma命令加latest或者锁定版本后重新npm install。4.2 服务端组件与客户端组件边界导致的水合错误Next.js App Router 在水合hydration阶段最经典的报错就是Text content does not match server-rendered HTML.t3code 开发后期遇到最多的是时间格式化问题。服务端渲染时用的是 Node.js 环境的时区客户端经水合后用的浏览器本地时区如果页面直接输出post.createdAt.toLocaleString()两端结果不一致就报水合错误。我的处理方式是第一如果时间显示不重要就在服务端统一格式化为 ISO 字符串再传给客户端。第二如果你确实需要在客户端按用户时区显示就把组件标记为客户端渲染同时用mounted状态延迟渲染首帧。还有一个非常隐蔽的坑把 localStorage 或 window 访问放在组件顶层。Server Component 在服务端根本没有window如果代码在模块顶层执行window.localStorage.getItem整个构建都会崩。正确做法是放到useEffect里或者加一个typeof window ! undefined的判断。这类错误看起来是“小问题”但排查起来特别耗时间。我的经验是先在页面里二分注释代码把一半组件注释掉如果不再报错说明问题在下半部分再用同样的思路继续缩小范围。半小时内一定可以定位到具体组件。4.3 部署上线时必看的环境变量、数据库与构建配置清单t3code 的部署我首选 Vercel因为它跟 Next.js 的适配可以说是无痛的。但有几个点不提前 check 的话上去就是 500。第一环境变量要全。.env里的DATABASE_URL、NEXTAUTH_SECRET如果开了鉴权都要在 Vercel 项目的 Environment Variables 面板重新填一遍。本地.env不会自动同步到云端很多团队本地跑得飞起一部署就连不上库99% 是环境变量漏了。第二数据库位置。Vercel 本身的 serverless 函数是无状态的连传统的常驻数据库连接池不合适。我建议用 NeonServerless PostgreSQL或者 PlanetScale 的连接池模式连接字符串里带?pgbouncertrueconnection_limit1这类参数。原因是 Serverless 环境会并发启动多个 lambda 实例每个都开自己的数据库连接池瞬间就能把数据库连接数打满。用 pgbouncer 类型的连接池之后短连接被复用连接数压力就小很多。第三构建命令检查。create-t3-app默认 build 脚本是next build但如果你在代码里用了环境变量来条件编译某些逻辑Vercel 构建时这些变量也得存在否则构建期就会因为 undefined 报错。我建议在本地先完整跑一次npm run build确认没问题再 push。4.4 常见问题速查表症状原因处理方式tRPC 接口调通但前端类型报错修改 router 后没重启 dev server重启npm run dev让类型重新生成Prisma 查询方法不存在如findMany报错Client 与 schema 版本不同步npx prisma generate后重启项目Browser 里调用window报错组件被当作 Server Component 渲染加use client或移到useEffect内Vercel 部署后 API 全部超时serverless 连接池配置不匹配换 Neon/PgBouncer 或调connection_limit页面水合报错服务端/客户端渲染结果不一致统一时间/随机数输出或延迟客户端渲染env变量 undefined.env与部署环境的变量不一致检查 Vercel 环境变量面板最后分享一点个人折腾 t3code 的体会t3code 这个样板仓库前前后后攒了小半年最大的收获倒不是某个具体库用得多熟而是重新理解了“工具链的一致性”这件事。TypeScript 本身不是银弹但如果从数据库到 API 到 UI 全部走在同一条类型链路上编译器就能帮你挡掉一大批低级错误你把省下来的精力放到业务模型和交互设计上这才是 T3 Stack 真正的价值。想提醒一句的是这套方案适合从零开始的新项目或者你准备把老项目的接口层整体翻新一遍再去接。如果是一个已经有几百个 REST 接口的系统想逐步迁到 tRPC成本会大不少建议先切一个小模块验证再决定要不要全量推进。另外如果你的项目马上要对外开放 API 或有非 Web 客户端T3 这套“端到端类型安全”的优势会受到削弱到时候可能还是 REST 或 GraphQL 更合适。对我个人来说t3code 不只是一个代码仓库更像是一套团队协作的“插件”。新成员进来照着仓库里的页面和 router 抄一抄半天就能写出符合规范的模块。而每次看 IDE 里那些从一个 Prisma 字段延伸出来的类型提示心里都会默默觉得这坑值得踩。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑