首页 / 资讯中心 / 文章详情

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

T3 Stack全栈开发实战:从Prisma到tRPC的类型安全之旅 ★ FEATURED ARTICLE
T3 Stack这套东西圈内讨论热度一直没降过。我第一次看到t3code这个项目名的时候第一反应就是这八成又是T3 Stack全家桶的实践——结果还真没跑偏。Next.js、TypeScript、Tailwind、tRPC把这几样捏在一起做一个全栈应用最大的爽点就是类型安全从数据库一路贯穿到页面组件联调的时候少了一堆接口字段返没返的破事。这篇文章就基于t3code这个项目模式把整个搭建过程、核心设计思路、踩过的坑完整捋一遍适合正在学T3 Stack或者想从传统前后端分离切到全栈同构的开发者参考。1. 项目整体设计与架构思路1.1 为什么选中T3 Stack而不是传统前后端分离先聊个很现实的问题现在做一个带数据库的Web应用大部分人第一反应还是Next.js做前端后端单独起一个Express或者NestJS服务两边通过REST API对接。这个方案本身没毛病但项目一大类型地狱就来了——后端定义了一个User类型前端为了拿到同样的结构得在types目录里再手抄一份一旦后端字段改了前端忘记同步运行时就给你抛一个undefined错误排查半天发现是类型对不上。T3 Stack的思路是能省则省。tRPC把API调用做成了像调用本地函数一样自然所有输入输出类型直接从后端推断到前端不用手写接口文档也不用在前后端各维护一份类型定义。Next.js负责页面渲染和服务端逻辑Prisma操作数据库Tailwind写样式整条链路几乎没有多余的胶水代码。t3code这个项目选它核心就是冲着类型安全和开发效率这两个词去的。1.2 整体目录结构与数据模型设计我用一个网络书签管理的demo来讲解功能不复杂用户可以注册登录、添加书签、按标签筛选、收藏喜欢的条目。这个场景够典型CRUD齐全又涉及用户隔离权限很适合演示tRPC的完整链路。t3code的目录结构长这样t3code/ ├── prisma/ │ └── schema.prisma # 数据库模型定义 ├── src/ │ ├── pages/ # Next.js页面路由 │ │ ├── index.tsx # 书签列表页 │ │ ├── login.tsx # 登录页 │ │ └── new.tsx # 新建书签页 │ ├── server/ │ │ ├── api/ │ │ │ └── trpc.ts # tRPC服务端入口 │ │ ├── auth.ts # NextAuth配置 │ │ ├── db.ts # Prisma客户端实例 │ │ └── trpc.ts # tRPC路由器和上下文 │ ├── styles/ │ │ └── globals.css # Tailwind配置 │ └── utils/ │ └── api.ts # tRPC客户端封装 ├── .env # 环境变量 ├── tailwind.config.js └── package.jsonPrisma的模型定义是整张数据表的源头我建议把这里当成设计文档来写字段注释、关系约束都写清楚model User { id String id default(cuid()) name String? email String? unique passwordHash String? bookmarks Bookmark[] favorites Favorite[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Bookmark { id String id default(cuid()) title String url String description String? tags String[] // 直接用数组保存标签简单场景够用了 owner User relation(fields: [ownerId], references: [id]) ownerId String favorites Favorite[] createdAt DateTime default(now()) } model Favorite { id String id default(cuid()) user User relation(fields: [userId], references: [id]) userId String bookmark Bookmark relation(fields: [bookmarkId], references: [id]) bookmarkId String createdAt DateTime default(now()) unique([userId, bookmarkId]) }数据库选的SQLite原因只有一个——本地开发零配置prisma migrate dev一条命令就能把表建出来。真要上生产把datasource里的provider改成postgresql连接字符串换一下重新迁移一次就完事Prisma在这块的抽象做得足够好。1.3 tRPC路由器设计API即函数tRPC的核心思想是把服务端定义的路由直接变成前端可以导入调用的函数。t3code里的路由器定义长这样// src/server/trpc.ts import { initTRPC, TRPCError } from trpc/server; import { type CreateNextContextOptions } from trpc/server/adapters/next; import { getServerSession } from next-auth; import { authOptions } from ./auth; export const createContext async (opts: CreateNextContextOptions) { const session await getServerSession(opts.req, opts.res, authOptions); return { session, db: prisma, }; }; const t initTRPC.contexttypeof createContext().create(); export const router t.router; export const publicProcedure t.procedure; // 需要登录才能调用的路由中间件 export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session: ctx.session, }, }); });这段代码把上下文context和权限中间件一起解决了。createContext在每次请求进来时执行把数据库实例和登录会话塞进上下文里后续所有路由都能直接ctx.db、ctx.session这样访问不用每个接口单独查一遍用户。2. 核心链路拆解从Prisma到tRPC再到页面2.1 数据库操作与Prisma Client的正确姿势Prisma的客户端实例一定要全局复用不能每个文件都new PrismaClient()。开发环境下热重载会导致实例越建越多最后数据库连接池直接被撑爆。.env里的DATABASE_URL、NEXTAUTH_SECRET这些配置不要在代码里写死。初始化数据库的流程我习惯三步走# 1. 根据schema.prisma生成迁移文件 npx prisma migrate dev --name init_bookmark # 2. 生成Prisma Client类型 npx prisma generate # 3. 如果是全新数据库直接执行迁移 npx prisma db pushmigrate dev和db push的区别在于前者会生成完整的迁移历史记录适合多人协作和线上环境回滚后者只是把当前模型同步到数据库适合本地快速原型。t3code这个项目建议从一开始就用migrate dev养成好习惯。2.2 书签路由器的完整实现书签相关的核心API都放在一个bookmarkRouter里看代码比看文档直观得多// src/server/routers/bookmark.ts import { z } from zod; import { router, publicProcedure, protectedProcedure } from ../trpc; export const bookmarkRouter router({ // 获取书签列表支持关键词搜索和标签过滤 list: publicProcedure .input( z.object({ search: z.string().optional(), tag: z.string().optional(), }) ) .query(async ({ ctx, input }) { const bookmarks await ctx.db.bookmark.findMany({ where: { OR: input.search ? [ { title: { contains: input.search } }, { description: { contains: input.search } }, ] : undefined, tags: input.tag ? { has: input.tag } : undefined, }, include: { owner: { select: { name: true } }, _count: { select: { favorites: true } }, }, orderBy: { createdAt: desc }, }); return bookmarks; }), // 新建书签必须登录 create: protectedProcedure .input( z.object({ title: z.string().min(1), url: z.string().url(), description: z.string().optional(), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { const bookmark await ctx.db.bookmark.create({ data: { ...input, ownerId: ctx.session.user.id, }, }); return bookmark; }), // 删除书签只能删自己的 delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const bookmark await ctx.db.bookmark.findUnique({ where: { id: input.id }, }); if (!bookmark || bookmark.ownerId ! ctx.session.user.id) { throw new TRPCError({ code: FORBIDDEN }); } await ctx.db.bookmark.delete({ where: { id: input.id } }); return { success: true }; }), });注意细节create和delete用的是protectedProcedure即必须登录list是publicProcedure随便看。Zod的校验不止做在前端后端同样跑一遍双重保险。z.string().url()这个校验帮我拦住过不少人家随手写错的书签地址。2.3 React Query把服务端状态变成前端缓存tRPC默认背后是React Query现在叫TanStack Query这意味着数据缓存、加载状态、失效刷新这套机制它全帮你接好了。前端调用API不像以前fetch一把梭而是通过useQuery和useMutation把数据变成响应式状态// src/utils/api.ts import { createTRPCNext } from trpc/next; import { httpBatchLink } from trpc/client; import { AppRouter } from ../server/routers/_app; export const api createTRPCNextAppRouter({ config() { return { links: [ httpBatchLink({ url: /api/trpc, }), ], }; }, ssr: false, });在页面组件里api.bookmark.list.useQuery({ search, tag })执行完数据会自动缓存。提交新书签后调用api.bookmark.create.useMutation()成功之后执行utils.bookmark.list.invalidate()让列表自动重新拉取不用手动刷新页面。这套机制的爽点在于React Query会帮你处理请求去重缓存过期失败重试传统前端要自己用Redux管理的一大堆loading状态、error状态在这里全被抽象掉了。3. 认证与权限把NextAuth无缝接进tRPC3.1 NextAuth的配置要点t3code里登录用的NextAuth我选择了Credentials Provider账号密码登录如果你要接GitHub、Google OAuth配置逻辑完全一致就是多填一个Provider而已。// src/server/auth.ts import { NextAuthOptions } from next-auth; import CredentialsProvider from next-auth/providers/credentials; import { compare } from bcryptjs; export const authOptions: NextAuthOptions { providers: [ CredentialsProvider({ name: 账号密码, credentials: { email: { label: 邮箱, type: email }, password: { label: 密码, type: password }, }, async authorize(credentials) { const user await prisma.user.findUnique({ where: { email: credentials?.email }, }); if (!user?.passwordHash) return null; const valid await compare(credentials!.password, user.passwordHash); return valid ? { id: user.id, email: user.email, name: user.name } : null; }, }), ], session: { strategy: jwt }, callbacks: { jwt({ token, user }) { if (user) token.id user.id; return token; }, session({ session, token }) { if (token.id) session.user.id token.id; return session; }, }, secret: process.env.NEXTAUTH_SECRET, };密码不要明文存注册时用bcryptjs的hash()处理一遍。有次我图省事直接存明文被朋友看到了直接怼你是不是没做过生产项目。密码哈希这东西真不是开玩笑bcryptjs的hash(123456, 12)成本因子12单次哈希耗时大概100毫秒暴力破解的成本直接拉满。3.2 在受保护路由上做权限控制前端页面层面Next.js有两种做法一种是getServerSideProps里重定向适合需要服务端渲染数据的页面另一种是客户端useSession判断适合纯展示页面。// src/pages/new.tsx import { useSession } from next-auth/react; import { useRouter } from next/router; import { useEffect } from react; export default function NewBookmarkPage() { const { data: session, status } useSession(); const router useRouter(); useEffect(() { if (status unauthenticated) { router.push(/login); } }, [status, router]); if (status loading) { return div加载中.../div; } return ( // 表单内容 ); }但页面层面的跳转只是用户体验真正的安全防线还是3.x那节写的protectedProcedure中间件。前端跳过去、接口挡住两层都做才叫完整。3.3 按用户隔离数据多租户设计的第一课t3code里的书签必须做到谁创建、谁看到。查询端如果用户只想要自己的书签findMany的where条件里加上ownerId: ctx.session.user.id管理端如果要展示全量数据至少也要校验当前用户是不是管理员角色。我写过一个常见错误创建记录时忘记把ownerId塞进数据里导致所有书签变成无主游魂列表查出来全是null的归属。这个问题排查起来特别隐蔽因为功能看着是好的但权限控制一旦加上就全乱了。所以创建接口务必从ctx.session里取用户ID永远不要信任前端传过来的userId字段。4. 前端样式与交互细节4.1 Tailwind的效率体现在哪里T3 Stack默认集成Tailwind很多人以为Tailwind只是把CSS写在class里其实它真正的价值是约束设计规范。t3code里的UI间距间距用p-4、m-2这种统一的度量单位颜色用预设的调色板不会出现今天这个页面蓝色#3498db、明天那个页面蓝色#1a6beb的混乱局面。一个小建议全局样式里把暗色主题做了。Tailwind切暗色模式只要在tailwind.config.js里加一行darkMode: class然后在页面上用dark:bg-gray-900 dark:text-white这种前缀适配。用户偏好这块反而挺拉好感。4.2 一个完整的表单提交流程示例表单交互是整个前端体验的核心之一。接下来看一个新建书签表单的完整实现包括loading状态、错误处理、成功跳转const utils api.useUtils(); const createBookmark api.bookmark.create.useMutation({ onSuccess: () { utils.bookmark.list.invalidate(); router.push(/); }, onError: (error) { setError(error.message); }, }); const handleSubmit (e: FormEvent) { e.preventDefault(); createBookmark.mutate({ title, url, description, tags: tagInput.split(,).map((t) t.trim()).filter(Boolean), }); }; return ( form onSubmit{handleSubmit} classNamespace-y-4 {/* 表单字段 */} button typesubmit disabled{createBookmark.isLoading} classNamebg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50 {createBookmark.isLoading ? 保存中... : 保存书签} /button /form );注意useMutation的isLoading状态点击后按钮文字变成保存中...避免用户重复提交。React Query在mutation进行中再次点击会直接忽略这比手写if (submitting) return省事不少。4.3 React Hook Form合用的姿势表单字段一多光靠useState逐个维护再手动处理校验error代码很快就臭了。t3code里我用react-hook-form配合zod做方案const { register, handleSubmit, formState: { errors } } useForm({ resolver: zodResolver(bookmarkSchema), });zodResolver直接把之前tRPC后端用的同一个Schema拿到前端校验两边校验规则永远一致不会出现前端说必填、后端说选填的尴尬。5. 构建部署与运维要点5.1 环境变量管理T3 Stack的.env文件有几个关键变量缺一个都没法跑# 数据库连接串本地SQLite DATABASE_URLfile:./dev.db # NextAuth的加密密钥生产环境一定要换成随机字符串 NEXTAUTH_SECRETyour-random-secret # NextAuth的站点地址本地开发可以填localhost NEXTAUTH_URLhttp://localhost:3000NEXTAUTH_SECRET怎么生成终端跑一行openssl rand -base64 32。千万不要用一段固定的弱密码不然JWT可以被伪造。5.2 部署到Vercel的完整流程Next.js部署到Vercel基本是零成本但数据库这块容易踩坑。SQLite在Vercel的无服务器环境里只读写不进去所以生产环境必须换Postgres。我推荐用Railway或者Supabase先建一个免费的Postgres实例拿到连接串之后# 1. 修改schema.prisma里的datasource datasource db { provider postgresql url env(DATABASE_URL) } # 2. 重新生成迁移 npx prisma migrate reset --force npx prisma migrate dev --name switch_to_postgres # 3. 提交代码在Vercel里配置好DATABASE_URL和NEXTAUTH_SECRET部署完之后有个小毛病Prisma Client没生成会导致Cannot find module prisma/client所以package.json的build脚本最好加一句build: prisma generate next build5.3 数据库迁移与备份的实操方案线上环境的schema变更不能再跑db push那等于裸奔。正确做法是# 本地修改schema.prisma后 npx prisma migrate dev --name add_click_count # 提交到代码仓库Vercel的构建命令里加 npx prisma migrate deploymigrate deploy只会执行还没跑过的迁移文件安全可靠。备份方面Postgres用pg_dump每天定时导一次SQLite的话直接复制文件就行不过SQLite基本不推荐上生产并发写就是灾难。6. 实操中常见的坑与排查心得6.1 问题排查速查表一路踩下来我把高频问题整理成一张表方便按图索骥症状可能原因解决方案页面白屏并报hydratation failed服务端渲染的DOM和客户端不一致检查组件里的Date.now()或Math.random()这类不确定值改用useEffect只在客户端执行登录后session.user.id为undefinedNextAuth回调里没把ID写进token在jwt和session回调里手动映射user.idtRPC请求返回UNAUTHORIZED请求没带登录态或中间件校验失败确认浏览器存了cookieNEXTAUTH_URL和实际域名一致Prisma报Unique constraint failed插入数据字段冲突检查schema.prisma里的unique字段注册场景要先查重部署后环境变量不生效环境变量没同步到托管平台Vercel等平台要在Project Settings里配置不能只写在.env文件数据列表刷新后位置跳动列表排序不稳定orderBy至少加一个唯一字段比如id或createdAt6.2 调试技巧tRPC的日志与错误追踪tRPC的报错默认很粗略只给你一个INTERNAL_SERVER_ERROR。排查的时候我先在服务端封装一个日志中间件const loggerMiddleware t.middleware(async ({ path, type, next }) { const start Date.now(); const result await next(); const duration Date.now() - start; console.log([tRPC] ${type} ${path} ${duration}ms); if (result.ok) { // 打印出参和入参方便定位 console.log( input:, result.input); } return result; });开发环境里每个请求的耗时、路径、参数一目了然。特别是那些偶发性的报错有了日志才能抓住现场。6.3 性能优化列表页的加载体验书签数据一多首页列表直接findMany全部返回页面起码卡几百毫秒。我做了三个优化效果立竿见影第一分页或者无限滚动。tRPC的input里加cursor参数查询用cursor-based pagination前端用useInfiniteQuery配合IntersectionObserver实现滚动加载。第二列表项组件用memo包裹。书签列表里每项有收藏按钮点一下收藏React Query会更新缓存并触发重渲染如果列表全部组件都重渲染数据量上千的时候直接卡顿。memo只让被点击的项更新体验完全两样。第三图片和富文本懒加载。书签有描述或者封面图的话loadinglazy和IntersectionObserver能省下大量初始请求。6.4 为什么不建议在T3 Stack里硬塞Redux有人习惯性把Redux装上我劝一句非必要别装。tRPC React Query已经覆盖服务端状态管理NextAuth覆盖认证状态Zustand这类轻量方案覆盖客户端UI状态Redux全家桶在这里属于过度设计。多个状态管理库共存责任边界模糊调试成本反而升高。7. 写在最后的实操心得t3code这个项目做完我最大的感受就是类型安全带来的信心。以前写前后端联调接口文档过期、类型对不上、运行时崩了查半天这些破事在T3 Stack里几乎绝迹。改一个字段从Prisma schema到前端组件编译器一路帮你查漏补缺这种体验一旦尝过就回不去了。如果你想自己复现这个项目我建议按这个顺序走先跑通Prisma迁移和tRPC的hello world再加NextAuth认证接着写书签CRUD最后慢慢优化体验。别一步到位容易迷路。开发环境优先用SQLite省去数据库安装的麻烦等要部署了再切Postgres迁移成本比你想象的低。最后分享一个连招npx create-t3-applatest创建项目、npx prisma studio可视化看数据库、把src/server/routers按业务模块拆文件。这三样配合起来日常开发效率直接翻倍。
阅读完成 · 觉得有帮助?
咨询建站