尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

T3 Stack全栈实践:从Next.js到tRPC的类型安全闭环

T3 Stack全栈实践:从Next.js到tRPC的类型安全闭环 从 2023 年开始我就一直在用 T3 Stack 写全栈项目今年做内部工具和几个外包项目时又一次把整套流程完整跑了一遍。t3code 就是我对这套技术组合的实践代号Next.js 当外壳、TypeScript 贯穿前后端、tRPC 做接口层、Prisma 管数据库、Tailwind CSS 负责样式再加上 NextAuth.js 处理登录鉴权。这套方案最大的价值就是“前后端类型全打通”一个数据模型在数据库、接口、前端页面三个地方长得一模一样改一个字段名所有引用点全部同步报错不用像传统前后端分离那样在代码里来回翻找接口文档。这篇文章我会把这套组合从初始化到部署的完整路径写清楚包括每个环节为什么要选这个工具、有哪些坑、怎么排查适合刚接触 T3 栈的人也适合已经在用但想补全细节的开发者。1. 从 t3code 说起T3 全栈到底解决了什么问题1.1 为什么是 Next.js TypeScript 打底很多人第一次听到 T3 Stack 时会以为是某个独立的新框架其实它就是把几套成熟工具做了一个“整合”核心是「以 TypeScript 为第一公民」。TypeScript 在这套体系里不是可有可无的类型标注而是连接所有模块的“胶水语言”。数据库的表结构、接口的请求参数、组件拿到的数据格式全部源自同一份类型定义。这种开发方式的转变用一句话概括就是把“前后端约定”从人脑里移到编译器里。Next.js 在这套组合里的角色是“应用框架”负责路由、服务端渲染、静态生成和 API 路由。选择它而不是 Vite React 的纯 SPA 方案是因为 tRPC 在服务端组件里的调用体验非常自然——你可以在 Server Component 里直接发起 tRPC 请求数据在服务端拿完再渲染成 HTML首屏速度比客户端全部水合快很多。同时 Next.js 的 API Routes 可以作为 tRPC 的 HTTP 出口前后端代码放在同一个仓库里逻辑上却依然边界清晰不需要额外维护一套 Node 服务。1.2 tRPC 把前后端沟通变成“直接调用函数”在 t3code 这套实践里tRPC 是最让人觉得“真香”的一环。传统开发模式下后端写 REST 接口前端需要手动定义调用层、处理错误码、维护接口文档而 tRPC 的模型完全变了后端定义好一个个 procedure查询或命令前端直接像调用本地函数一样去调用它们且有完整类型提示和编译期校验。为什么能做到这一点核心在于 tRPC 在构建时通过 TypeScript 类型推断生成客户端调用函数。比如你在后端定义了一个getUserList的 query前端代码里就自动会有一个api.user.getList.useQuery()方法参数、返回类型都是强约束的。如果你把后端的返回结构从{ name: string }改成{ name: string; age: number }前端用到data.name的地方依然能编译但用到旧字段的地方会立刻报错。这就是 t3code 里最核心的“类型安全闭环”。1.3 Prisma Tailwind NextAuth 补齐全链路数据库层我是用 Prisma 管理的它提供的 schema 文件可以理解成“数据库的单一事实来源”。你在 schema 里定义一个User模型Prisma 会自动生成配套的 TypeScript 类型供前后端使用同时还能生成数据库迁移脚本。这比手写 SQL 建表然后另写一套类型定义要省太多事了。样式方面Tailwind CSS 在这套栈里的位置属于“约定俗成”。它不像传统的 CSS Modules 或者 Sass 那样把样式分成独立文件而是直接在 className 里书写原子类配合 Next.js 的样式系统很容易做到零运行时样式开销。NextAuth.js 则是登录鉴权部分的标准答案它对于第三方登录例如 GitHub、Google有比较完整的适配与 Prisma 的 User/Account/Session 模型能直接对接不需要自己造轮子。下表是我从 t3code 实战中总结的 T3 栈工具定位工具负责解决的问题替代方案我选择它的关键原因Next.js路由、渲染模式、API 出口Remix、Vite SPA全栈一体化SSR/SSG 灵活切换TypeScript跨端类型统一JavaScript编译期发现接口不一致tRPC前后端数据调用与类型安全REST OpenAPI、GraphQL无需生成代码类型自动推导Prisma数据库建模、迁移、查询TypeORM、Drizzle数据模型自动生成类型迁移可控Tailwind CSS样式体系CSS Modules、Styled Components快速迭代设计与代码更贴近NextAuth.js登录与会话管理Clerk、自建 JWT与 Prisma/Next.js 生态结合顺畅2. 开工前的技术准备环境、版本和目录设计2.1 Node.js 版本与包管理器选择t3code 这类工程对 Node.js 版本是有要求的官方脚手架在 18.x 和 20.x 下表现最稳定。我自己用的是 Node 20 LTS如果你还在用 16建议先升级否则 Next.js 14 系列的某些特性跑不起来。包管理器方面npm、pnpm、Yarn 都能用但我更推荐 pnpm原因有两个一是安装速度明显更快二是它能严格管理依赖的依赖关系对于 T3 这种强调整合的项目避免了某些包嵌套层级过深带来的怪问题。如果你本机还没有 pnpm可以先用 npm 全局安装它npm install -g pnpm之后所有项目内命令我都用 pnpm 来演示逻辑上完全等价于 npm只是更高效。2.2 create-t3-app 初始化与修改选项初始化一个新项目只要一行命令pnpm create t3-applatest t3code-app交互式提问会列出一堆可选项我通常只勾选 Next.js、Tailwind CSS、tRPC、Prisma、NextAuth.js 这几项ESLint 和 Prettier 可以选上但不是核心。这里有个建议第一次创建的时候不要贪心把全部选项都打勾先让基础设施跑通再逐步加功能模块。尤其是如果项目初期根本不需要鉴权可以先不选 NextAuth.js否则还要配环境变量和数据库表会让第一个“Hello World”显得格外繁琐。创建完成之后T3 会自动生成一套目录结构核心路径如下src/ app/ # Next.js 13 的 App Router 页面目录 pages/ # 如果选择 Pages Router 会存在 server/ # 服务端逻辑目录 api/root.ts # tRPC 根路由拆分子路由 api/routers/ # 业务子路由 auth.ts # NextAuth 配置 db.ts # Prisma Client 实例 trpc/ server.ts # tRPC 服务端初始化 client.ts # tRPC React 客户端 caller.ts # 服务端调用 tRPC 的工具这一套目录不是随意堆放的它体现了 T3 的“关注点分离”理念server/db.ts只负责创建数据库客户端实例server/api/root.ts聚合所有业务路由trpc/client.ts则为前端提供类型感知的调用入口。2.3 目录逻辑与数据模型设计在实际写代码之前我会先做一步很多人忽略的工作把业务模块用数据模型画清楚。因为 t3code 强调类型闭环数据库模型一旦定义好后面几乎所有层的类型都会跟着定型所以值得花点时间想清楚字段。以我近期做一个简单的“内容发布与管理”模块为例设计了 Users、Posts、Comments 三个数据模型。Users 核心字段有 id、email、namePosts 有 id、title、content、published、authorId、createdAt、updatedAtComments 有 id、content、postId、authorId、createdAt。这个模型最大的特点是关系比较清晰一个用户有多篇文章一篇文章有多条评论。接着在prisma/schema.prisma文件里把这些模型定义出来generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) email String unique name String? emailVerified DateTime? image String? posts Post[] comments Comment[] } model Post { id String id default(cuid()) title String content String published Boolean default(false) authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) comments Comment[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Comment { id String id default(cuid()) content String postId String post Post relation(fields: [postId], references: [id], onDelete: Cascade) authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) createdAt DateTime default(now()) }这里我特意为所有 id 选用了cuid()而不是自增整数。原因也很简单公开接口如果暴露自增 ID很容易被人抓取后猜测业务量比如今天注册了 1000 用户明天再看可能就是 1005存在一定的信息安全隐患。cuid 更安全且适合分布式生成。对我这种中小型项目来说这比使用 UUID v4 更短、更友好数据库索引空间占用也更小。定义好之后执行一次迁移把模型同步到数据库pnpm prisma migrate dev --name init这条命令会生成迁移 SQL 文件同时将 Prisma Client 的类型更新到最新状态这也是后面所有 tRPC 路由类型能“自动长出来”的前提。3. 核心链路实操从数据库到前端页面的完整闭环3.1 tRPC Router 搭建与命名约定在 t3code 的架构中tRPC Router 是业务逻辑与数据之间的“翻译官”。文件位置在src/server/api/routers/下。我的习惯是按业务领域拆文件每个文件导出自己的 router然后在root.ts里聚合。以文章模块为例新建post.tsimport { z } from zod; import { createTRPCRouter, publicProcedure, protectedProcedure } from ../trpc; export const postRouter createTRPCRouter({ all: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ where: { published: true }, include: { author: { select: { name: true, image: true } }, _count: { select: { comments: 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: { author: { select: { name: true, image: true } }, comments: { include: { author: { select: { name: true, image: true } } }, orderBy: { createdAt: asc }, }, }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1, 标题不能为空), content: z.string().min(1, 内容不能为空), published: z.boolean().default(false), }) ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, published: input.published, authorId: ctx.session.user.id, }, }); }), });这里有个区分点publicProcedure表示不需要登录即可访问protectedProcedure则要求必须有有效会话ctx.session.user.id是 NextAuth 注入到 tRPC 上下文里的数据保证了后端逻辑能够识别当前用户。然后在src/server/api/root.ts中合并import { createTRPCRouter } from ~/server/api/trpc; import { postRouter } from ./routers/post; import { userRouter } from ./routers/user; export const appRouter createTRPCRouter({ post: postRouter, user: userRouter, }); export type AppRouter typeof appRouter;命名空间的设计在 tRPC 里特别重要因为它会直接转化为前端调用的路径前缀。比如router.post.all在浏览器端就是api.post.all.useQuery()。3.2 页面与组件消费数据类型最小化到哪个层级后端路由写好后前端的调用体验是整个 t3code 最亮眼的部分。在任意一个客户端组件里你可以像调用本地请求方法一样去获取数据use client; import { api } from ~/trpc/react; export function PostList() { const { data: posts, isLoading } api.post.all.useQuery(undefined, { staleTime: 60_000, // 1分钟内不重复请求 }); if (isLoading) return div加载中.../div; return ( div {posts?.map((post) ( article key{post.id} h2{post.title}/h2 p{post.author?.name}/p p{post._count.comments} 条评论/p /article ))} /div ); }注意api.post.all.useQuery这个函数的形态它完全由前面的 router 定义推导而来。不需要自己维护请求路径不需要手写fetch也不需要把后端返回的数据再对应到什么 interface——因为返回类型自动就是 Router 里定义的值。如果你想在 Server Component 里获取数据t3code 有两种方式一是直接把 tRPC caller 拿进来在组件内同步调用二是先在外面调用把数据作为 props 传进去。我实际项目里八成用的是第二种让服务端组件保持纯粹把交互部分放在客户端组件里这样可测试性和静态分析结果都会更好。3.3 数据变更与缓存策略mutation 触发更新对于写操作tRPC 是 mutation 概念。它的用法和 query 基本对称use client; import { api } from ~/trpc/react; import { useRouter } from next/navigation; export function CreatePostForm() { const utils api.useUtils(); const router useRouter(); const createMutation api.post.create.useMutation({ onSuccess: async () { await utils.post.all.invalidate(); router.push(/posts); }, }); const handleSubmit (e: React.FormEventHTMLFormElement) { e.preventDefault(); const form new FormData(e.currentTarget); createMutation.mutate({ title: String(form.get(title)), content: String(form.get(content)), published: true, }); }; return ( form onSubmit{handleSubmit} input nametitle required placeholder标题 / textarea namecontent required placeholder正文 / button typesubmit disabled{createMutation.isPending} {createMutation.isPending ? 发布中... : 发布} /button /form ); }这里我特意用了utils.post.all.invalidate()而不是手动重新查询一次。invalidate是 tRPC 自动缓存失效机制它会把对应 key 所关联的缓存标为过期如果页面正处于挂载状态会自动触发重新请求。这比手动 setQueryData 维护状态要可靠得多能保证“数据发生变更后所有依赖这份数据的组件自动刷新”。3.4 NextAuth 接入与第三方登录细节在 t3code 项目里接入 NextAuth.js最大的优点是它和 Prisma 模型天然兼容。初始化时只需在src/server/auth.ts里填上 provider 信息和adapterimport { PrismaAdapter } from next-auth/prisma-adapter; import { db } from ~/server/db; import { type NextAuthOptions } from next-auth; export const authOptions: NextAuthOptions { adapter: PrismaAdapter(db), session: { strategy: database }, providers: [ // 这里按需添加 GitHub / Google / Credentials 等 provider ], callbacks: { session: ({ session, user }) ({ ...session, user: { id: user.id, name: user.name, email: user.email, image: user.image, }, }), }, };这里有一个关键的取舍默认 NextAuth 可以使用 JWT 模式这样不需要把 session 存到数据库。但既然 t3code 已经用 Prisma 打通了数据库链路我更推荐把session.strategy设为database。原因有两点第一服务端渲染场景下数据库 session 更可靠不会因为 JWT 在 Cookie 里过期导致用户感知异常。第二后端如果要做类似“踢人下线”“查询所有在线 session”这类功能只有数据库 session 模型才做得到。实操中我踩过最大的坑就是环境变量填写不完整。NextAuth 最少需要NEXTAUTH_SECRET和一个NEXTAUTH_URL。在本地开发时如果.env里忘了写NEXTAUTH_URLhttp://localhost:3000第三方登录回跳时经常会出现回调地址不匹配的问题。这个坑几乎每个 T3 新手都会遇一次排查办法就是看回调地址与配置的 URL 是否完全一致。4. 部署与上线最容易翻车的地方都在这里4.1 环境变量管理与构建配置t3code 项目部署时我最先检查的不是代码而是环境变量。整套系统最少需要这样几项变量名用途示例DATABASE_URL数据库连接串postgresql://user:passhost:5432/dbNEXTAUTH_SECRET会话签名密钥随机长字符串NEXTAUTH_URL认证回调地址https://your-domain.comAUTH_PROVIDER_CLIENT_ID第三方登录公钥取决于 providerAUTH_PROVIDER_CLIENT_SECRET第三方登录密钥取决于 provider一个典型的错误是把NEXTAUTH_URL直接设为http://localhost:3000然后部署到服务器这会导致线上登录后跳回本地。我自己习惯在.env.example里把所有需要配置的变量名列好再让 CI 流程强制检查必填项避免上线时环境配错。另外Prisma 在构建时会执行prisma generate这会读取DATABASE_URL来生成 client所以构建环境里必须先有这个变量否则会报无法加载 Prisma Client 的错误。在本地如果遇到这种问题可以先执行pnpm prisma generate再构建。4.2 使用 Vercel 部署还是自托管如果你用的是标准 T3 栈且偏向于“少操心”路线Vercel 是最顺滑的一站式部署方式。把代码推到 Git 仓库后在 Vercel 里链接项目它自动识别 Next.js插件化完成构建和部署。数据库可以用 Vercel Postgres 或者原生的 PostgreSQL前提是环境变量配置正确。Vercel 需要注意的一点是Serverless 函数是无状态的每次请求都会创建新的函数实例。如果你的 Prisma Client 在文件顶层直接new PrismaClient()在冷启动时会重复初始化数据库连接可能触发连接数被打满的错误。解决办法是在全局对象里缓存 Prisma 实例代码可以这样写import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }这样在 Serverless 环境下每次模块被加载时如果全局缓存已经有实例就直接复用避免每次冷启动都新建连接。如果你倾向自托管可以部署到普通的 Linux 服务器上用 Docker 或者 PM2 跑pnpm start。要注意的差异是自托管是长驻进程Prisma 连接池可以自然复用同时你需要为 Node 进程单独配置反向代理终结 TLS。4.3 性能与首屏优化从 SSR 到静态生成T3 栈的性能上限往往取决于“哪些页面用静态生成、哪些用服务端渲染”。对于文章列表、公告这类内容变化不频繁的页面我建议在 Next.js 的 App Router 里主动使用export const revalidate 60这样的缓存配置让页面 60 秒增量刷新一次首屏速度会非常好。对于需要登录后查看的个人中心页面则不应该静态生成而应使用动态渲染通过cookies()读取会话后把用户数据直接 render 到 HTML 里。如果错误地把动态页面设为静态用户看到的数据就会带有严重的滞后性甚至是上一个上线版本的数据。我自己的习惯是在 t3code 项目的src/app/page.tsx里先把公开内容部分做成静态或增量生成把用户专属区域降级到客户端组件里动态加载。这样兼顾了 SEO 和个性化。5. 常见问题排查与避坑实录5.1 一张表看清高频故障的根因所有 T3 新手都会走一遍下面的坎我把它们整理成速查表排查时对着看能省很多时间。症状直接原因解决办法tRPC 接口 404trpc与api路径不匹配或 middleware 挂载顺序错误检查src/app/api/trpc/[trpc]/route.ts是否正确导出了 fetch handlerPrisma 迁移时提示数据表已存在表结构不一致或迁移历史混乱用prisma migrate reset重置开发库会丢数据慎用NextAuth 登录后没有 session回调地址配置错误、session 策略不一致核对NEXTAUTH_URL、NEXTAUTH_SECRET以及 authOptions 里的 provider 配置热更新缓慢或卡死pnpm 版本不一致、旧版 Node统一 Node 20 pnpm 8必要时删除node_modules重装页面类型报错找不到api客户端没有正确创建 tRPC React 提供者在根 layout 里用TRPCReactProvider包裹全局入口Tailwind 样式突然消失构建缓存异常或配置了未包含的 content 路径检查tailwind.config.ts的content数组是否包含了src/**/*.{ts,tsx}5.2 tRPC 上下文ctx里拿不到当前用户这个是很多人的知识盲区。如果你在后端路由里想用ctx.session.user.id但运行时报 session 为 null大概率是因为你没有在 tRPC 初始化时传入 NextAuth 的 session 对象。T3 脚手架默认会把createTRPCContext放在src/server/api/trpc.ts里它里面通常会使用getServerAuthSession获取当前会话。如果你改了文件位置或者自定义过createTRPCContext注意一定把 session 传进去不然所有protectedProcedure都会失效且不会给你明确错误提示而是等到访问ctx.session.user.id时才空指针。5.3 数据库连接被塞满PostgreSQL 的默认最大连接数在 100 左右而 Serverless 环境每次函数实例都会创建新连接。很多人在 Vercel 上跑 t3code 时遇到 “Too many connections” 错误。我的建议是不管本地还是线上都用上面提到的“全局缓存 Prisma”方案同时把连接池的上限控制一下可以在DATABASE_URL上附加?connection_limit5之类的参数。如果并发量真的很大就要引入独立的连接池代理。5.4 useState 与 useQuery 混用时数据不同步在刚开始用 tRPC 时常见的困惑是“我在 mutation 成功后更新了本地 state为什么页面上的数据没有变”原因是 useQuery 的数据是从缓存里读的mutation 只改了本地 state并没有让缓存失效。解决办法不是去手动 setState 覆盖而是调用utils.post.all.invalidate()。这一点我希望每个新手都记住tRPC 的缓存是自动维护的不要试图去破坏它而是学会使用它的失效机制。5.5 一个关于修改 Prisma schema 的珍贵经验最后分享一个我在 t3code 项目里养成的习惯每次修改 Prisma schema 之后第一件事不是写业务代码而是立即运行pnpm prisma migrate dev --name change紧接着运行pnpm prisma generate。因为 tRPC 的类型推导依赖 Prisma Client 的类型如果你改了数据库字段但没有执行 generate那么你在 tRPC router 里写的新字段在编辑器里还是旧类型你会以为“代码写错了”实际是“Prisma Client 没更新”。把这两个命令固化成一个动作能省下不少排查时间。6. 从 t3code 到生产级应用的扩展方向6.1 数据层扩展从 Prisma 到 Drizzle 的迁移考量T3 栈虽然默认搭配 Prisma但生态并不是一成不变的。如果你遇到 Prisma 性能不够理想或者想更贴近 SQL 的业务编写方式可以考虑迁移到 Drizzle ORM。Drizzle 以“轻量、类型安全、对 SQL 友好”著称和 t3code 的类型闭环思路完全一致。不过我的建议是不要为了“新”而迁移。Prisma 在建模、迁移、生态工具链方面仍然很稳很多第三方服务都提供 Prisma 适配。只有当项目对数据库查询性能有极高要求、需要手写复杂 SQL 或存储过程时再考虑 Drizzle 会更值得。6.2 缓存与性能逐步引入 Redis 和增量静态生成随着访问量上升瓶颈通常会集中在数据库查询和会话校验上。t3code 中可以逐步引入 Redis 作为缓存层具体做法是在 tRPC query 方法内先检查缓存未命中再查询数据库并回填。这个过程不需要替换掉 tRPC我一般是在业务代码外围加一个cacheable装饰器功能的通用函数把 GET 类型的 procedure 包裹起来。同时Next.js 的增量静态生成ISR也能大幅提升页面性能。我之前做过的一个 CMS 项目文章更新频率很低把文章详情页改成revalidate 3600后数据库的查询压力直接下降一个数量级。6.3 多人协作时的模块拆分当一套 t3code 项目有多个开发者同时参与建议从第一天开始就把 tRPC Router 拆得足够细。每个业务模块一个文件、每个模块的权限规则在protectedProcedure层处理而不是在组件内判断。这样整个项目的人协作时不会频繁碰撞文件代码 review 也会因为小模块边界清晰而顺畅很多。7. 最后聊聊我对 t3code 这套组合的真实感受如果你是从头到尾跟着这篇文章走了一遍会发现 t3code 真正的生产力不在于任何一个单一技术有多先进而在于“类型系统从前到后贯穿始终”之后大脑里可以减少多少低效的上下文切换。传统开发里大部分时间花在“猜接口返回什么”“翻文档确认参数格式”这种琐碎事情上而在 T3 栈里编辑器就是最好的文档编译器就是最严格的质检员。我在实际使用中还有一个体会t3code 特别适合中小型项目快速验证业务想法比如内部管理后台、内容管理平台、社区类的 MVP。它的试错成本很低想加一个数据模型几分钟就能完成全链路打通。如果是超大型项目、多团队并行、需要与大量外部系统对接的复杂架构T3 栈的“一体化”特性就变成了一种约束那时更合理的做法是拆分成微服务或者独立 API。最后再分享一个小技巧每个 t3code 新项目我都习惯先做一次“Key Path 冒烟测试”——新建一个测试数据模型从 Prisma migrate 到 tRPC query 再到页面展示打通一条最简化链路。这个过程一个小时不到但它能提前暴露整个项目环境、数据库连接、鉴权配置的关键问题之后正式写业务时就可以一路狂奔。
返回列表