搞全栈的人这两年应该都听过 T3 这个缩写——TypeScript、Tailwind、tRPC 这套组合,社区热度一直很高。我自己给一个内部项目起代号就叫 t3code,用 Next.js App Router 当壳,tRPC 包住 API 层,Prisma 管数据库,Tailwind 管样式。从脚手架初始化到部署上线完整跑了一遍,前前后后折腾了将近一个季度。这篇就是想把 t3code 从 0 到 1 的整个落地过程、关键决策逻辑和踩过的坑摊开讲清楚。
我默认你至少用过 React 和 TypeScript,对 Prisma 和 Next.js 有一定了解但不深入。这篇不会停留在"跟着官方文档跑通 Hello World",而是把 t3code 项目里真正费过心思的部分——数据链路的类型打通、读写分离时的 tRPC 组织方式、发版时的数据库迁移策略——按实战顺序拆开。读完你应该能照着自己的项目复用这套经验,而不是只能复读命令。
1. t3code 的项目定义:为什么全家桶非要选 T3
先交代背景。t3code 最初的需求是一个带后台管理的 Web 应用,功能分成三块:前台内容展示、登录后的工作台、给运营用的数据看板。没有移动端,没有原生客户端,所有交互都发生在浏览器里。团队不到四个人,前后端不分开,每人都是全栈角色。
这种项目形态其实非常常见,但选型上特别容易犯两个错误:一是直接上微服务或者前后端彻底分离,结果代码仓库、接口文档、数据契约全都成了额外负担;二是贪图"快速出活"选了太重口的 Admin 框架,等到自定义业务逻辑复杂起来,框架的黑盒会让你寸步难行。
1.1 选型时为什么排除了传统 REST 方案
最早我也考虑过 Next.js 只做前端渲染层,后端单独起一个 NestJS 或者 Fastify,两边通过 REST API 通信。这在团队人数够多、接口文档纪律够好的团队里没问题,但对 t3code 这种四个人不到、需求天天变的小团队来说,代价太高了。
REST 模型的本质是"资源 + 动作",你为了给前端提供数据,得先设计资源路径、定义 DTO、维护 request/response 的类型声明,然后前端要自己写一层 fetch 封装,处理错误、鉴权头、loading 态。这一套工序下来,每次新加一个字段要动至少三个文件——Prisma schema、后端 DTO、前端 interface。改一个对象属性,类型定义散落三处,任何一个忘记同步,运行时就可能给你颜色看看。
tRPC 正好把这个痛点砍掉了。它让前端直接以函数调用的方式触达数据库查询逻辑,类型跟着 TypeScript 的类型推断一路从 Prisma schema 保真传到组件里。你在服务端改了返回字段,前端编译立刻报错,根本不会出现"前后端以为字段名一致,其实不一致"这种尴尬。
1.2 加一个页面级框架的意义
t3code 选择 Next.js 而不是纯 Vite + React,考虑点是路由和服务器端能力。虽然纯 SPA 也能做,但内容展示部分对首屏要求高,Next.js 的 RSC 和 SSR 可以让核心内容直接 HTML 输出,搜索引擎抓到的就是实实在在的文字。
当然,选了 Next.js 也意味着要接受它的心智负担。App Router 的 server component、client component 边界,缓存策略的 revalidate 规则,这都是后话。初期 t3code 的页面实现比较简单,我把大部分组件标成 client component,服务端只负责初始数据的预取,过渡期并没有太痛苦。
2. 从 create-t3-app 开始的工程骨架:哪些默认约定必须理解
t3code 不是从一个空目录手搓的,而是直接用 create-t3-app 生成的。脚手架这类东西,很多开发者觉得跑一下就行,但生成出来的项目结构里其实隐藏着一套设计哲学,理解它比记住命令重要得多。
创建命令是:
npx create-t3-app@latest t3code它会问你几个问题:用不用 Next.js、用不用 Tailwind、用不用 tRPC、用不用 Prisma、用不用 NextAuth。t3code 全部选了 yes,因为这几个正好构成一条完整链路:Prisma 定义数据,tRPC 暴露操作,NextAuth 管理身份,Next.js 渲染,Tailwind 画皮。
2.1 生成后的目录结构逐个拆解
t3code 生成后的核心目录如下:
src/ ├── app/ # Next.js App Router 页面目录 │ ├── (auth)/ # 认证相关路由组 │ ├── (dashboard)/ # 工作台路由 │ ├── api/ │ │ └── trpc/ # tRPC HTTP 入口 │ ├── layout.tsx │ └── page.tsx ├── components/ ├── pages/api/auth/ # NextAuth 路由入口 ├── server/ │ ├── api/ │ │ ├── root.ts # 所有子路由聚合 │ │ └── routers/ # 业务路由文件 │ ├── auth.ts # NextAuth 配置 │ └── db.ts # Prisma Client 实例 ├── trpc/ │ ├── server.ts # 服务端 tRPC 初始化 │ ├── client.ts # 客户端 tRPC 封装 │ ├── query-client.ts # React Query 配置 │ └── react.tsx # Provider 组件 └── styles/我建议你特别重视src/server/api/routers和src/trpc这两块。t3code 后期几乎所有的业务代码都集中在 routers 里,每个文件对应一个领域模型。而trpc/react.tsx里的 Provider 挂在全局布局上,前端所有组件都能直接调用 useQuery 系列方法,不需要到处 import API 函数。
2.2 环境变量的类型推导约定
t3code 最让我觉得舒服的一点,是脚手架默认集成了env校验。这个机制来自t3-env,它会在服务端启动时校验必需的环境变量,缺了就报错,避免你带着坏配置上线。
.env 示例:
DATABASE_URL="postgresql://user:password@localhost:5432/t3code" NEXTAUTH_SECRET="your-secret-here" NEXTAUTH_URL="http://localhost:3000"然后在src/env.js里用一套 schema 声明格式:
export const env = createEnv({ server: { DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(1), }, client: { NEXT_PUBLIC_APP_NAME: z.string().optional(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME, }, });server字段下的变量只在服务端可读,client字段下必须带NEXT_PUBLIC_前缀,这样就能避免你把数据库密码不小心打进浏览器 bundle。这套机制在后来的部署阶段帮我挡掉了一次事故——拉新环境时忘配某个变量,启动直接报错,而不是等到运行时才出现 500。
3. 数据链路打通:从 Prisma 建模到 tRPC Router
这一步是 t3code 的前期核心工作。不夸张地说,如果这一层设计得不够稳,后面所有页面功能都要跟着返工。
3.1 数据模型设计的实际取舍
t3code 核心数据模型有 Users、Projects、Tasks、AuditLogs。我重点说说 Projects 和 Tasks 的关系。
初版建模时,我把任务的详情字段直接塞在 Task 表里,包括项目关联、创建人、标题、描述、截止时间等十几个字段。看起来没毛病,但真正做工作台的时候发现一个痛点:用户在列表页只需要标题、状态、截止时间,详情页才需要全部字段。Prisma 默认查询会返回所有 scalar 字段,一个列表接口拖出大量无关数据,负载虽然不大,但响应体积明显过剩。
后来把 Task 拆成 TaskSummary 和 TaskDetail 两个模型,用一对一关系关联。列表查询只读 summary 部分,进入详情再按需读 detail。Prisma 的select也能做到类似效果,但那是查询层优化,模型层拆分的好处在于约束了业务上"哪些字段属于高频区"的边界,团队协作时不容易再往里随便加字段。
3.2 从 Prisma Client 到 tRPC 查询:类型一路保真
tRPC 最爽的地方,就是类型不用手工声明。定义 router 的时候直接用 Prisma 推断出来的类型,前端拿到的数据天然带完整类型。
举个例子,查询项目列表的 router 大概是:
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const projectRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ orgId: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.project.findMany({ where: { orgId: input.orgId }, orderBy: { updatedAt: "desc" }, include: { tasks: { select: { id: true, status: true } } }, }); }), });前端组件里:
const { data: projects } = api.project.list.useQuery({ orgId });projects的类型就直接是(Project & { tasks: { id: string; status: TaskStatus }[] })[],完全不经过手写 DTO。改字段、加关联,类型跟着变,编译期就能发现任何不一致。
这一整条链路里有两个关键来源:Prisma 的 schema 生成 client 类型,tRPC 的 inference 把 procedure 的返回类型导出给 useQuery 使用。你不需要知道每个泛型的实现细节,但要明白一件事——类型安全的源头只有一个,就是 Prisma schema,其他所有类型都是推导出来的。
3.3 第一个 mutation:创建任务时的校验逻辑
mutations 的典型场景是创建任务。t3code 里我是这样组织的:
create: protectedProcedure .input( z.object({ projectId: z.string(), title: z.string().min(1).max(100), dueDate: z.date().optional(), }) ) .mutation(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ where: { id: input.projectId }, select: { ownerId: true }, }); if (!project) { throw new TRPCError({ code: "NOT_FOUND" }); } if (project.ownerId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } return ctx.db.task.create({ data: { title: input.title, dueDate: input.dueDate, projectId: input.projectId, createdById: ctx.session.user.id, }, }); }),这里的校验有两个层次:第一层用 zod 对入参做格式校验,title 的长度、必填项;第二层是业务权限校验,确认当前登录用户确实有这个项目的操作权。第二层校验必须在数据库层做一次实际查询,不能只靠前端隐藏按钮,因为 tRPC 的 endpoint 是可以被直接 curl 的,绕过 UI 后权限校验就全靠服务端。
4. 认证与权限:NextAuth 与 t3code 的深水区
t3code 的登录需求很简单:邮箱密码注册登录,支持 Google OAuth,登录后根据角色控制前端菜单和接口权限。NextAuth 在这个场景里算是最顺手的选择,但实际嵌进去有非常多细节需要处理。
4.1 Session 回调的类型覆盖问题
跑过 NextAuth 的都知道,默认的 Session 类型里只有user.name、user.email、user.image,但你业务里通常要拿user.id、role。不带类型扩展直接读session.user.id会在 TypeScript 里直接报错。
t3code 的做法是在src/types/next-auth.d.ts里补充类型:
import { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; role: "ADMIN" | "MEMBER"; } & DefaultSession["user"]; } interface User { role: "ADMIN" | "MEMBER"; } }同时必须在callbacks.session里显式把数据库里的 role 字段塞向 session,否则前端拿不到:
callbacks: { async session({ session, user }) { if (session.user) { session.user.id = user.id; session.user.role = user.role; } return session; }, },这里有个坑:默认的session回调里user参数未必存在,当你用数据库 session 策略时尤其容易踩到。建议设置session.strategy = "database",配合 Prisma 存储 session,以便在查询数据库获取用户信息后,取最新的 role 值然后合并进 session。
4.2 中间件路由保护的正确姿势
Next.js 中间件配合 NextAuth 做页面级保护是常见方案。t3code 在src/middleware.ts里写的是:
export { default } from "next-auth/middleware";然后给需要保护的路由在src/app/(dashboard)/layout.tsx里加了getServerSession判断,没登录就 redirect。
这里我强烈建议别只依赖全局中间件。单独用 middleware 做保护有个烦人的问题:它会包住所有匹配路径,而你不一定希望所有子页面都强制登录。所以 t3code 是双保险——middleware 只针对/api/trpc和 dashboard 根路径匹配,每个敏感布局里再通过getServerSession检查当前会话,没会话返回 redirect。
中间件运行在 Edge Runtime,默认没法直接读 Prisma 实体,所以只做基础 session 存在性判断,业务级权限校验还是要回到 server 端 tRPC 层。
4.3 轻量角色权限实现
角色权限我用的不是完整 RBAC 框架,而是给 User 表加一个 enum 角色字段,在 protectedProcedure 里二次判定。按项目维度做细粒度权限时,用一个 ProjectMember 关联表,存projectId、userId、role三列。
t3code 的权限逻辑可以抽象成一个可复用方法:
export function assertProjectRole( { ctx, projectId, allowed }: { ctx: ProtectedContext; projectId: string; allowed: Role[]; } ) { const membership = await ctx.db.projectMember.findUnique({ where: { projectId_userId: { projectId, userId: ctx.session.user.id }, }, }); if (!membership || !allowed.includes(membership.role)) { throw new TRPCError({ code: "FORBIDDEN" }); } }在 router 里每次 mutation 前先调一遍,就能控制某个 project 下谁能编辑、谁能删除。这种做法的优点是心智负担小,不引入外部 RBAC 库就能覆盖 90% 的内部工具项目场景。
5. 部署到生产:t3code 从本地到线上的最后一公里
本地跑通只是第一步,部署阶段才是真正把人逼疯的地方。t3code 选择的部署目标是 Vercel,数据库用 PostgreSQL,托管在 Neon。这套组合的好处是零运维,坏处是容易出现一些本地和云端环境不一致的问题。
5.1 数据库连接池与迁移策略
t3code 初期在 Vercel 上频繁遇到数据库连接数被打满的问题。原因很简单:Serverless 环境下每个函数实例都会创建新的 Prisma 客户端连接,冷启动实例一多,连接池直接爆掉。
应对办法是全局复用 Prisma 实例:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient({ log: ["query"], }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;配合 Prisma 官方推荐的加速器功能,在连接串上加?pgbouncer=true&connect_timeout=10,能减少连接占用。这一步非常重要,上线后数据库连接不报了,才算真正的稳定。
数据库迁移是另一个重灾区。t3code 在 schema 改完之后一直习惯跑npx prisma migrate dev,这在本地没任何问题,但生产环境千万不能直接跑。正确的流程应该是:
# 本地生成迁移文件 npx prisma migrate dev --name add_new_field # 提交到仓库,然后在 CI 或发布流程中执行 npx prisma migrate deploymigrate deploy 只应用已提交到 migrations 目录的文件,不会创建新的迁移,不会悄悄修改 schema,也不会在连接串混乱时误操作生产库。这个区别如果没搞清楚,一个手滑就可能把生产数据重置掉。
5.2 上传文件存储与静态资源
t3code 前台上传的头像和项目附件,最初直接存在本地目录public/uploads,本地跑没问题,部署到 Vercel 后才发现完全不可靠——Serverless 文件系统是只读的,实例销毁后写入就消失。
建议是所有用户生成内容一律走对象存储。我和团队最后选择的是直接接 S3 兼容服务,上传时通过预签名 URL 方式,不用把文件经服务器中转,省流量的同时也能减轻延迟压力。
核心流程大概三步:
- tRPC mutation 请求一个 upload URL 并传递文件名和类型
- 前端直接 PUT 文件到对象存储
- 完成后把最终的 CDN 地址写回数据库
这套方案在本地不太好模拟,但我劝你别偷懒,生产环境一定得做。
5.3 日志与错误监控
t3code 初期的排查方式是看平台的实时日志,后来发现按函数查日志完全够用,但缺少请求链路追踪,出了问题定位慢。后来接了一个简单可观测方案:把错误记录统一吐到一个日志收集端点,在 tRPC 的 errorFormatter 里统一拦截。
function errorFormatter(opts) { if (opts.error.code === "INTERNAL_SERVER_ERROR") { // 这里可以通知监控系统 console.error(opts.error); } return { message: opts.error.message }; }这样做的意义在于,前端拿到的是用户友好的错误 message,开发端又能保留完整的堆栈信息。生产环境里不要直接把原始 error 抛给前端,SQL 报错信息里往往会带表名和字段信息,安全上不划算。
6. 性能调优与体验打磨:t3code 跑起来之后的那些优化
功能全部上线之后,大部分精力都花在性能和体验调优上。这一章的内容比较杂,我把最值得说的几点分开讲。
6.1 tRPC 批量查询与 React Query 缓存策略
t3code 的 dashboard 页面有多个区块:本周任务、项目进度、活动日志。刚开始这三个数据用三个独立的 tRPC 查询,数组上旋的 loading 状态不同步,页面总是东一块西一块地跳。
后来改成用一个聚合 router,在 query 里同时查多个数据源:
dashboard: protectedProcedure.query(async ({ ctx }) => { const [tasks, projects, logs] = await Promise.all([ ctx.db.task.findMany({ where: { ... }, take: 5 }), ctx.db.project.findMany({ where: { ... }, take: 5 }), ctx.db.auditLog.findMany({ where: { ... }, take: 10 }), ]); return { tasks, projects, logs }; }),一个查询拿到全部三个区块的数据,页面加载时统一 loading 结束。React Query 缓存也跟着受益——聚合查询的缓存条数变少,也不再出现多个 key 分别过期导致数据不同步的尴尬。
缓存策略上,React Query 默认 staleTime 为 0,意味着每次页面重新聚焦都会重新请求。t3code 明确调整了两个参数:
- 对实时性要求不高的数据(如项目列表),设置
staleTime: 60_000 - 对用户 wallet 积分这类变化频繁的数据,不设 staleTime,默认重新获取
这个选择直接影响请求量,尤其是大中午全员在线操作的场景,请求数差异是倍数级别的。
6.2 图片加载优化
t3code 的内容页里有若干大图展示,早期直接 img 标签,首屏加载图片时间明显影响性能评分。切到 Next.js 自带的next/image组件后,问题解决了大半。它会自动做 responsive image、懒加载、优先级标记。
有一个注意点:next/image如果接外部域名,必须在next.config.js里配remotePatterns,否则生产环境直接报错。上传的文件在对象存储上,域名比较长,配置的时候注意 pattern 别写错,否则带 query string 的图片也可能被挡下来。
另外,预加载策略我建议只对首屏首图设置priority属性,其他区块图片保持默认懒加载。不然页面会一股脑把所有首屏图片请求完,白白浪费带宽。
6.3 错误边界与加载体验
t3code 在全局 layout 里加了一个 React ErrorBoundary,避免某个区块抛错导致整页白屏。tRPC 的错误尽量在 UI 层拦截,比如 useQuery 返回的isError状态时渲染一个重试按钮,而不是直接交给页面上方的红色 error overlay。
加载状态的设计说不上高级,但很实用:列表页用骨架屏,详情页用局部 spinner,按钮提交后 disable + 显示 loading 文案。这些没有技术含量,但对用户体感提升最直接。
最后分享一个我实际操作中的体会:t3code 这套技术组合最值钱的不是任何一个单独的框架,而是类型安全的完整性。从一个字段的 database schema、到 API 入参出参、再到 React 组件的 props,类型信息从头到尾一条线贯穿。你修一处 schema,剩下所有联动错误都在编译期告诉你,改起来特别踏实。这种体验一旦习惯,再回去写 passport 风格的后端接口,你第一反应就是不适应。
如果你正在评估要不要上 T3 这套体系,我建议直接找一个小功能跑一下。不要起大项目,就做个登录 + 数据列表,感受一下从 schema 到组件的链路是不是真的像宣传说的零断点。任何一个环节让你觉得别扭,说明它不适合你的场景;但如果你也像我一样觉得顺畅,那后面的生产落地,基本上就是踩坑、填坑、慢慢变稳的过程了。