资讯详情

资讯详情

T3 Stack全栈实践:从tRPC到部署的完整技术指南

“t3code”这名字说实话一开始就是随手打的。当时我想给一个基于 T3 Stack 的实验性全栈项目起个代号方便在 Git 提交和本地文件夹里称呼结果这个四个字符的组合一直沿用到了线上。整个项目从零到部署技术选型基本沿用了 T3 Stack 的默认路线Next.js、TypeScript、tRPC、Prisma、NextAuth、TailwindCSS。这一整套东西在社区里口碑不错但真正亲手把每个环节串起来、踩过一遍坑之后我对它的评价确实变了好几次。这篇文章不打算写成一份官方教程的复读而是想完整记录一下我在 t3code 这个项目里做技术决策、搭数据层、写 tRPC 路由、接认证、部署上线的全过程。如果你正准备用 T3 Stack 做自己的项目或者已经在半路上卡住了这里面的经验应该能帮你省不少折腾时间。1. t3code 的技术底座为什么这个组合值得认真对待1.1 T3 Stack 到底由什么组成T3 Stack 不是一个新框架它更像是一套“默认值”。核心组件没有花哨的新东西Next.js 承担页面和接口宿主TypeScript 负责全链路类型tRPC 替代传统 REST 接口层Prisma 做数据库 ORMNextAuth 处理身份认证TailwindCSS 管样式。我之所以最终选择这一套是因为它对“一个人的全栈项目”特别友好。传统前后端分离的做法需要我同时维护两套类型定义后端写一套接口返回结构前端再手写一遍对应的 interface。一旦改字段两边很容易不同步而 tRPC 最大的特点就是把类型系统天然打通了。t3code 里我写一个数据库查询函数前端直接调用它返回值的类型是自动推断出来的不需要额外写 API 文档也不需要手搓 axios 的泛型。另外一点是减少样板代码。以前用 Express 写 REST 接口每个路由都要单独建文件、注册路径、处理 CORS、定义请求体校验。tRPC 的 router 写法简单很多一个文件可以挂多个查询和变更逻辑按业务拆开就行。1.2 我对类型安全真实价值的重新认识没实际用之前我以为类型安全只是一堆 TS 泛型带来的“编译期爽感”。但 t3code 做到一半的时候我改了一个数据库字段名从postContent改成content。如果是传统 REST 架构前端大概率要等到运行时才发现接口返回变了。而在 tRPC 项目里前端调用处的 TypeScript 直接报错我去哪里改、有哪些地方引用了旧字段编辑器里全标出来了。这种体验不是“锦上添花”而是实实在在减少了一次线上事故的可能。当然类型安全也不是没有代价。刚上手 tRPC 时对createTRPCContext、createCallerFactory这些概念不熟悉很容易觉得配置繁琐。这里我的建议是第一次接触不要直接看高级用法先老老实实跑通一个最小 router再逐步加鉴权、加错误处理。t3code 的初始版本就是在最小改动的前提下一点点加复杂度的。2. 从 create-t3-app 初始化到本地跑通脚手架没你想的那么简单2.1 初始化命令与选项选择我用的命令很常规npm create t3-applatest交互界面会让你选择需要的模块我勾了 Next.js、Tailwind、tRPC、Prisma、NextAuth。第一次初始化时有个选择App Router还是Pages Router。新版 create-t3-app 默认是 App Router我建议直接选 App Router虽然学习曲线稍微陡一点但配套的 tRPC server 写法已经是基于 App Router 优化的网上资料也更偏向新版。初始化完成后项目结构大概是这个样子src/ app/ api/trpc/[trpc]/route.ts layout.tsx page.tsx server/ api/ routers/ post.ts root.ts trpc.ts db.ts auth.ts trpc/ react.tsx server.ts这个目录一开始看容易懵但稍微理一下就能想明白src/server里面放的是所有服务端逻辑数据库实例、认证配置、tRPC 路由定义都在这里src/trpc是前后端共享的类型入口和 React 绑定层。2.2 环境变量配置第一道坎create-t3-app 初始化完成后项目里有个.env.example文件我直接复制成.env然后开始填DATABASE_URLpostgresql://... NEXTAUTH_SECRETyour-secret NEXTAUTH_URLhttp://localhost:3000DATABASE_URL是 Prisma 用的连接串我本地用的是 Docker 起的 PostgreSQL所以长这样DATABASE_URLpostgresql://admin:adminlocalhost:5432/t3code?schemapublicNEXTAUTH_SECRET可以用 openssl 生成openssl rand -base64 32这一步很关键如果用固定字符串NextAuth 在开发模式下可能没问题但部署到线上之后 session 会经常失效。NEXTAUTH_URL在本地是http://localhost:3000上生产要改成实际域名。提示.env不要提交到 Git。create-t3-app 默认生成的.gitignore已经把它排除了但如果你是自己手动拼的项目一定要加这一条否则数据库连接串会直接暴露在仓库里。2.3 数据库同步与初始迁移环境变量准备好之后先跑 Prisma 的迁移命令npx prisma migrate dev --name init这条命令会读取prisma/schema.prisma里的定义在数据库里创建表结构。如果数据库连接有问题这一步会直接报错常见错误是连接串里的密码包含了符号没做编码或者 PostgreSQL 服务没启动。跑完迁移后可以顺手打开 Prisma Studio 看一眼数据表npx prisma studio这个图形化界面很方便开发阶段直接在里面改数据、核对关联关系比命令行查来查去直观得多。3. 数据模型设计t3code 的表结构是怎么一步步定下来的3.1 需求梳理与模型拆分t3code 的业务逻辑不算复杂用户登录、发布内容、内容附带标签、用户可以浏览列表。但越简单的需求越考验数据建模的合理性。我最初的版本只设计了一张 Post 表把所有字段都塞进去后来为了加标签功能不得不把多对多关系补进去还顺手重构了一次代码。如果一开始想清楚后面能省不少事。实际用到的 Prisma schema 大概是这样的model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] posts Post[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String authorId String author User relation(fields: [authorId], references: [id]) tags Tag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique posts Post[] }这里我故意把多对多关系放在posts和tags上面Prisma 会自动创建一张中间表不需要手写。实际开发中这种隐式的关联表在迁移文件里能看到叫_PostToTag不用管它直接用include或where查询就行。3.2 为什么主键选中字符串而不是自增数字我把主键设成了cuid()而不是 PostgreSQL 常见的自增id。主要原因是业务上不需要对外暴露记录序号字符串主键还能避免爬虫猜遍历地址的问题。另一个好处是在迁移脚本和集群环境下不需要考虑自增序列的冲突。缺点也很明显字符串主键在索引上比整数稍微慢一点点但 t3code 这种量级根本感知不到差别。3.3 字段变更的迁移经验开发过程中改字段是很频繁的比如我给 Post 加了一个slug字段slug String unique然后执行npx prisma migrate dev --name add-slugPrisma 会自动生成迁移 SQL并且同步更新客户端类型。这里有一点值得注意如果项目已经部署上线再新增必填字段时需要先考虑已有数据怎么办。稳妥的做法是先加一个可空字段或带默认值等数据补齐后再改成必填。t3code 因为在开发阶段频繁重置过数据库所以这个问题不严重但如果你手上有线上项目一定不要在生产库直接prisma migrate reset那会把所有数据清空。4. tRPC 路由设计接口层像函数调用一样自然的代价4.1 tRPC 的运作逻辑tRPC 的核心思路是“过程调用”把服务端的函数直接暴露给前端调用。一个最小的 router 写起来是这样import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from /server/api/trpc; import { db } from /server/db; export const postRouter createTRPCRouter({ list: publicProcedure .query(async ({ ctx }) { const posts await ctx.db.post.findMany({ include: { author: true, tags: true }, orderBy: { createdAt: desc }, }); return posts; }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string(), tagNames: z.array(z.string()) })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: { connectOrCreate: input.tagNames.map((name) ({ where: { name }, create: { name }, })), }, }, }); }), });前端的调用大概是这样的const utils api.useUtils(); const createPost api.post.create.useMutation({ onSuccess: () { utils.post.list.invalidate(); }, }); createPost.mutate({ title: 你好, content: 正文内容, tagNames: [React, Next.js], });我没写fetch没写axios也没定义返回类型但数据会自动被序列化到前端并且类型是完整推导出来的。utils.post.list.invalidate()会在创建成功后自动刷新列表数据这套逻辑在字符量大的项目里特别香。4.2 路由拆分的实操思路刚开始我把所有接口都挂在根 router 上结果 root.ts 文件越来越大。后来我按业务拆成了postRouter、userRouter、tagRouter再通过 root.ts 合并export const appRouter createTRPCRouter({ post: postRouter, user: userRouter, tag: tagRouter, });这样前端调用路径就变成api.post.list、api.user.profile语义清晰也方便单元测试。拆分的颗粒度不要太小一个业务模块 3-6 个 procedure 比较合适拆太细会变成另一种维护负担。4.3 鉴权 procedure 是怎么区分公共和受保护接口的t3code 里有两类接口公共接口浏览帖子列表和受保护接口创建帖子、删除帖子。tRPC 的写法是通过 procedure 来区分的create-t3-app 默认在trpc.ts里导出了publicProcedure和protectedProcedure后者内部已经做了 session 判断export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });这个中间件机制非常直观请求进来先查 session没有登录直接抛 401。前端的useQuery或useMutation收到错误后可以显示一个统一的登录提示。4.4 一个容易踩的坑服务端组件里如何调用 tRPC在 App Router 模式下服务端组件里调用 tRPC 的方式和客户端组件完全不同。t3code 首页这种主要是静态展示的页面我是用服务端组件直接查数据库而不是走 tRPC因为服务端本来就有数据库访问能力没必要再发一次 HTTP 请求给自己。只有需要交互、需要跟随登录状态变化的页面区域我才用api.*的客户端 hooks。如果确实要在服务端走 tRPC需要用到createCallerFactoryimport { createCallerFactory, createTRPCContext } from /server/api/trpc; import { appRouter } from /server/api/root; const createCaller createCallerFactory(appRouter); const caller createCaller(await createTRPCContext({ headers: headers() })); const data await caller.post.list();这里createTRPCContext需要传 headers 参数主要是 NextAuth 的 session 读取依赖请求头里的 cookie。这个细节容易翻车我一开始在服务端调用返回全是 401后来检查才发现是没有正确传递 headers。5. NextAuth 接入登录认证不止是“能登录就行”5.1 Provider 与 Session 的基本配置t3code 的登录页我放了两个入口邮箱登录和 GitHub OAuth。开发阶段用 GitHub 登录最省事因为 callback URL 配置简单不需要额外审批。NextAuth 的初始化配置放在了src/server/auth.tsimport { getServerSession, type NextAuthOptions } from next-auth; import GitHub from next-auth/providers/github; export const authOptions: NextAuthOptions { providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], session: { strategy: jwt, }, };把 session 策略设成jwt而不是默认的 database session是因为 t3code 不想为 session 单独建表也不希望每次请求都查一次数据库。JWT 模式下用户信息是签在 token 里的请求到来时用NEXTAUTH_SECRET验证一下就行。5.2 把用户信息同步到业务表NextAuth 的 JWT 策略有一个需要注意的点OAuth 登录用户的信息不会自动写进 Prisma 的 User 表需要我们自己在events回调里同步。这是我踩过的一个比较明显的坑登录成功后页面明明显示“已登录”但数据库里找不到这个用户导致 tRPC 的authorId插入外键失败。解决办法是在 NextAuth 配置里加上events: { async signIn({ user }) { const dbUser await db.user.findUnique({ where: { email: user.email! }, }); if (!dbUser) { await db.user.create({ data: { email: user.email!, name: user.name, image: user.image, }, }); } }, },因为 Provider 拿到的email是唯一的用它做findUnique的查询条件是合理的。这里的一个小细节是user.image可能为 null所以 Prisma schema 里 image 字段要允许空。5.3 session 回调里的用户信息补全JWT 模式下默认的 session 内容只有 Provider 返回的有限字段。为了让前端能拿到数据库里的用户 IDtRPC 的authorId依赖它需要在callbacks里把session.user.id补进去callbacks: { async session({ session, token }) { if (session.user) { session.user.id token.sub!; } return session; }, },token.sub就是 NextAuth JWT 里的标准用户标识这里直接把它映射到session.user.id上。前端调用useSession()时就能拿到用户 IDtRPC 里也能通过ctx.session.user.id来跟踪操作者。5.4 前端鉴权的正确姿势页面上的登录状态判断我用的是 next-auth/react 的useSessionconst { data: session } useSession(); if (!session?.user) { return div请先登录/div; }但要注意这个状态是异步加载的首屏渲染时session可能还是null直接判断会导致页面闪一下登录界面。t3code 的做法是结合 React 状态判断一个isLoading或者在全局 layout 里用 SessionProvider 包一层避免每个组件里都重复处理加载逻辑。6. 从本地到生产环境部署 t3code 的真正考验6.1 部署前必须确认的三件事把 t3code 推到线上前我列了一个检查清单照着过一遍再点 Deploy能避免大部分低级失误。第一prisma generate是否已经执行。数据库模型更新后Prisma Client 的类型和查询引擎都得重新生成本地跑没问题不代表构建机上也存在最新产物。稳妥做法是在 package.json 的 build 脚本里加上它build: prisma generate next build第二环境变量是否齐全。Vercel 上需要配置DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL、GITHUB_CLIENT_ID、GITHUB_CLIENT_SECRET。其中NEXTAUTH_URL必须填线上域名否则 GitHub OAuth 的回调地址会不匹配。第三生产数据库的迁移文件是否已经应用。开发环境可以直接prisma migrate dev生产环境不能用 dev 模式正确做法是npx prisma migrate deploy这条命令会只执行尚未应用的迁移文件不会做任何破坏性操作也不会清空表。6.2 数据库选型与连接串t3code 的生产数据库一开始用的是 Vercel Postgres后来因为免费额度限制迁移到了 Neon 的免费计划。两者的 PostgreSQL 协议是兼容的Prisma 不需要改连接方式只是连接串换成pooler模式后有一个坑非常隐蔽Neon 的连接池地址包含-pooler后缀这个地址只适合做连接池代理。Prisma 的某些事务操作比如交互式事务走连接池可能会出现prepared statement already exists的报错。解决方式是把 Prisma schema 里的连接地址拆两个变量一个给普通查询一个给事务或者干脆用直接连接地址绕过连接池。我最后图省事直接用了非 pooler 的直连地址t3code 的量级完全够用。6.3 线上发现的第一个问题冷启动慢部署到 Vercel 后我遇到的第一个实际问题是冷启动响应慢。Serverless 环境下每个新实例启动时都要重新连接数据库、加载 Next.js 应用首次访问可能要等好几秒。Vercel 的配置里我调整了项目的regions尽量把函数部署到离数据库更近的区域减少跨区网络延迟。由于数据库在国内访问本身就慢t3code 的体验只能说尚可如果追求极致速度后端到数据库之间加一层连接池会好很多。6.4 线上日志和错误监控开发模式下的报错在终端里看线上就没那么舒服了。t3code 我接了 Sentry配置文件其实很精简import * as Sentry from sentry/nextjs; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, tracesSampleRate: 0.2, });当时接入 Sentry 的主要原因不是性能监控而是 tRPC 抛出的UNAUTHORIZED和其他业务错误在线上有没有被正确捕获、哪些接口被反复报错我需要一个面板能看到。实践证明刚上线的第一周它帮我发现了一个很要命的缺陷某些手机浏览器的 cookie 无法写入导致所有需要登录的接口都在 401而桌面端一切正常。如果没有日志上报这个问题可能要等用户主动反馈才知道。7. 开始下一个项目时我会怎么做跑过 t3code 的整个生命周期我对 T3 Stack 的看法从“花哨”变成了“顺手”。这个技术栈不会让你少写业务代码但它把“类型不一致”这类问题从运行期压到了编译期省下的调试时间会在项目中后段非常可观。如果现在从头再做一个类似项目有几点我会直接一步到位数据结构先在纸上画清楚再写 Prisma schema认证相关代码从第一天就放到独立的 auth 模块而不是散落在页面里部署前先确认数据库迁移方式不要想当然直接用migrate dev对付生产库。最后分享一个小技巧create-t3-app 默认生成的代码结构已经很合理但业务一复杂root.ts里的 router 数量和依赖关系会很快膨胀。我建议每次新增 router 时顺手在root.ts文件顶部写一行注释标明用途同时用 TypeScript 的satisfies或显式类型标注把 router 之间的返回类型约束住。这样过了两三个月回来看代码还能一眼明白当初为什么这么分。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →