恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
T3 Stack全栈类型安全实践:从tRPC到Prisma的工程落地指南
首页
资讯中心
/
T3 Stack全栈类型安全实践:从tRPC到Prisma的工程落地指南
T3 Stack全栈类型安全实践:从tRPC到Prisma的工程落地指南
发布时间:2026/10/14 3:07:55
做全栈最让人崩溃的瞬间不是框架报错而是前后端明明在一个仓库里却像两个世界。我在团队里维护一个内部文档工具前端 React、后端 Express、数据库 MongoDB接口文档全靠 README 自觉维护。结果就是后端改了字段名前端没人知道线上优雅崩掉新同事入职光搞清接口字段就要翻半天代码。后来我把这个工具用 T3 技术栈重写项目代号就叫 t3code——整套都是 TypeScript从数据库到页面类型像一条管道一样通到底。这篇文章是我从初始化到上线的完整记录中途踩了不少坑也总结了一些可以抄作业的做法。如果你也在犹豫要不要上 T3 Stack或者已经初始化了项目但还没搞懂 tRPC 的类型安全到底是怎么串起来的这篇应该能给你省下不少时间。1. 为什么会有 t3code全栈类型安全不是选择题是刚需1.1 原来那套技术方案的问题出在哪老工具是前后端分离的经典结构前端 React Axios后端 Express接口层完全是人肉约定。后端一个POST /api/posts返回{ id, title, content, author, createdAt }看起来挺清楚。但过了两个月需求迭代有人给表加了status字段顺便把author重命名成authorName前端拿到的author就变成了undefined。这种问题在纯 JS 环境里根本发现不了一定要等渲染报错或者用户反馈才暴露。更要命的是联调阶段前端要对着 Postman 手工复制类型复制完了经常忘记更新接口文档也从来没人维护。一个内部工具而已却有一大半时间耗在对字段名上这显然不合理。1.2 T3 的三条核心原则T3 不是某个新框架是一套组合拳社区里普遍认可的核心就三条Typesafe by default类型安全默认开启而不是靠自觉。从数据库 schema 到后端 router再到前端 hook全链路 TypeScript 推导。Simplify能用框架和工具解决的就不自己造轮子把复杂度交给成熟方案。端到端 TypeScript前后端尽量统一语言共享类型定义不搞前端 TS、后端 Java这种割裂局面。这三条对我这种小团队内部工具来说非常对路。项目规模不大、并发不高、不需要微服务拆分核心诉求就是改完代码能马上知道有没有写错而不是把时间花在维护接口契约上。1.3 t3code 的定位t3code 重做后是团队的内部知识库带登录、发布文章、评论、标签筛选再加一个基础搜索。功能不复杂但权限和数据结构有一点要求。选 T3 技术栈的另一个原因是我们团队本来就会 TypeScript上手成本低一个人也能维护全栈代码。如果你只是做个演示项目或者学习 T3也可以把定位简化成Todo 列表 登录 标签核心链路是一样的。2. create-t3-app 初始化十分钟拉起工程但别急着写代码2.1 初始化命令和选项我用的是 create-t3-app 的交互式方式npm create t3-applatest然后项目名输入t3code接下来会弹出一些模块选项我是这样选的选项我的选择备注Next.js App Router是新版推荐服务端组件更好用TypeScript是T3 的根基必须选Tailwind CSS是内部工具不想写一堆 CSS 文件tRPC是全栈类型安全的核心Prisma是数据库 ORM 和迁移工具NextAuth.js是登录认证直接复用Docker否部署方案还没定不加复杂度顺带说一句Docker 选项如果你后面确实要用容器部署可以自己补 Dockerfile不一定非得在初始化阶段选。加了反而会多一层配置负担尤其是团队里大家本机环境不一致的时候。2.2 生成的目录结构逐层拆解初始化完成后目录大概是这样的t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ ├── auth/[...nextauth]/route.ts │ │ │ └── trpc/[trpc]/route.ts │ │ ├── layout.tsx │ │ └── page.tsx │ ├── env.js │ ├── pages/ │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ └── routers/ │ │ ├── auth.ts │ │ └── db.ts │ └── trpc/ │ ├── client.ts │ ├── react.tsx │ ├── server.ts │ └── shared.ts几个关键位置要心里有数prisma/schema.prisma唯一的数据库模型文件所有表结构在这里定义。src/server/db.ts导出一个 Prisma 客户端单例tRPC 和 NextAuth 都通过它访问数据库。src/server/auth.tsNextAuth 的配置包括 provider 和 adapter。src/trpc/tRPC 桥接层server.ts给服务端调用react.tsx给客户端 hook 用。src/app/api/trpc/[trpc]/route.tstRPC 的 HTTP 入口App Router 模式就是靠这个 route handler 收请求。src/env.js用 Zod 做环境变量校验启动时缺变量直接报错。模板代码一开始可能觉得多但别急着删先把它跑起来。默认页面里已经包含了一个getLatest的 tRPC 示例你可以直接在页面上看到服务端返回的数据这就能证明链路通着。2.3 配置数据库和 OAuth我本地用一个 Postgres 容器做开发库避免在电脑上装一堆原生服务docker run --name t3code-db \ -e POSTGRES_PASSWORDpassword \ -p 5432:5432 \ -d postgres:16然后.env文件长这样DATABASE_URLpostgresql://postgres:passwordlocalhost:5432/t3code NEXTAUTH_SECRETopenssl rand -base64 32 生成的字符串 NEXTAUTH_URLhttp://localhost:3000 GITHUB_CLIENT_ID GITHUB_CLIENT_SECRETGitHub OAuth 需要去 GitHub 开发者设置页创建一个 OAuth App回调地址填http://localhost:3000/api/auth/callback/github其他没太多坑。如果你不想用 GitHub也可以换成 Discord、Google 甚至邮箱密码NextAuth 的 provider 机制都很成熟。2.4 跑通第一条 tRPC 链路配置完直接npm run dev访问http://localhost:3000。默认模板会有一个页面展示getLatest查询的返回结果。第一次跑通这条链路意味着你已经同时搞定了Next.js 页面渲染tRPC 路由注册Prisma 数据库连接类型推导从服务端到客户端后面写业务都是在这个骨架上加肉。3. tRPC 类型安全不是魔法从 Router 到 React hooks 的单点类型源3.1 为什么 tRPC 适合这个项目而不是 REST/GraphQL很多人第一次接触 tRPC 会问这跟axios OpenAPI 生成代码有什么区别我的对比是方案类型来源额外描述文件联调成本REST OpenAPI手写类型或代码生成需要维护 YAML/JSON代码生成工具要接入构建流程GraphQLschema resolver要维护 .graphql schemaresolver 和 schema 两份逻辑tRPCTypeScript 代码本身不需要Router 就是唯一类型源用生活里的类比REST 像是两个人各自记账月底对账总有人记错tRPC 是一个共享记账本谁写了大家立刻看得到。减少一个中间描述层就减少一类维护成本。3.2 Router 和 procedure 是骨肉Zod 是守卫t3code 里的文章模块我写了一个postRouterimport { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure, } from ~/trpc/trpc; export const postRouter createTRPCRouter({ getLatest: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ take: 10, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), }) ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });这里有几个点要理解publicProcedure表示所有人都能调。protectedProcedure会先检查ctx.session没登录直接抛 UNAUTHORIZED。.input()里的 Zod schema 不只是运行时校验它同时定义了前端调用时的入参类型。ctx里已经封装好了db和session不需要每个 handler 自己取。写第二个业务模块的时候你会发现完全复制这套套路就能跑这是一件好事——一致性强团队其他人接手也容易。3.3 客户端 hook 的类型如何自动推导前端组件里就这样用use client; import { api } from ~/trpc/react; export function PostList() { const { data, isLoading } api.post.getLatest.useQuery(); if (isLoading) return p加载中.../p; return ( ul {data?.map((post) ( li key{post.id}{post.title}/li ))} /ul ); }api.post.getLatest.useQuery()里post对应postRouter的命名空间getLatest是 procedure 名data的类型就是后端findMany返回的类型。整个过程没有代码生成步骤没有临时.d.ts文件纯靠 TypeScript 的 typeof 推导和泛型传递。后端加一个字段前端data的类型立刻多一个字段后端删一个字段前端编译直接报红。这比等着跑起来发现undefined强太多了。3.4 对比手写 fetch 的老做法以前写前端请求// types.ts手写 export interface Post { id: string; title: string; content: string; } // api.ts手写请求函数 export async function getPosts() { return axios.getPost[](/api/posts).then((res) res.data); }现在const { data } api.post.getLatest.useQuery();后者的关键优势是类型永远跟实际实现同步。你不需要记得去更新类型因为类型就是从实现代码里推导出来的。3.5 服务端组件里也能直接调 tRPCt3code 用的是 Next.js App Router很多页面组件是服务端组件没有use client。在服务端组件里调用 tRPC不需要走 HTTPimport { api } from ~/trpc/server; export default async function Dashboard() { const posts await api.post.getLatest(); return PostView posts{posts} /; }~/trpc/server是 create-t3-app 生成的服务端调用器它直接调用 router 函数等于省掉一次网络往返。这个设计对内部工具这种服务端渲染场景特别友好数据拿到手再渲染首屏内容完整配合客户端 hooks 做交互体验比较顺。4. 数据模型与会话认证Prisma 建模和 NextAuth.js 在 t3code 里的分工4.1 Prisma schema 的实际设计我的schema.prisma里除了模板自带的 User、Account、Session 模型还加了业务模型model User { id String id default(cuid()) name String? email String? unique role String default(member) posts Post[] comments Comment[] accounts Account[] sessions Session[] } model Post { id String id default(cuid()) title String content String visibility String default(team) createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id]) comments Comment[] tags TagOnPost[] } model Comment { id String id default(cuid()) content String createdAt DateTime default(now()) authorId String postId String author User relation(fields: [authorId], references: [id]) post Post relation(fields: [postId], references: [id]) } model Tag { id String id default(cuid()) name String unique posts TagOnPost[] } model TagOnPost { postId String tagId String post Post relation(fields: [postId], references: [id]) tag Tag relation(fields: [tagId], references: [id]) id([postId, tagId]) }User 表里没写password因为登录走 GitHub OAuth不需要自己管密码。NextAuth 的 Account 和 Session 表由 adapter 自动管理放在同一个 schema 里即可不用手写。4.2 迁移和数据初始化写好 schema 后执行npx prisma migrate dev --name init这个命令会生成 migration 文件同时把表建到本地数据库。再配置一下 seednpm install -D tsx然后在package.json里加prisma: { seed: tsx prisma/seed.ts }seed 脚本里写几条初始 tag 和一个测试用户方便本地开发不用每次手工注册。4.3 认证接入全流程src/server/auth.ts的配置import NextAuth from next-auth; import GitHub from next-auth/providers/github; import { PrismaAdapter } from auth/prisma-adapter; import { db } from ~/server/db; export const authOptions { adapter: PrismaAdapter(db), providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], session: { strategy: database }, callbacks: { session({ session, user }) { if (session.user) { session.user.id user.id; } return session; }, }, }; export const { handlers, auth, signIn, signOut } NextAuth(authOptions);这里有个选择要说一下session 策略用database而不是jwt。为什么内部工具希望管理员能踢人下线数据库会话可以随时删 session 生效。如果只是普通 SaaS 公网应用JWT 策略性能更好但这里我优先考虑可控性。4.4 页面和接口的双层保护页面层是在服务端组件里判断import { auth } from ~/server/auth; import { redirect } from next/navigation; export default async function DashboardPage() { const session await auth(); if (!session) { redirect(/api/auth/signin); } return Dashboard /; }接口层就是 tRPC 的protectedProcedure两层都加别只堵一边。有人可能觉得页面已经挡了接口没必要但如果以后加了个 API route 或者第三方回调少了接口层保护就漏了。5. 落地过程踩过的坑类型不同步、环境变量、缓存与样式5.1 tRPC 类型改了前端不更新的问题这个坑遇到过不止一次。现象后端 router 的 query 返回类型改了前端useQuery()拿到的数据类型还是旧的甚至新增的 procedure 在api对象上完全找不到。排查链路一开始我以为是热更新问题重启 dev server 没用然后怀疑是 tRPC 版本缓存去看node_modules/.cache也没头绪最后把.next目录删掉重新npm run dev才恢复。根因App Router 架构下tRPC 的类型推导发生在编译阶段而 dev server 对src/trpc/下文件的监听有时候不够灵敏改动 router 后没有触发完整的类型重编译。尤其当你一次改了多个文件时TypeScript 的增量编译缓存可能会留在旧状态。现在的固定操作改完 router 先看终端有没有重新编译的日志如果前端类型没变不急着怀疑代码先重启npm run dev还不行再删.next。别浪费时间琢磨。顺带提一句如果你用了 VS Code 的 TS Server遇到类型更新滞后可以手动执行TypeScript: Restart TS Server有时候就是编辑器缓存的问题根本不用删项目。5.2 NEXTAUTH_SECRET 和回调地址的坑开发环境 NextAuth 没配置 secret 也能跑因为 dev server 会自动生成一个临时 secret。但生产环境不配置会出现每次重启都重新登录或者直接启动报错。正确做法openssl rand -base64 32把输出粘到.env的NEXTAUTH_SECRET。还有个更隐蔽的坑回调地址必须跟实际访问域名一致。本地调试用http://localhost:3000部署后变成了https://t3code.vercel.appGitHub OAuth App 里如果只填了 localhost线上登录就会报redirect_uri不匹配。把两个回调地址都加进 GitHub 设置或者按环境切换。5.3 Prisma 迁移跨环境的问题开发环境用prisma migrate dev很顺手它会自动生成 migration 文件并同步数据库。但同样的命令千万不能直接在生产环境跑。migrate dev会尝试重置数据或者执行一些交互逻辑线上跑很容易出事故。生产环境或者 CI 里应该用npx prisma migrate deploy这个命令只按顺序执行 migration 文件不生成新文件、不重置数据。部署流程里还别忘了先prisma generate不然 Prisma Client 没生成构建会报错。我的实际做法是构建阶段在构建命令里加上prisma generate数据库迁移则在发布前手动执行一次migrate deploy避免多个实例并发执行迁移。5.4 Tailwind 样式动态拼接的坑新版 create-t3-app 用的是 Tailwind v4CSS 入口是import tailwindcss。样式扫描默认覆盖src/**/*.tsx所以新建目录一般不用管。真正的问题是动态拼接 class 名比如const color red; div className{text-${color}-500}内容/divTailwind 运行时扫描的是源码字符串不是运行时值text-red-500这种完整字符串它找得到但拼出来的text-${color}-500它扫不到样式就丢了。稳妥做法是用映射表const colorClasses: Recordstring, string { red: text-red-500, blue: text-blue-500, };class 名写成完整字符串Tailwind 才能正确扫描。6. 从本地跑到线上t3code 部署到 Vercel 的实践记录6.1 构建前要处理三件事第一是保证npm run build能过。t3code 这种依赖 session 的页面如果直接静态渲染会拿不到 session构建或者运行时可能报错。给相关页面加上export const dynamic force-dynamic;第二是确认 Prisma Client 已经生成。Vercel 构建环境不会自动跑prisma generate需要在构建命令里手动加。我的 package.json 脚本大概是build: prisma generate next build第三是环境变量全部在 Vercel 项目设置里配好本地.env不要提交到仓库。6.2 环境变量清单需要配置的变量如下变量名用途示例DATABASE_URL生产数据库连接字符串postgresql://user:passhost:5432/dbNEXTAUTH_SECRET会话签名密钥随机生成的长字符串NEXTAUTH_URL外部访问地址https://t3code.vercel.appGITHUB_CLIENT_IDGitHub OAuth App IDIv1.xxxxxGITHUB_CLIENT_SECRETGitHub OAuth Secret从 GitHub 后台复制.env.example可以提交到仓库作为参考但.env和真实密钥必须忽略。6.3 数据库迁移的上线操作我的发布顺序是先把代码推送到仓库。本地或 CI 里执行一次生产环境prisma migrate deploy。确认 migration 全部应用成功再触发 Vercel 构建。如果 Vercel 构建成功但页面报数据库错误多半是 DATABASE_URL 指向不对或者数据库的访问 IP 没放开。还有一个 serverless 环境要注意的Prisma Client 在 serverless 冷启动时可能会创建很多数据库连接。如果你的数据库是托管服务且连接数限额很低建议把 Prisma 的连接池控制一下或者在 Vercel 上用连接池代理。这个要看具体数据库厂商不展开说了但遇到连接数报错时优先查这里。6.4 上线后观察的几个指标部署完成后我主要关注首屏加载耗时内部工具不需要极致性能但如果 TTFB 超过两三秒就去查是不是冷启动时间太长或者有没有不必要的服务端重计算。图片加载Next.js 的next/image会自动优化图片但如果引用了外部图片源需要配置remotePatterns不然线上图全裂。日志报错Vercel 自带日志面板重点看 tRPC 的 UNAUTHORIZED 错误和数据库连接错误。UNAUTHORIZED 频繁出现通常是某个页面的 session 判断没做好。6.5 后续迭代的代码组织建议t3code 上线后我做了几次小迭代最有用的一条建议是按业务模块拆 router不要把所有 procedure 塞进一个文件。比如postRouter、commentRouter、tagRouter各放一个文件然后在src/server/api/root.ts里合并import { postRouter } from ~/server/api/routers/post; import { commentRouter } from ~/server/api/routers/comment; import { tagRouter } from ~/server/api/routers/tag; export const appRouter createTRPCRouter({ post: postRouter, comment: commentRouter, tag: tagRouter, });前端使用命名空间访问逻辑清晰类型也不会有负担。另一个建议是Zod schema 尽量抽取公共文件。比如创建文章的表单校验和 tRPC 的 input 校验用的是同一个 schema前端表单能直接复用类型彻底避免前端校验一套、后端校验一套的重复维护。最后说一下依赖升级。Next.js、React、Prisma、NextAuth 这几个包之间耦合比较紧升级时不要图快一次性全升。我踩过一次把 Next.js 从 14 升到 15 后tRPC 的 server caller 和 App Router 的类型推断出现一堆兼容问题最后花了大半个下午才收拾干净。小步升级每升一个就npm run build一次比集中排查省心太多。如果你也准备搭一个团队内部工具或者全栈学习项目照着 t3code 这套链路走不会太偏初始化模板、数据库建模、认证接入、写几个 tRPC 业务模块、部署上线每个环节都有现成答案。真正需要你花时间的是理解每条链路上的类型是怎么串起来的——搞懂了那个T3 Stack 就算真正上手了。