2026/10/28 23:22:10

T3 Stack实战:从tRPC到Prisma构建类型安全全栈应用

T3 Stack实战:从tRPC到Prisma构建类型安全全栈应用 1. 先说清楚t3code 到底是什么我最近一直在折腾全栈项目绕不开一个词t3code。如果你也混迹前端技术社区应该对T3 Stack有印象——这套技术栈最近两年火得不行GitHub 上的讨论度、招聘 JD 里出现的频率都很高。而 t3code 这个词其实就是社区里对 T3 Stack 相关实践代码、脚手架产物、以及围绕这套技术栈写出来的应用代码的统称。大家平时说我去看下 t3code潜台词就是我去参考一下 T3 体系下的最佳实践怎么写。简单来说T3 Stack的核心是 TypeScript First——从数据库到 API 层到前端组件全链路用 TypeScript 串起来把类型安全做到极致。它通常包含五件套Next.js全栈 React 框架、Tailwind CSS原子化 CSS 框架、tRPC端到端类型安全的 API 层、PrismaTypeScript 原生的 ORM、NextAuth.js认证方案再加上Zod运行时数据校验。它解决的核心痛点是传统全栈项目里前端和后端各写各的类型定义API 联调靠口头约定 文档 跑 HTTP 看返回改一个字段要前后端同时改两遍改漏了就等着线上报错。T3 Stack 用一套 TypeScript 类型把前后端全部打通你在前端调用后端的函数就像调用本地函数一样参数和返回值的类型都自动推导写错了直接编译报错根本不给运行时出问题的机会。这篇东西适合谁想找一个现成的全栈技术选型模板的中高级前端想了解 tRPC 到底解决了什么问题的后端同学以及准备从零搭一个全栈项目、但不想把时间都耗在胶水代码上的独立开发者。我尽量把这些概念拆开揉碎讲清楚每个环节的来龙去脉和避坑点而不是甩你一份 README 让你自己悟。2. 技术栈拆解为什么偏偏是这五个家伙2.1 Next.js 是骨架但不是普通的页面框架很多人对 Next.js 的印象停留在React 的服务端渲染框架实际上它是一个全栈应用框架。T3 选它核心原因是App Router 模式下API Route 和页面组件可以共用一套 TypeScript 类型和模块体系。你在app/api/xxx/route.ts里写一个接口跟在前端组件里调用它理论上共享同一个环境和同一套类型导入路径。配合 tRPC 之后你甚至不需要显式写 HTTP 路由——tRPC 会把 RPC 请求打到 Next.js 暴露的单一 API 端点通常挂在app/api/trpc/[trpc]/route.ts业务逻辑全都收拢到 router 里页面组件不关心请求怎么传只关心调用什么函数、拿到什么类型。实际体验下来App Router 的Server Components 和 Client Components 的边界是最需要花时间理解的地方。我刚开始写的时候就踩了坑默认所有组件都是 Server Component里面不能直接用useState要手动加use client指令。但 T3 项目里 tRPC 的客户端调用本身依赖 React Context所以涉及数据交互的组件基本都得标记成客户端组件。这个动作本身不复杂但会影响你整个目录组件拆分的方式。2.2 tRPC 是灵魂端到端类型安全的魔法tRPC 是整个 T3 体系最容易被低估的一环。它的原理不复杂后端定义一组 router 和 procedure前端通过类型推导获得这组 router 的类型签名然后用类似trpc.post.list.query()这样的调用方式触发请求。举个贴近生活的类比你点外卖的时候App 里的菜单和商家的实际库存如果各记各的那你经常遇到下单时还有、支付后没货的尴尬。tRPC 等于把菜单和库存数据库看成同一套系统你在客户端能看到的菜品一定是服务端确认能出餐的。类型安全到了 HTTP 边界依然生效而且请求和响应都要过 Zod 校验跑到运行时也有保障。tRPC 的效率也很高。它默认走的 HTTP 协议而不是 WebSocket所以部署和常规 REST 接口没有任何区别CDN、负载均衡、日志中间件全部照旧。但它省掉了一整层 RESTful API 设计——不需要给每个资源设计 GET/POST/PUT/DELETE 路由不需要在接口文档里维护请求和响应结构因为代码本身就是文档。2.3 Prisma 是数据层的定海神针Prisma 在 T3 生态里承担数据库访问层的角色。你要是写过几年后端一定懂那些年被 MyBatis XML 映射文件支配的恐惧Prisma 的 schema 文件会自动同步生成类型安全的查询客户端写错字段名编译都过不去。T3 项目通常用 SQLite 起步方便本地调试部署前可切换到 PostgreSQL 或 MySQL。我在本地开发时一直用 SQLite后来上生产切到 PostgreSQL只改了DATABASE_URL和 provider然后执行一次prisma migrate deploy迁移脚本全部复用几乎没有额外成本。当然SQLite 和 PostgreSQL 在部分类型上有一点差异比如DateTime的行为但只要不在 schema 里写太数据库特有的类型切换基本无感。Prisma 的查询 API 设计我个人非常喜欢。写嵌套查询的时候include语法非常直观const userWithPosts await prisma.user.findUnique({ where: { id: userId }, include: { posts: { orderBy: { createdAt: desc }, take: 10 } } });这和手写 SQL JOIN 相比可读性和修改成本都低了一大截。2.4 NextAuth.js 是认证层的老熟人认证是每个业务系统绕不开的环节。NextAuth.js 配合 T3 的推荐用法最爽的是它把 登录态 直接挂到 tRPC 的 Context 上。你在 tRPC 的 createContext 里读 session然后在 procedure 里校验登录状态整个过程类型安全且不打断业务逻辑的线性感。T3 模板默认集成的是Discord Provider因为作者 Theo 自己用 Discord 社区但实际要接入 GitHub、Google 或邮箱验证码登录只是换 provider 配置的事。我最喜欢它的getServerSession函数——在服务端拿 session 不会产生额外请求也不依赖客户端状态特别适合做权限型页面的服务端渲染判断。有一点必须提醒NextAuth v4 和 v5 的配置方式差异很大v5 里变成了auth.ts统一入口很多旧教程已经不适用。如果你用最新模板初始化项目大概率拿到的是 v5 版本的生态注意不要照抄 v4 的代码。2.5 Tailwind CSS 和 Zod一个管样式一个管安全Tailwind CSS 不用多说T3 选它一方面是因为开发效率另一方面是它和组件库的解耦设计让 T3 模板保持轻量。Zod 则是 tRPC 的黄金搭档——tRPC 的输入校验默认依赖 Zod你在 procedure 里定义input: z.object(...)时这个 Schema 会被自动推导成 TypeScript 类型贯穿前后端。举个例子如果你定义了一个创建文章的 input schemacreatePost: t.procedure .input(z.object({ title: z.string().min(1).max(100), content: z.string().min(10), })) .mutation(async ({ ctx, input }) { // 这里的 input 类型已经被 Zod 推导了 })前端拿到这个过程函数类型签名里就包含title和content的结构和约束。如果你传进去一个number类型的 title在编辑器里立刻标红不需要等到发请求。3. 完整实操从零初始化一个 t3code 项目3.1 项目初始化建议直接用 T3 官方脚手架create-t3-app。环境要求是 Node.js 18这点卡得很死版本太低会直接报错。npx create-t3-applatest my-t3-app交互过程中它会问你选装哪些模块。我自己的建议初次尝试的话全选尤其不要跳过 tRPC 和 Prisma因为这两个才是 T3 的精髓。选完之后它会自动初始化 Git 仓库、安装依赖、生成 Prisma Schema 并跑初始迁移整个过程大概 2 到 5 分钟。初始化完成后你会得到这样一个目录结构核心部分app/api/trpc/[trpc]/route.ts— tRPC 的 HTTP 端点前端的请求都汇聚到这里server/api/root.ts— 根 Router聚合所有子 Routerserver/api/routers/post.ts— 示例帖子 Router官方模板就带一个基础 CRUDserver/db.ts— Prisma 客户端实例prisma/schema.prisma— 数据模型定义trpc/react.tsx— 前端的 tRPC Provider 配置env.js— 环境变量校验文件基于 Zod3.2 Environment 变量管理T3 模板带了一个很妙的机制环境变量的运行时校验。它的env.js文件用 Zod 定义了哪些环境变量必须存在、哪些是可选。比如DATABASE_URL没有配置时启动服务会直接抛错而不是等你跑到数据库查询时才一脸懵。这个设计对协作开发非常友好。新人克隆仓库后哪怕不看文档启动失败时能立刻看到是哪几个环境变量缺失。我接手过不少老项目最头疼的就是README 写得花团锦簇结果环境变量全靠猜T3 这套机制治好了我的精神内耗。3.3 核心依赖文件速写快速看下server/api/routers/post.ts的官方示例代码import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const postRouter createTRPCRouter({ hello: publicProcedure .input(z.object({ text: z.string() })) .query(({ input }) { return { greeting: Hello ${input.text}, }; }), create: publicProcedure .input(z.object({ name: z.string().min(1) })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { name: input.name, }, }); }), });注意这里的publicProcedure表示任何人都能调用不需要登录态。需要保护的路由就换成protectedProcedure它会自动检查 session未登录直接抛 UNAUTHORIZED 错误。然后把app/_components/post.tsx里的调用方法展示一下const latestPost api.post.getLatest.useQuery(); api.post.create.useMutation({ onSuccess: () { utils.post.getLatest.invalidate(); }, });前端调用api.post.getLatest末尾的.useQuery()是 React Query 的强化版本。你不需要手动处理 loading、error 这些状态全都由 React Query 帮你管了。这就是 t3code 的核心体验编程逻辑完全本地化请求和响应类型零断层。4. 我用 t3code 写第一个业务模块的全过程4.1 需求描述我拿一个常见的 demo 场景来拆解做一个简单的待办事项管理功能包含三个动作查询列表、新增待办、标记完成。数据存 SQLite不需要用户系统纯公开接口。这个场景麻雀虽小五脏俱全正好能把 T3 的核心链路走一遍。4.2 建表与迁移打开prisma/schema.prisma加一个 Todo 模型model Todo { id Int id default(autoincrement()) title String completed Boolean default(false) createdAt DateTime default(now()) }然后跑迁移命令npx prisma migrate dev --name add_todo这一步会自动生成迁移文件、更新 Prisma Client 类型。如果之前启动过 dev server此处建议重启一次因为 Prisma Client 的类型更新后编辑器可能需要重新加载才认。4.3 创建 tRPC Router在server/api/routers下新建todo.ts注册到根 Routerimport { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const todoRouter createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) { return ctx.db.todo.findMany({ orderBy: { createdAt: desc } }); }), create: publicProcedure .input(z.object({ title: z.string().min(1).max(200), })) .mutation(async ({ ctx, input }) { return ctx.db.todo.create({ data: { title: input.title } }); }), toggle: publicProcedure .input(z.object({ id: z.number().int(), completed: z.boolean(), })) .mutation(async ({ ctx, input }) { return ctx.db.todo.update({ where: { id: input.id }, data: { completed: input.completed } }); }), });这里toggle的输入设计我有意把整条 completed 传进来而不是只传 id 让后端取反。原因是取反这个逻辑依赖客户端当前状态如果两个客户端同时操作同一数据就会互相覆盖。API 层做一个幂等更新比翻转更新更安全。更新根 Routerserver/api/root.tsimport { todoRouter } from ~/server/api/routers/todo; export const appRouter createTRPCRouter({ post: postRouter, todo: todoRouter, });4.4 前端页面接入新建app/todos/page.tsx在前端组件里直接连 APIuse client; import { api } from ~/trpc/react; import { useState } from react; export default function TodosPage() { const [title, setTitle] useState(); const utils api.useUtils(); const todosQuery api.todo.getAll.useQuery(); const createMutation api.todo.create.useMutation({ onSuccess: () utils.todo.getAll.invalidate(), }); const toggleMutation api.todo.toggle.useMutation({ onSuccess: () utils.todo.getAll.invalidate(), }); const handleAdd () { if (!title.trim()) return; createMutation.mutate({ title }); setTitle(); }; return ( main classNamemax-w-xl mx-auto p-8 h1 classNametext-2xl font-bold mb-4待办列表/h1 div classNameflex gap-2 mb-4 input classNameborder p-2 flex-1 value{title} onChange{(e) setTitle(e.target.value)} placeholder输入待办内容 / button onClick{handleAdd} classNamebg-blue-600 text-white px-4 py-2 rounded 添加 /button /div ul classNamespace-y-2 {todosQuery.data?.map((todo) ( li key{todo.id} classNameflex items-center gap-2 border p-2 input typecheckbox checked{todo.completed} onChange{(e) toggleMutation.mutate({ id: todo.id, completed: e.target.checked, }) } / span className{todo.completed ? line-through opacity-60 : } {todo.title} /span /li ))} /ul /main ); }核心点在api.useUtils()的返回值utils.todo.getAll.invalidate()。这句话的意思是增删改成功之后主动让getAll这个查询失效React Query 会自动重新拉取最新列表。这个模式我称之为更新后失效、查新数据整个 T3 模板的 CRUD 样例都是这个套路极大减少了手动管理缓存状态的心智负担。4.5 改动验证跑npm run dev打开http://localhost:3000/todos你会看到页面直接渲染待办列表。新增一条数据后列表自动刷新整个过程你都不用刷新浏览器页面。为什么这么顺滑因为 mutate 的 onSuccess 触发 invalidateReact Query 知道getAll的数据已经过期于是带着新请求重新执行数据重新渲染。链条清晰、类型完整没有断点。5. 关于部署和生产环境Vercel 与数据库迁移实战5.1 部署前要做的检查用 t3code 写出来的应用属于 Next.js 项目最省心的部署方式是 Vercel。但在点击 Deploy 之前有几个事项提前处理好否则很容易卡在环境配置上。第一数据库服务。如果你一直用本地 SQLite部署前要把数据库切成远程的 PostgreSQL。因为 Vercel 的函数是无状态的本地文件系统不可持久化SQLite 文件会在冷启动时直接消失。你可以选 Vercel Postgres、Railway、Supabase 或者 Neon只要提供连接字符串就能接上。第二迁移文件。Vercel 构建时会跑prisma generate但它不会自动跑迁移。常见做法是在构建命令里加上prisma migrate deploy。构建命令建议写成build: prisma generate prisma migrate deploy next build注意顺序先 generate 生成 Client再 deploy 把 migration 推给数据库最后才 next build。因为 next build 阶段可能已经要导入 Prisma Client 了顺序错了类型链接会断掉。5.2 环境变量配置清单在 Vercel 的 Environment Variables 面板里至少需要配置这几个DATABASE_URL远程 PostgreSQL 的连接串NEXTAUTH_SECRET用于加密 session 的密钥可以用openssl rand -base64 32生成NEXTAUTH_URL生产环境的域名地址v4或AUTH_URLv5各种第三方 Provider 的 ID 和 SecretNEXTAUTH_SECRET这个变量特别容易被忽略。本地开发时模板可能帮你内置了但生产环境不配置的话NextAuth 会直接拒绝启动而且不会给很友好的报错提示。5.3 部署后常见的本地好好的线上挂了问题最典型的本地连 SQLite 一切正常部署到 Vercel 后页面白屏或接口 500。排查路径如下先在 Vercel 的 Function Logs 里看报错内容。大概率是Cant find database file或者环境变量没读取到。确认DATABASE_URL的值为远程库连接串且允许外部 IP 访问有些云数据库默认白名单不开外网。检查 CORS——如果你有独立的 API 域名或者前后端分离需要在 tRPC 或 Next.js 层配置跨域。T3 模板默认同源部署不开 CORS 也没问题但如果你要给它配自定义域名映射就要额外处理。另外一旦部署流程跑通后续命令都会自动执行。后续更新数据库 schema本地改完后运行prisma migrate dev生成新迁移文件push 到仓库Vercel 会自动跑 build构建命令里的migrate deploy会把增量迁移应用到生产库。这个流水线用顺手了之后你会发现数据库版本管理其实没那么吓人。6. 常见问题避坑指南6.1 前端组件调用 useQuery 报错必须是客户端组件很多新手把模板的app/page.tsx改成自己页面时直接写在服务端组件里然后调用useQuery立即报错Hooks can only be called inside of the body of a function component。排查方法很简单查看组件文件顶部是否声明了use client。T3 模板已经在封装层处理好布局但你自己新建的页面默认是 Server Component不声明这个指令就没有 Hooks 运行环境。页面级别组件加use client没问题但如果你希望部分组件保持服务端渲染以享受 SEO 红利建议拆成内外两层——外层 Server Component 负责拉静态数据和 metadata内层 Client Component 负责交互。6.2 include 字段写错导致前端类型拿不到Prisma 类型推导很强但有一个容易踩的坑前端用 tRPC 调用返回的数据里想访问某个关联表字段却发现自己没在 Prisma 查询里include这个关联。类型会直接报错不会静默返回 undefined。这一点我觉得反而省事它逼着你显式声明取数范围避免传统 ORM 那种隐式 N1 查询问题。// 假设要取 todo 关联的 user ctx.db.todo.findMany({ include: { user: true } })确保字段名和 Prisma schema 里的关系名一致大小写和驼峰都要对得上。6.3 tRPC 请求全部挂掉但页面静态部分正常这种时候优先检查 API Route 的路径是否被中间件拦截了。NextAuth 的中间件如果配了 matcher可能会把/api/trpc也拦掉导致未登录跳转。推荐的做法是中间件 matcher 显式排除 tRPC 端点或确认 tRPC 的 procedure 使用protectedProcedure来处理鉴权而不是依赖 NextAuth 中间件拦截 API 链路。6.4 迁移文件冲突多人协作时 migrate dev 常常打架一个真实场景团队两个人都改了 schema各自本地跑prisma migrate dev生成了相同序号的迁移push 之后必冲突。我的习惯是开发时尽量用prisma db push来快速同步 schema不需要生成迁移文件只有快发布时才统一收敛成一次正式的migrate dev。这种模式在单人项目和早期团队非常实用等真的很多人同时动 schema 时再考虑引入专门管理迁移的人或流程。6.5 NextAuth session 在服务端拿不到如果你在 Server Component 里直接调getServerSession()拿不到 session很可能是没有正确配置authOptions到 app router 的对应文件。v5 版本里要确认/auth.ts的导出是否正确且trpc的 context 创建是否从auth()读取 session 而不是getServerSession()读旧配置。我遇到过不少把 v4 配置硬搬到 v5 导致 session 永远为 null 的情况分布在不同版本间迁移的时候注意版本差异。7. 我的真实体会整个 t3code/T3 Stack 用下来最强烈的体感是它把那根类型红线从数据库一路画到了浏览器。你换个复杂项目试试就知道传统 REST API 全链路的问题往往暴露在联调和上线之后字段命名不一致、缺字段、类型错配。T3 把这些问题前置到编码阶段大部分低级错误在敲键盘的时候就给你拦截了。有一个细节我印象特别深刚用 T3 时我在前端写错了一个 input 字段名编辑器直接红波浪线告诉我这个属性不存在并且给出了可选列表。那一刻我突然理解了端到端类型安全的含义——不是测试覆盖率提升多少不是文档写得多好而是类型系统本身就变成了一个活生生的接口文档和一个永不疲倦的代码审查员。如果你之前没接触过 tRPC建议从今天这个小 todo 例子开始跑一遍初始化一个模板项目改一个 model写一个 procedure再在前端查一下数据感受一下那条类型链路是怎么贯穿下来的。等你顺手了再去把认证、权限、部署串起来整个全栈开发的自信水平会上一大截。最后再分享一个经验T3 生态更新速度快模板也在持续变化别把网上两年前的教程当真。跑项目时如果遇到报错第一反应去看官方文档和仓库的 release notes很多报错的答案都写在版本更新说明里。我踩过最长的一次坑就是照着老教程配 NextAuth v4 最终在 v5 项目里反复横跳折腾了一下午。认准版本、再动手能帮你省下大量时间。