
t3code 全栈项目实战复盘从选型到上线的完整记录T3 Stack 这套东西最初我是抱着观望态度看的。Next.js 加 TypeScript 加 tRPC 加 Prisma 加 Tailwind听起来是五个热词硬凑在一起直到自己动手把一个完整的 t3code 项目从零推到生产环境才意识到这套组合拳真正的价值在哪。这篇文章就围绕 t3code 这个项目的完整落地过程把我踩过的坑、想明白的原理、以及最终沉淀下来的工程化套路全部摊开讲适合正在纠结技术选型、或者已经入了 T3 坑但被类型问题折磨的开发者参考。t3code 本质上是一个基于 T3 Stack 的全栈应用目标很纯粹用一套 TypeScript 类型贯穿服务端和客户端把从前端调用后端接口时的类型断裂问题彻底解决掉。整套应用包括了 PostgreSQL 数据库、Prisma 作为 ORM、Next.js App Router 做页面与路由、tRPC 负责 API 层再用 Tailwind 处理样式。整个过程中最让我觉得值回票价的是 tRPC 带来的端到端类型安全——类型不是复制粘贴出来的而是直接从服务端推导到组件里。1. 技术选型与整体设计思路1.1 为什么选择 T3 Stack而不是传统的 REST 或 GraphQL先回答一个很多人会问的问题既然 REST 已经足够成熟GraphQL 也火了这么久为什么还要折腾 tRPC我当时的判断有三点做完整个 t3code 项目之后回头看这三点全部被验证了。REST 的痛点不在“能不能用”而在“类型怎么同步”。前端和后端各自维护一份类型定义接口一改要么前端拿到 undefined 才知道出事要么得靠 codegen 脚本在启动前重新生成一遍类型。GraphQL 解决了类型同步问题但引入了 schema 语言和 resolver 的复杂度一个小团队要维护的成本并不低。tRPC 的做法非常直接所有过程都写在服务端前端直接 import 类型TypeScript 编译器自己推导不需要任何代码生成步骤。选 T3 Stack 还有一个很务实的理由——它的生态组件全部围绕 Next.js 展开没有历史包袱。Next.js 的 App Router 提供了 RSC 和服务端组件Tailwind 这些年也迭代到了 JIT 编译Prisma 的客户端查询引擎在性能上已经足够覆盖绝大多数业务场景。这套选型放在三年前可能有点超前放在今天就是一套相当顺手的默认配置。1.2 架构分层与目录规划t3code 的项目目录是 T3 Stack 的标准结构但我在实际项目中做了几处微调重点是把业务逻辑和传输层解耦避免所有代码都堆在 API route 里。src/ ├── app/ # Next.js App Router 页面 │ ├── api/trpc/ # tRPC HTTP 入口 │ ├── (auth)/ # 登录注册相关路由 │ └── dashboard/ # 主业务页面 ├── server/ │ ├── api/root.ts # tRPC 根路由聚合 │ ├── api/routers/ # 按业务域拆分的 router │ ├── auth/ # NextAuth 配置 │ └── db/ # Prisma Client 单例 ├── trpc/ │ ├── server.ts # 服务端 tRPC 初始化 │ └── client.ts # 客户端调用封装 └── lib/ # 工具函数、常量、通用类型这套分层的关键在于server/api/routers这个目录。每个业务域用户、项目、任务、文件一个文件每个文件暴露一个 router最终在root.ts里聚合。这样做最大的好处是单个文件的行数控制在合理范围内职责边界清晰新增接口时不用全局搜索路由定义。TypeScript 是整个项目的骨架这一点值得单独强调。T3 Stack 的灵魂就是从 schema 定义到数据库模型、再到前端组件全程由 TypeScript 类型贯穿。项目里几乎没有any每一层的数据边界都是可以静态检查的。我在跑通整个流程后最大的感受是类型系统不是限制而是细绳——它拽着你不让你往坑里跳。2. 核心细节解析与实操要点2.1 tRPC 的端到端类型安全是怎么实现的理解 tRPC 的原理是玩转 T3 Stack 的前提。tRPC 的服务端导出一个类型化的 router 对象客户端通过createTRPCClient或者 Next.js 的TRPCReactProvider来消费它。关键在于 TypeScript 的infer能力客户端引入的不是一份手写的 API 定义而是直接从服务端 router 推导出来的类型。用实际代码解释。服务端定义一个过程// server/api/routers/task.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const taskRouter createTRPCRouter({ getByProjectId: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) { return ctx.db.task.findMany({ where: { projectId: input.projectId }, orderBy: { createdAt: desc }, }); }), });这时候客户端前端组件里直接调用const { data, isLoading } api.task.getByProjectId.useQuery( { projectId: project.id }, { enabled: !!project.id } );注意这两段代码之间没有任何 codegen 步骤。编译器看到api.task.getByProjectId时会反向推断出projectId必须是stringdata的类型就是 Prisma 返回值类型。如果服务端改了返回结构前端 TS 检查立即报错而不是等到线上跑挂了才暴露。实操中要注意的是 protectedProcedure 的抛错行为。未登录用户调用受保护过程时tRPC 默认返回UNAUTHORIZED错误码。这个行为在本地开发时不会暴露问题但放到线上如果前端没处理好错误态会出现接口 401 之后页面直接白屏的情况。我的做法是在全局 error handler 里统一拦截对UNAUTHORIZED做跳转登录页处理对BAD_REQUEST做个 toast 提示其余错误一律上报到日志平台。2.2 Zod 校验数据边界的最后一道防线T3 Stack 默认使用 Zod 做输入校验这一点被很多人低估了。Zod 不只是在接口层做类型检查它更大的价值在于复用——同一个 schema 可以用在表单校验、服务端入参校验、甚至数据库 seed 脚本的数据构造上。我在 t3code 里对 Zod 的实际用法分三层。第一层是接口输入校验直接写在 tRPC procedure 的input字段里确保任何不符合 schema 的请求根本进不了业务逻辑。第二层是表单校验前端 React Hook Form 配合 Zod resolver同一个 schema 两边都生效。第三层是配置文件校验env.mjs里用 Zod 校验环境变量启动时缺什么直接报错而不是跑到一半才发现process.env.X是 undefined。Zod 的进阶用法也值得提一下。嵌套对象的校验、数组元素的 refine、以及跨字段依赖比如开始时间必须早于结束时间这些都能用 Zod 表达。举个例子创建项目时有一个字段校验规则是如果项目类型是“定时任务”必须填写 cron 表达式。用 zod 的superRefine可以这样写const createProjectSchema z .object({ name: z.string().min(1).max(50), type: z.enum([manual, scheduled]), cron: z.string().optional(), }) .superRefine((data, ctx) { if (data.type scheduled !data.cron) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [cron], message: 定时任务必须填写 cron 表达式, }); } });这套写法的好处是校验逻辑和业务逻辑分离前端直接把 schema 传给 RHF 的 resolver后端直接传给 procedure 的 input两边永远保持一致不存在校验规则漂移的问题。2.3 Prisma 数据模型设计与迁移策略数据模型是整个应用的地基。t3code 里我设计的核心模型包括用户、项目、任务、评论、附件以及用户与项目之间的多对多关系。设计时遵循了几条原则尽量用关系而不是嵌入式 JSON 实现关联查询必要的冗余字段可以加但要有意识控制所有外键字段必须有索引。以任务表为例这是业务里查询频率最高的一张表设计时的关键选择是为projectId、assigneeId、status都建了索引。联合索引方面[projectId, status]能覆盖“某个项目下所有未完成任务”这类高频查询。这些索引看起来简单但热表查询的响应时间差异就在毫秒级数据量上来之后差距会被放大得非常明显。迁移策略这块开发环境和生产环境用了不同的方式。本地开发直接prisma migrate dev每一次 schema 变更都会生成一个 SQL migration 文件并同步到本地数据库。生产环境则用prisma migrate deploy只应用已经生成好的 migration 文件不会因为多台机器并发执行迁移命令而产生冲突。有个容易忽略的点Prisma 生成的 migration 文件应该提交到 Git不要把它当做构建产物忽略掉否则新成员接手时无法快速重建数据库结构。Prisma Client 的初始化代码也有讲究。文档推荐的做法是把PrismaClient实例声明为全局单例避免开发环境下热更新导致连接数爆炸// server/db.ts import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }这个模式在 Next.js 开发模式下尤其重要没有全局单例的话每次文件热更新都可能新建一个 Prisma 连接最终导致 PostgreSQL 报连接数超限。我在项目早期就遇到过这个问题具体现象是开发服务器跑了几小时后数据库连接数飙到几百最后定位到就是这个原因。3. 实操过程与核心环节实现3.1 从零搭建create-t3-app 的初始化与配置所有 T3 项目的起点都是create-t3-app。这条命令生成的项目骨架包含了 Next.js App Router、TypeScript、Tailwind、Prisma、NextAuth、tRPC 的完整脚手架省去了手工装配的时间。pnpm create t3-applatest t3code交互式命令行里会询问需要哪些模块我当时的勾选情况是Next.js、TypeScript、Tailwind、Prisma、NextAuth、tRPC 全部选中ESLint 也勾上。这里有个容易被忽略的选项是“使用 App Router”新版本默认就是 App Router老教程里还是 Pages Router 的写法读的时候注意区分。初始化完成后优先做几件收尾工作。首先配置环境变量文件.env里填入 DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL。其次跑一次prisma migrate dev把初始的用户表和 session 表建好。最后启动开发服务器如果能正常打开首页且 tRPC 的健康检查通过说明骨架没问题可以开始写业务了。环境变量的校验是 T3 项目的特色env.mjs里默认用 Zod 做了完整的校验。我的实践经验是不要只在开发时维护这份校验部署时同样需要。有些线上事故就是因为某个环境变量在生产环境的取值是空的程序没有在启动时拦下来而是运行到业务代码里报了个莫名其妙的 undefined 错误。3.2 从数据库到前端一个完整功能的实现路径以“创建项目并邀请成员”这个功能为例展示整个 T3 开发链路。数据库层先定义 Project 和 ProjectMember 模型model Project { id String id default(cuid()) name String slug String unique createdAt DateTime default(now()) members ProjectMember[] } model ProjectMember { id String id default(cuid()) projectId String userId String role String default(member) project Project relation(fields: [projectId], references: [id]) user User relation(fields: [userId], references: [id]) unique([projectId, userId]) }然后写 tRPC router。创建项目的过程有两个关键点一是用db.$transaction保证项目创建和成员关系写入的原子性二是 slug 不能重复重复时返回友好错误。// server/api/routers/project.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const projectRouter createTRPCouter({ create: protectedProcedure .input( z.object({ name: z.string().min(1).max(50), slug: z.string().regex(/^[a-z0-9-]$/, slug 只能包含小写字母、数字和连字符), }) ) .mutation(async ({ ctx, input }) { const existing await ctx.db.project.findUnique({ where: { slug: input.slug }, }); if (existing) { throw new TRPCError({ code: BAD_REQUEST, message: 该项目路径已被使用, }); } return ctx.db.$transaction(async (tx) { const project await tx.project.create({ data: { name: input.name, slug: input.slug } }); await tx.projectMember.create({ data: { projectId: project.id, userId: ctx.session.user.id, role: owner }, }); return project; }); }), });前端组件里的调用就简洁多了因为类型全部推导出来了const utils api.useUtils(); const createProject api.project.create.useMutation({ onSuccess: () { utils.project.invalidate(); }, }); const onSubmit (values: FormValues) { createProject.mutate(values); };这整个流程走下来最核心的体感是“类型安全的成本几乎为零”。我不用记接口返回的是{ code: number, data: ... }还是直接返回对象——编译器会告诉我。我在重构任务接口字段时前端所有用到这个字段的地方会在编译期全部报错这种确定性带来的效率提升是实打实的。3.3 NextAuth 与 tRPC 的认证链路整合认证是 T3 项目里绕不开的一环。t3code 用的 NextAuth通过 getServerSession 把会话注入到 tRPC 的 context 里每个 procedure 就能通过ctx.session.user拿到当前登录用户。这套链路的核心在那个protectedProcedure上。它做的事很简单检查ctx.session是否存在不存在则抛UNAUTHORIZED// server/api/trpc.ts export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session as Session } }); });实际操作时我踩过两个坑都是围绕 session 生命周期展开的。第一个是 NextAuth 的session在 tRPC context 里创建的时机。需要在trpc/server.ts中创建 context 时调用getServerSession而且必须把authOptions传进去否则 ap 在请求上下文里拿不到正确的 session。第二个是开发者模式下的 session cookie 与生产域名不匹配问题表现为本地能登录、部署到线上后一直跳登录页。排查方向是先看 cookie 的 domain再看 NEXTAUTH_URL 是否和线上域名完全一致。NextAuth 的 Provider 配置也很重要。t3code 里我同时启用了 GitHub 和邮箱登录两种方式邮箱登录是为了给测试账号用。生产环境如果要保留邮箱登录必须配置邮件发送服务否则用户点了验证链接收不到邮件整个登录流程是断的。3.4 API 层错误处理与日志规范错误处理是前后端联调时最容易撕逼的地方T3 项目里可以用 tRPC 的错误码做一套统一规范。我的做法是在根 router 里挂一个全局 error middleware把所有异常收敛成标准格式返回给前端const errorFormatter ({ shape, error }) { return { ...shape, message: error.message, data: { ...shape.data, // 生产环境不把内部错误细节暴露给客户端 stack: process.env.NODE_ENV development ? error.stack : undefined, }, }; };前端的处理策略是对于 mutation统一拦截错误码UNAUTHORIZED跳登录BAD_REQUEST用 toast 展示 messageINTERNAL_SERVER_ERROR则提示“系统繁忙”并把原始错误发送到日志平台。对于 query错误态直接在组件里渲染一个重试按钮避免静默失败。日志规范上除了 tRPC 的 error log我在每个 router 文件里加了一个router.onError的回调把 error code、path、input 摘要组合成一个结构化日志对象输出。配合 logflare 或者类似的日志服务排查线上问题时就少了很多瞎子摸象的过程。4. 常见问题与排查技巧实录4.1 类型推导丢失与泛型约束T3 开发中最常见的问题就是“为什么我调用 api.xxx 时类型是 any 或者 never”。排查思路要沿着类型生成的链路走。第一步检查服务端 router 是否正确导出。很多人会在root.ts里漏掉appRouter的createCaller前端api对象为空。第二步检查trpc/client.ts里的类型引用是否是AppRouter而不是本地某个结构体的副本。第三步检查是否有循环依赖。tRPC 对循环依赖的容忍度比较低roouter A 引用了 router Brouter B 又引用 router A某些情况下类型推导会退化。我实际遇到过的一个诡异问题是useQuery返回的data类型正确但传给子组件后变成了undefined。原因是子组件 props 的类型写得太宽泛自己定义了一个type Task { id: string; title: string } | undefined把 tRPC 推导出的精确类型给覆盖了。解决方法是直接用RouterOutputs[task][getByProjectId][number]取类型而不是手写。type Task RouterOutputs[task][getByProjectId][number];这样无论服务端怎么改返回结构组件类型始终跟着变不会出现手写类型与真实数据不一致的情况。4.2 Prisma 查询性能与 N1 问题的处理Prisma 写业务查询很快但稍不注意就会踩 N1 的坑。任务列表页展示了每个任务的项目名称和指派用户如果直接用嵌套查询Prisma 对include的关联数据默认会生成多条 SQL// 容易产生 N1 的写法 const tasks await db.task.findMany({ include: { project: true, assignee: true }, });当任务有几百条时这个查询会执行几百次关联查询性能和响应时间双双下滑。解决办法是改用 Prisma 的select只取需要的字段配合include里的字段白名单避免加载整行大字段。如果再遇到极端场景可以直接写 raw SQL或者用prisma.$queryRaw做聚合查询。调试 Prisma 查询性能有个百试不爽的技巧开启 Prisma 的查询日志所有 SQL 语句会在控制台输出逐个看次数和耗时很快就能定位到哪条查询在重复执行。开发环境在 PrismaClient 初始化时加上log: [query, warn, error]然后跑通一个典型页面观察 SQL 输出。4.3 部署上线时容易忽略的配置项部署 T3 项目可以选择 Vercel 或自建服务器两者有一些共通的坑。第一个是NEXTAUTH_SECRET必须在部署环境里设置而且不能和开发环境的 secret 相同否则线上 session 无法通过校验。第二个是 CORS 配置如果前端页面和后端 API 走不同的域名需要在server/api/trpc.ts里设置允许的 origin。使用 Vercel 且前后端同域时不需要配置但自建部署很可能踩到这里。第三个坑是构建时的类型检查。next build默认会执行 TypeScript 类型检查和 ESLint任何类型错误都会导致构建失败。小项目还好但到了业务中期一个小改动可能把几十个文件的类型全部打断。我的建议是 CI 流程里用next build本地开发用next dev生产构建尽量避免跳过类型检查因为类型错误大概率代表运行时 bug。上线后的监控也很重要。我在 t3code 里挂了 Sentry 做前端错误收集同时在服务端 tRPC error handler 里接入了日志服务。发现线上问题和本地复现不是一回事日志先行永远比事后猜测可靠。建议任何 T3 项目上线前至少确认清楚两件事错误日志能查到具体 request path 和 stack数据库慢查询能浮出水面。4.4 常见问题速查表现象排查方向解决方案前端 api 对象类型为 anytrpc/client.ts 类型引用是否正确确认引用的是 AppRouter 类型而非自定义接口登录后刷新页面即掉登录NEXTAUTH_URL 或 Secret 配置错误检查环境变量确认与线上域名一致Prisma 查询数据条数正确但特别慢查看是否触发 N1检查 include/nested 查询必要时开启日志开发环境数据库连接数暴涨PrismaClient 是否创建了多实例使用全局单例模式参考 server/db.tsmutation 返回 500 但本地不报错生产环境的错误被 errorFormatter 吞掉查看日志服务中的 stack 信息部署后 Tailwind 样式丢失build 时 CSS 提取是否正常检查 postcss 配置文件确认 Tailwind 版本兼容NextAuth 回调地址错误Google/GitHub OAuth 白名单把线上域名加入 Provider 回调白名单构建时类型错误堆积T3 项目类型检查严格分批修复类型问题推荐 CI 阶段才做 full check这八条问题几乎涵盖了 T3 项目从开发到上线最常见的故障类型。遇到问题时不要先怀疑框架先按“环境变量 → 类型推导 → 数据库查询 → 部署配置”的顺序排查90% 的问题都出在这几条链路里。项目跑通到上线是一个里程碑但真正让这套架构释放价值的是后面几个月的迭代速度。每次改完数据库模型跑一次 migrate 加 create 一个新 mutation前端组件里立刻就能获得完整类型提示不再需要 tab 切换去看 API 文档。如果需要扩展能力tRPC 的 router 可以嵌套可以加 middleware也可以混合常规 REST APIT3 Stack 没有把你锁死在单一模式里。我个人在实际操作中的体会是t3code 这类项目最值得投入的地方不在某个单一框架而在“所有类型贯穿全链路”的思维方式。T3 Stack 用工程手段把类型安全、数据库访问、认证体系、样式方案、部署流程全部折叠进一套脚手架里省掉的是大量重复的胶水代码留下的空间全部可以被业务逻辑填满。对于中小型团队、希望快速迭代验证想法的场景这套方案确实算得上成熟可靠的默认选项。