
如果你是一个独立开发者或者正在经营一支五到十人的小团队做全栈项目时一定经历过这样的场景后端写一套接口前端写一套请求两边对着 Postman 截图来回确认字段名改一个字段类型前端编译报错、后端却安然无恙为了一个列表页前后端联调耗掉一下午。t3code 这个名字不同的人可能有不同的联想但在我的语境里它就是围绕 T3 Stack 这套全栈组合写出来的工程代码。它的核心理念说穿了只有四个字类型直通。TypeScript 的类型从数据库表一路传到浏览器组件中间不再需要手写任何 DTO、接口声明或者 mock 数据。这篇文章我打算用实际操作过的经验把这套组合完整讲透包括它到底好在哪、每个部分怎么协同、一个完整项目怎么落地以及我在生产环境里踩过的那些坑。适合谁读呢正在做技术选型的全栈工程师、想从传统 REST API 切到更轻方案的团队以及被前后端联调折磨到想换技术栈的人。1. 整体设计思路T3 选型背后的取舍逻辑1.1 从 REST 到 tRPC少写几百行胶水代码传统的全栈开发思路大多是 Next.js 前端 Express 或 NestJS 后端前后端各占一个仓库中间靠 REST API 通信。这种方式不是不能用但维护成本随着团队规模增长呈指数上升。每新增一个接口你要做的事情包括但不限于在后端定义路由、设计请求和响应结构、补充文档在前端新建 api 目录、写 fetch 函数、定义接口类型、处理 loading 和 error 状态。这些工作里没有多少技术含量却是联调时最容易出幺蛾子的地方字段名拼错、大小写不一致、缺一个可选参数都能让一次联调变成找茬游戏。tRPC 的思路完全不一样。它不定义 URL不写文档而是让前端像调用本地函数一样调用后端函数。后端写好一个 router 和对应的 procedure前端通过类型引用直接获得完整的入参和返回值类型提示。你可以把它类比成两家公司之间走正式合同流程和两个人在同一个办公室直接喊一声的区别。tRPC 不是要替代 HTTP 或者网关它只是让内部服务之间的函数调用透明化让开发者把精力集中在真正有业务价值的部分。1.2 为什么是 Next.js而不是 Vite 加 Express第一次接触 T3 Stack 的人经常会问一个问题既然要类型安全为什么不用 Vite 做前端、Express 做后端然后把类型定义放到 shared 包里我的答案是类型定义放在 shared 包只能是单向共享而且离不开 monorepo 工具链一旦前后端变成两个进程类型的安全性就完全靠人工同步。Next.js 解决了这个问题靠的是一体化进程App Router 既能渲染页面又能通过 Route Handler 挂载 tRPC 服务端。整个项目是一个进程、一个仓库、一套 TypeScript 编译上下文。前端组件、API 路由、数据库查询全部写在一起类型系统天然无缝。Vite 的生态确实丰富但那个场景适合前后端已经明确分离、需要单独构建部署的团队。对于中小型全栈项目Next.js 明显是更省事的选择。1.3 极简主义少即是多T3 Stack 还有一个很重要的精神内核工具链要尽量精简不为炫技引入复杂度。T3 的创始人 Theo 在社区里反复强调过不该盲目上微服务、消息队列、Kubernetes这些基础设施是给有明确规模瓶颈的系统准备的。t3code 这类项目的默认组合是 Next.js tRPC Tailwind Prisma NextAuth每一个组件都能在各自领域内解决一个明确问题。这套组合的哲学不是“功能最多”而是“手感最好”尽量减少让开发者从一个上下文跳转到另一个上下文的次数。2. 核心细节解析t3code 工程的五大核心组件2.1 TypeScript真正的粘合剂很多人以为 TypeScript 只是给 JavaScript 加了一些类型注解但在 T3 Stack 里TypeScript 是整个架构的钢筋骨架。从 Prisma schema 定义数据库结构之后使用prisma generate就能生成带有完整字面量类型的 Prisma ClienttRPC 的router在运行时会剥离类型但编译期间前端通过api.link.getAll.useQuery()拿到的返回类型就是后端 Prisma 查询返回的真实类型。这一条链路是打通全栈类型安全的关键如果某个工程没有开启strict模式或者大量使用any那这套组合的价值立刻减半。2.2 tRPC 的 router 与 proceduretRPC 的核心概念只有两个router和procedure。router是接口的容器类似于传统 REST 里的 Controllerprocedure是具体的处理函数分成query和mutation两种分别对应读操作和写操作。实际写起来你可以在一个 router 里定义十几个 procedure每个 procedure 用 zod 声明入参校验规则tRPC 会自动完成校验并把校验后的干净数据传给处理函数。我举个例子一个简单的链接收藏功能import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; import { z } from zod; export const linkRouter createTRPCRouter({ getAll: publicProcedure.query(({ ctx }) { return ctx.prisma.link.findMany({ orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), url: z.string().url(), description: z.string().optional(), }) ) .mutation(({ ctx, input }) { return ctx.prisma.link.create({ data: { title: input.title, url: input.url, description: input.description, userId: ctx.session.user.id, }, }); }), });这段代码同时完成了路由定义、入参校验、鉴权判断、数据库写入。前端不需要单独再维护任何类型也不需要 mock 数据。这也是 t3code 项目里最典型的代码形态。2.3 Prisma数据库访问层的类型保障Prisma 的作用是让你用 schema 文件描述数据库结构然后生成一个类型安全的数据库客户端。它比直接写 SQL 或使用 TypeORM 的优势在于schema 即文档、迁移即版本管理、查询结果自动带类型。一个典型的 Prisma schema 片段model Link { id String id default(cuid()) title String url String description String? userId String? user User? relation(fields: [userId], references: [id], onDelete: SetNull) createdAt DateTime default(now()) updatedAt DateTime updatedAt }这里我选择cuid()而不是自增数字因为自增主键在分布式场景下容易碰撞而且会暴露业务体量。cuid 和 UUID 类似但更短、更适合做 URL 参数。Prisma 生成的 Client 类型与 schema 严格对应改 schema 之后后续代码里的类型错误会第一时间在编辑器里暴露。2.4 Tailwind样式层的效率方案样式方案里CSS Modules 需要在每个组件旁边维护一个同名.module.css文件styled-components 需要时刻思考 props 与样式的绑定。Tailwind 的做法更激进直接在 JSX 里写工具类。这样做的好处是你不需要频繁切换文件上下文改一个按钮样式只需要在组件属性里加几个类名也不需要担心样式作用域和覆盖问题。需要注意的是Tailwind 的 JIT 编译只会扫描配置里指定的文件后缀如果你新增了自定义组件路径一定要在tailwind.config.ts的content数组里补上否则样式会莫名失效。2.5 NextAuth会话与鉴权的一体化方案NextAuth 解决了账号体系的最后一公里你只需要配置一个authOptions里面声明 providers、secret、数据库适配器就可以在 tRPC 的 context 里通过getServerSession拿到当前用户。tRPC 的protectedProcedure就是基于这个 session 做中间件拦截的。export const protectedProcedure publicProcedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new Error(UNAUTHORIZED); } return next({ ctx: { session: ctx.session, }, }); });收到UNAUTHORIZED错误时可以推断出当前请求是未登录状态前端可以据此跳转到登录页。3. 实操用 t3code 从零构建一个完整全栈应用3.1 初始化命令与脚手架选项T3 Stack 官方提供了一个脚手架工具create-t3-app一条命令就能生成完整的项目结构。在终端执行npx create-t3-applatest my-t3-app交互式命令行会询问你要启用哪些模块。我的习惯是全选Next.js、Tailwind CSS、tRPC、Prisma、NextAuth。如果你不需要登录完全可以去掉 NextAuth但保留它并不会增加多少负担。生成后的项目结构大致如下my-t3-app/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── post.ts │ │ │ │ └── root.ts │ │ │ ├── trpc.ts │ │ │ └── context.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── react.tsx │ └── app/ │ ├── api/ │ │ └── trpc/ │ │ └── [trpc]/ │ │ └── route.ts │ ├── _trpc/ │ │ └── Provider.tsx │ └── page.tsx ├── package.json ├── tailwind.config.ts └── tsconfig.json看到这个结构你会直观感受到脚手架已经把目录约定、Provider 注入、API 路由挂载全部配置好了你只需要往里面填业务代码。3.2 数据库设计与迁移不管做什么应用我建议先想清楚数据模型再写页面。这样 tRPC 的 procedure 能围绕真实数据操作来定义而不是凭空造接口。我们继续用链接收藏功能做例子。在prisma/schema.prisma里补充用户与链接的关系执行迁移npx prisma migrate dev --name add_link_model npx prisma generate这两步分别用于生成 SQL 迁移文件和更新 Prisma Client 的类型。prisma generate是必须的因为它会读取 schema 并生成新的 TypeScript 类型供 tRPC router 使用。3.3 tRPC 路由设计从公共接口到受保护接口在src/server/api/routers/link.ts里我把接口分成两类所有访客都能调的getAll以及必须登录才能调的create和deleteOne。加入删除操作后要注意一个小细节用deleteMany并带上userId条件避免普通用户删掉别人的数据。deleteOne: protectedProcedure .input(z.object({ id: z.string() })) .mutation(({ ctx, input }) { return ctx.prisma.link.deleteMany({ where: { id: input.id, userId: ctx.session.user.id, }, }); }),可能有人会问直接删别人的也不行吗理论上是。但deleteOne找不到记录不会报错这个设计更多是防御性编程防止未来某个组件不小心把别人的 id 传进来。3.4 页面调用useQuery 与 useMutation 的实际用法前端调用 tRPC 接口用的不是 fetch也不是 axios而是生成好的 React Hooks。以列表页为例use client; import { api } from ~/trpc/react; export function LinkList() { const { data: links, isLoading } api.link.getAll.useQuery(); if (isLoading) { return div加载中.../div; } return ( ul {links?.map((link) ( li key{link.id} a href{link.url} target_blank relnoreferrer {link.title} /a /li ))} /ul ); }这里不用写 useEffect、不用手动管理 stateuseQuery已经帮你处理了缓存、重试和请求状态。写变更操作时关键是onSuccess里刷新列表数据const utils api.useUtils(); const createLink api.link.create.useMutation({ onSuccess: async () { await utils.link.getAll.invalidate(); }, });只要调用invalidate列表的useQuery会自动重新拉取不需要手动刷新页面。3.5 部署从本地到生产环境的完整步骤部署 t3code 项目我首选 Vercel因为 Next.js 和 Vercel 的适配度最高。具体步骤是把代码推到 Git 仓库在 Vercel 里导入项目设置环境变量点击部署。环境变量必须包含DATABASE_URL你的数据库连接字符串 NEXTAUTH_SECRET至少32位的随机字符串 NEXTAUTH_URLhttps://你的域名如果你的 NextAuth 版本较新可能还需要AUTH_SECRET或AUTH_URL具体看脚手架生成的版本。构建过程中prisma generate会自动执行但不建议在生产环境直接跑migrate dev我都是在本地执行迁移然后把上线后的机器环境变量指向同一套数据库。4. 常见问题与排查技巧实录4.1 tRPC 接口报 500 错误先看服务端日志tRPC 有一个设计经常会“坑”到新手浏览器端看不到详细的错误堆栈只显示500 Internal Server Error。这是因为 tRPC 默认不会把内部错误信息返回给前端防止业务逻辑泄露。排查的第一步永远是看终端Next.js 开发模式下日志会直接打印出 Prisma 查询错误、zod 解析错误和 tRPC procedure 内部抛出的异常。常见的 500 原因包括Prisma 查询出错例如字段名拼错、数据库连接失败、session 读取异常。如果你看到UNAUTHORIZED报错则是 protectedProcedure 的中间件拦截了未登录请求。4.2 React Query 缓存导致“数据不刷新”这是我踩过最多次的坑。当用户新增一条记录列表页如果不调用invalidate页面会一直显示旧数据因为 React Query 默认缓存了查询结果。常规的做法是const createLink api.link.create.useMutation({ onSuccess: () { utils.link.getAll.invalidate(); }, });还有一种更精细的控制方式直接更新本地缓存而不重新请求网络。比如删除一条记录后你可以用setData在本地过滤掉那条记录减少一次网络往返。这两个方案各有适用场景交互频率低用invalidate交互频率高用悲观更新或滚动更新。4.3 SSR 场景下的数据库连接与内存泄漏在服务端组件或getServerProps里直接调用 Prisma Client存在一个很隐蔽的问题每次请求都创建一个新的 PrismaClient 实例会导致数据库连接池被大量未释放的连接占满。解决方案是使用全局单例import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const prisma globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error] : [error], }); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma prisma; }这个模式的核心是开发环境开启node --watch之类的热重载时每次代码变更会重新创建实例但旧实例没有被垃圾回收把实例挂在globalThis上就能在重载期间复用同一个连接。生产环境则只需要一个全局实例。4.4 NextAuth 会话在 tRPC 中间件里的配置要点NextAuth 的 session 在 tRPC context 里读取时要特别注意你用的是 JWT 策略还是数据库策略。默认配置下session 数据被加密存储在 cookie 里getServerSession会把它解密并返回。如果你不小心在客户端组件里调用getSession拿到的是过期或不一致的缓存导致鉴权判断出错。解决方法是服务端一律用getServerSession(authOptions)客户端只用useSession。另外NEXTAUTH_SECRET一定不要在生产环境缺失。如果这个值未设置NextAuth 在不同实例之间会生成不同的签名密钥导致用户登录后刷新域名时 session 立即失效。4.5 依赖版本升级的坑T3 Stack 的脚手架和依赖迭代速度很快。以 Next.js 为例从 Pages Router 迁移到 App Router 时很多老的 tRPC 示例代码还在用pages/api/trpc/[trpc].ts而新版脚手架已经改成了app/api/trpc/[trpc]/route.ts。tRPC 本身也有大版本变化v10 用trpc.createTRPCProxyClientv11 则是createTRPCClient加httpBatchLink。我给你的建议是不要把升级当作例行公事。升级前先看 changelog特别是 breaking changes升级后跑一遍完整的开发流程尤其是登录、注册、数据列表这三个最常见的场景。如果你没有精力跟进升级锁住版本号或者直接用 create-t3-app 重新初始化再迁移业务代码反而更省事。4.6 问题排查速查表现象可能原因处理方式接口 500无详细信息tRPC 默认隐藏错误堆栈查看服务端日志或临时在开发环境关闭生产模式页面数据一直是旧数据mutation 后没有 invalidate调用utils.xxx.invalidate()登录状态丢失NEXTAUTH_SECRET 未设置或不稳定配置一个固定的长随机字符串作为 secretPrisma 连接卡死每次请求创建新 client使用全局单例模式Tailwind 样式不生效content 扫描路径不对修改tailwind.config.ts的 content 数组莫名的类型报错tsconfig没开启 strict在 tsconfig 中设置strict: true5. 经验心得t3code 适合什么场景边界又在哪里5.1 什么项目我推荐用它什么项目我不推荐用 T3 Stack 做中后台管理面板、内容型网站、内部工具、社区类应用都是非常趁手的组合。它的优势在于产品迭代速度极快、前后端一体化、类型安全让多人协作的底噪很小。但如果你要做的是一个面向全球开发者的开放 API让第三方通过 REST 或 GraphQL 对接那我建议还是用传统方案因为你无法要求外部调用者和你的 TypeScript 类型保持同步公共 API 的契约需要更稳定的接口文档和版本管理。另外如果你的团队前后端人数都在五人以上、已经各自有成熟的工程体系T3 Stack 的全栈一体化模型不一定适合。两套独立部署的架构加上微服务拆分解决的是组织协作问题和技术演进问题这不是类型安全能替代的。5.2 项目结构上的三个实用建议第一个建议按业务模块组织 router而不是按文件类型。routers/user.ts、routers/post.ts、routers/link.ts每一块业务内部的查询和变更放在同一个文件里比把 query 全部堆到一个文件好维护得多。第二个建议把 zod schema 单独抽成 shared 文件因为有些校验逻辑前端和服务端都要用比如用户名规则、URL 格式。第三个建议环境变量校验用 zod。脚手架默认已经配置了env.mjs你只需要在数组里补齐变量名应用启动时如果缺少某个环境变量会直接报错而不是运行到一半才爆出运行时错误。5.3 生态扩展文件上传、定时任务、支付回调t3code 并不意味着只能做简单的 CRUD。文件上传可以用 tRPC 生成预签名 URL然后前端直传对象存储定时任务可以在 Next.js 的 Route Handler 里写 cron 接口让外部调度器定时触发支付回调则可以用 Route Handler 接收第三方 webhook再用 tRPC 的 server caller 在服务器端直接调用业务 procedure。也就是说第一层是 tRPC 面向浏览器第二层是 Route Handler 面向外部系统两者可以共享同一个 Prisma Client 和同一套类型工程结构的扩展性其实比想象中好。结尾我自己完整体验过 t3code 这类项目之后最大的感受是“联调”这个词几乎从日常词汇里消失了。以前最怕领导问“这个接口什么时候好”现在前后端是同一份代码、同一个类型写好 router 前端马上就能拿到提示剩下的精力全部花在业务逻辑上。最后再多说一句t3code 不是银弹如果你的系统需要向第三方开放 API、需要严格的接口版本管理、或者你的团队本来就是两个独立工种那传统 REST 和 GraphQL 依然是更合适的选择。选型的本质是匹配场景。如果你决定试一试建议从小工具或内部系统入手先体验一次“改完数据库类型立刻在页面提示”的快感再决定要不要把整个团队的业务都搬上来。