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 做数据库 ORM,NextAuth 处理身份认证,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-app@latest交互界面会让你选择需要的模块,我勾了 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_URL="postgresql://..." NEXTAUTH_SECRET="your-secret" NEXTAUTH_URL="http://localhost:3000"DATABASE_URL是 Prisma 用的连接串,我本地用的是 Docker 起的 PostgreSQL,所以长这样:
DATABASE_URL="postgresql://admin:admin@localhost:5432/t3code?schema=public"NEXTAUTH_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,需要用到createCallerFactory:
import { 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.ts:
import { 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 返回的有限字段。为了让前端能拿到数据库里的用户 ID(tRPC 的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()时就能拿到用户 ID,tRPC 里也能通过ctx.session.user.id来跟踪操作者。
5.4 前端鉴权的正确姿势
页面上的登录状态判断,我用的是 next-auth/react 的useSession:
const { 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 之间的返回类型约束住。这样过了两三个月回来看代码,还能一眼明白当初为什么这么分。