t3code 实战:T3 Stack 类型安全全栈从搭建到部署 做全栈开发好几年我试过不少脚手架和样板间有前后端严格分离的有按业务模块拆微服务的也有干脆一个文件里塞满逻辑的。折腾一圈下来最后把手头这个代号t3code的项目稳定在了一套组合拳上——TypeScript、Tailwind CSS、tRPC、Next.js、Prisma也就是大家常说的 T3 Stack。t3code 不是某个官方框架它是我自己维护的一个全栈应用实践项目核心诉求非常朴素让类型从前端贯穿到后端让数据模型、接口定义、页面组件形成一条不用靠翻接口文档来维持的闭环。这篇文章就是把我从零搭建 t3code 到部署上线的完整思路、决策过程和踩坑记录写出来适合那些已经在用 Next.js 但还没接过 tRPC或者对类型安全全栈这个概念感兴趣的人参考。1. 从t3code这个名字说起T3栈到底解决了全栈开发的什么问题1.1 为什么大多数样板间项目会越写越乱先聊聊为什么我不直接用 Next.js 官方脚手架也不继续用以前习惯的Next.js REST API 手写类型的组合。早期做全栈项目前后端之间靠 REST 接口通信我需要在后端维护一套路由、在某个公共目录里维护一套请求类型、在前端再维护一套调用封装。项目小的时候没问题一旦业务表超过二十张问题就来了后端改了某个字段名前端类型不知道前端拿到一个联合类型后端某个分支没更新运行期直接炸。这类问题最可怕的地方在于——类型错误不会在编译期暴露只会在用户点开页面的时候变成一串白屏或者一条 500。T3 栈的核心思路是把接口这个概念从 REST API 中抽象掉用 tRPC 让前端直接调用后端的函数。听起来像 RPC实际用起来更接近远程函数调用你在后端写一个getProjectList的 procedure前端 import 同一个客户端对象后拿到的方法天然带参数类型和返回值类型。这里的类型不是手写的而是从后端路由定义里infer出来的前后端只要编译通过就能保证调用链路上百分之百类型一致。不再需要 schema 文件、不再需要 codegen、更不需要在出错时对着 Swagger 文档猜字段。1.2 t3code的定位与模式选择t3code 这个名字就是 T3 Stack 的缩写延伸。它并不是一个高并发的 ToC 产品而是一个中小型业务系统涵盖用户登录、项目空间、文档管理、成员权限这类典型场景。这样的业务形态非常适合 T3 栈原因有三个。第一业务逻辑之间有真实的数据关联Prisma 的声明式模型能让我在改模型的同时拿到 TypeScript 类型。第二权限和 Session 逻辑贯穿所有页面tRPC 的 middleware 机制可以统一做鉴权不用每个接口单独写一遍 token 校验。第三团队里没有专门的后端组与前端组之分一个人从模型到 UI 全包需要的就是减少上下文切换的工具。我见过不少人对 T3 栈有误解觉得它只适合快速做个 Demo。实际用下来t3code 的代码量已经超过两万行依然没有出现类型混乱、部署困难、性能失控这类典型问题。关键不在于框架本身多神奇而在于它把类型安全这件事做成了默认约束而不是人的自律。2. 项目初始化create-t3-app到底勾选了哪些东西2.1 脚手架交互与选项说明初始化 t3code 用的命令很直接npm create t3-applatest脚手架会问你项目名、要不要 TypeScript、借用哪个 UI 方案、是否启用 tRPC、Prisma、NextAuth、Tailwind 等。我实际勾选的是模块选择理由Next.js选用 App Router服务端组件与客户端组件分离更清晰适合文档类业务TypeScript必须这是整个栈的基石Tailwind CSS选用快速搭 UI 足够后面会细说定制边界tRPC选用类型安全的核心Prisma选用需要关系型数据模型NextAuth选用账号密码 第三方登录都覆盖其他如 drizzle、eslint 等附加项按需尽量保持初始整洁如果你只是想要一个最薄的 T3 栈做实验可以先只选 TypeScript tRPC Next.js其他模块后补。但既然目标是长期维护的业务系统我建议把 Prisma 和 NextAuth 一开始就纳入避免项目做到中期再引入数据层时不得不回头重构接口签名。2.2 生成后的目录结构解读跑完脚手架后目录结构大概是这样的t3code/ ├── prisma/schema.prisma ├── src/ │ ├── server/ │ │ ├── api/routers/ │ │ ├── db.ts │ │ └── auth.ts │ ├── trpc/ │ │ ├── server.ts │ │ ├── client.ts │ │ └── react.tsx │ ├── app/ │ └── styles/这个结构初看有点绕但逻辑非常清晰src/server里放所有后端逻辑包括数据库连接和 tRPC 路由src/trpc里放 tRPC 的服务端创建函数与客户端封装src/app里是页面组件。核心不变量是前端不允许 import 服务端的任何 Prisma 模型或数据库实例。所有数据出口都必须走 tRPC procedure这就保证了数据访问路径单一。如果你之前写过 NestJS 或者 Express刚开始可能会不适应这种路由即函数的写法。没关系后面我拆一个具体例子来说明。2.3 为什么我关闭了某些可选模块现在 create-t3-app 的交互式选项会比早期更丰富。有些人喜欢什么都勾上我的习惯是逆着来——只留我真的用得到的。我没有启用 ESLint 之外额外的代码风格插件团队小风格统一靠审阅就够了我没有启用 Playwright 的初始化示例因为业务测试脚本需要按自己的权限体系定制我没有在初始化阶段直接接入 CI/CD 配置而是等部署方案定下来之后再写流水线。这样做的目的只有一个减少脚手架默认代码对业务代码的干扰。T3 栈本身包含的抽象层已经不少了如果再把一堆示例代码堆进去刚开始会显得很规范但真正改业务时你需要先拆掉大量模板才能落笔。3. 类型安全的核心闭环tRPC路由、Prisma模型与Next.js页面3.1 先定数据模型再让tRPC路由长出类型我在 t3code 里的习惯是先改 Prisma schema再写 procedure。数据模型是类型系统的源头模型改完后Prisma Client 会自动生成对应的 TS 类型tRPC 路由只要直接引用这些类型返回值类型就自动带上了。拿文档管理举例表结构定义如下model Document { id String id default(cuid()) title String content String db.Text projectId String project Project relation(fields: [projectId], references: [id]) ownerId String owner User relation(fields: [ownerId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt }对应的 tRPC 路由export const documentRouter router({ listByProject: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) { return ctx.db.document.findMany({ where: { projectId: input.projectId, ownerId: ctx.session.user.id }, orderBy: { updatedAt: desc }, }); }), });这里的ctx.session.user.id是从 NextAuth 的 session 拿到的protectedProcedure是统一鉴权中间件。写这段代码时我完全没有手动定义返回值类型——listByProject的返回值类型被自动推断为Document[]前端调用时拿到的就是完整且精确的字段提示。3.2 页面组件如何直接消费类型安全的接口在客户端组件里调用后端接口是我个人觉得 t3code 最有幸福感的部分use client; import { api } from ~/trpc/react; export default function DocumentList({ projectId }: { projectId: string }) { const { data, isLoading } api.document.listByProject.useQuery({ projectId, }); if (isLoading) return DocumentSkeleton /; return ( ul {data?.map((doc) ( li key{doc.id} h3{doc.title}/h3 p{doc.updatedAt.toLocaleDateString()}/p /li ))} /ul ); }注意几个细节。第一api.document.listByProject这个路径是强类型的写错任何一个字符编译期就会报错。第二data的每一项都带doc.title、doc.updatedAt等字段提示不需要去查后端文档。第三这里接的是 React Query所以useQuery自带缓存、重新请求、失效机制。对于多屏联动场景比如修改文档标题后需要刷新列表可以直接用utils.api.document.listByProject.invalidate()让所有相关查询自动失效。这种改一个数据全界面同步的效果在 REST 模式下你需要自己设计事件总线或全局状态在 tRPC React Query 模式下是默认能力。3.3 受保护路由与Session管理的处理权限这块是业务系统的重头戏。t3code 里我做了两层保护页面层的路由守卫 数据层的 procedure 守卫。页面层很简单在服务端组件或布局里校验 sessionimport { auth } from ~/server/auth; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { const session await auth(); if (!session?.user) { redirect(/login); } return {children}/; }但页面守卫挡不住直接调用接口的请求所以真正要紧的是 procedure 层。我自定义了一个protectedProcedureconst enforceUserIsAuthenticated tRPC.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session, db: ctx.db } }); }); export const protectedProcedure tRPC.procedure.use(enforceUserIsAuthenticated);从此每个写数据的 procedure 都必须显式使用protectedProcedure或更细粒度的projectOwnerProcedure。这套机制让漏写鉴权变成编译期或运行期的显式问题而不是等到上线后被人刷接口才暴露。4. 样式与组件组织Tailwind CSS在t3code里的实际用法4.1 从原子类到组件拆分的边界Tailwind 是 T3 栈里争议最少但也最容易用歪的部分。很多人的问题是所有样式全写在 className 里一个标签二十个类名复用逻辑一多模板就臃肿。我在 t3code 里的处理方式是三层拆法。第一层全局样式用 CSS 变量控制包括颜色、圆角、间距节奏。Tailwind 的配置文件指向这些变量。第二层通用 UI 元素抽成组件比如Button、Input、Modal、Badge。这些组件内部使用 Tailwind 类名但对业务组件只暴露简单 props。第三层业务组件内部才直接写 Tailwind 类组织布局。到这个层级类名就算长一点也可以接受因为它是页面私有的不会影响复用。举个例子我封装了一个简单的按钮组件import { cn } from ~/lib/cn; type ButtonProps { variant?: primary | ghost | danger; size?: sm | md | lg; } React.ButtonHTMLAttributesHTMLButtonElement; const variantClasses { primary: bg-blue-600 text-white hover:bg-blue-500, ghost: bg-transparent text-gray-700 hover:bg-gray-100, danger: bg-red-600 text-white hover:bg-red-500, }; export function Button({ variant primary, size md, className, ...props }: ButtonProps) { return ( button className{cn( rounded-lg font-medium transition-colors focus-visible:outline focus-visible:outline-2, variantClasses[variant], sizeClasses[size], className )} {...props} / ); }这样业务代码里写Button variantdanger onClick{...}删除/Button既保留了 Tailwind 的快速表达能力又不会让每个页面重复粘贴一长串类名。4.2 主题变量与暗色模式的踩坑Tailwind 默认有一套颜色体系但实际业务系统通常需要品牌色来贯穿视觉。我在 t3code 里把主色、辅助色、背景色、边框色都提取到了globals.csstheme { --color-primary: #2563eb; --color-primary-hover: #1d4ed8; --color-surface: #ffffff; --color-surface-muted: #f8fafc; }然后 Tailwind 配置里把primary注册为可用的颜色类。前期如果没做这步后期改主题时你得全局搜索bg-blue-600一处处替换非常痛苦。暗色模式我采用class策略而不是media。media策略跟随操作系统用户没得选class策略则可以在设置页里让用户手动切换。切换逻辑封装成一个ThemeProvider把当前主题写入document.documentElement的 class 上。这里有个实际的小问题初次加载时主题闪烁。官方推荐的方案是在内联脚本里读 localStorage 再设置 class放在head里提前执行。我在根布局里加了一小段内联脚本实测加载时没有闪烁。5. 部署与性能优化t3code上线前后的几项关键改动5.1 部署平台的选择t3code 部署在 Vercel 上。选择它不是因为生态绑定而是因为 Next.js 在 Vercel 上的支持最完整尤其是服务端组件、中间件和边缘函数的特性支持。如果你的项目偏向自部署也可以考虑 Docker Node 服务器但代价是你需要自己处理 Prisma 的构建步骤、迁移脚本和静态资源缓存。对于中小型项目Vercel 的零配置体验值得省下这部分运维时间。部署前有几件事要在本地先做好设置环境变量DATABASE_URL、AUTH_SECRET、NEXTAUTH_URL准备数据库迁移t3code 用 PostgreSQLPrisma Migrate 的迁移文件必须进版本库确认output: standalone或默认模式的选择取决于你的部署环境。5.2 React Query缓存与SSR策略上线后我观察到列表页首屏加载偏慢排查发现是 React Query 的初始请求被发到了客户端页面先渲染骨架屏再请求数据。T3 栈默认允许TRPCClientErrorLike在前端直接消费但如果你用了useQuery的默认模式数据请求发生在客户端。优化方案是启用服务端预取。在服务端组件里先调用一次查询把数据写入 hydration cache然后传给客户端组件。t3code 里的做法是封装一个ServerTRPCProvider在服务端执行查询后将缓存序列化到页面 props 中。一个值得注意的细节不是所有查询都适合 SSR。像用户输入实时搜索这类高频查询SSR 反而浪费资源留在客户端更好。我的策略是首屏必需数据走 SSR 预取交互性数据走客户端请求。这样首屏从骨架屏加载缩到直接有内容体感快了一个档位。5.3 构建体积与包体优化Next.js 的包体分析功能帮我发现了一个隐藏问题文档编辑器相关的依赖被打进了主 bundle。原因是我在客户端组件里直接 import 了一个重型编辑器而它只用在一个弹窗里。优化方式是动态加载使用next/dynamic配合ssr: falseconst Editor dynamic(() import(~/components/editor/Editor), { ssr: false, loading: () EditorLoading /, });同时 tRPC 的 router 是按叶子路由拆分的吗其实不是tRPC 的 client 默认会打包所有路由。如果你有特别大的独立模块可以拆分成独立 router再在页面层用一个单独的 tRPC client 引用。不过对于 t3code 这种规模一个 client 完全够用过度拆分反而破坏类型统一。6. 踩坑记录我在t3code里趟过的四个连环坑6.1 type-only导入与isolatedModules第一个坑出现在早期我用import { type Document } from prisma/client或者直接import { Prisma } from prisma/client时eslint 和编译器偶尔会报isolatedModules相关的错误。原因是在单文件编译模式下TypeScript 编译器无法确定 import 进来的东西到底是类型还是值。解决办法很简单类型导入统一用import type或者内联的type关键字。当时项目里有一批老代码是普通import的结果每次构建都随机报错排查了很久才发现是这里。所以如果你打开项目就跑构建报错先检查所有 Prisma 类型的导入方式。6.2 tRPC error格式化导致的前端类型收窄第二个坑和错误处理有关。tRPC 后端抛出的TRPCError有code字段但如果你在生产环境用了自定义 error formatter前端拿到的错误对象类型会被抹平TRPCClientErrorLike里的枚举值可能会变成普通 string。我一开始没注意前端用error.data?.code UNAUTHORIZED做了分支结果发现UNAUTHORIZED永远匹配不上因为返回的是字符串而 TS 类型却认为它是枚举。解决方式是在自定义 error formatter 时明确保留 code 字段并让前端收窄类型export const errorFormatter ({ shape, error }) ({ ...shape, data: { ...shape.data, code: error.code, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, });前后端同时约束后前端分支判断才可靠。这个坑属于类型安全的边界——类型安全保证的是编译期但运行期数据仍然需要显式处理。6.3 Prisma连接池在Serverless环境下被耗尽第三个坑是部署后才暴露的。Vercel 的 Serverless 环境下每个请求可能会启动一个新的 Node 运行时如果每一个运行时都创建自己的 PrismaClient数据库连接数会涨得非常快。我在压测时发现 PostgreSQL 连接数稳定在 30 左右一旦有并发请求直接打满连接池上限。解决方案是全局单例模式官网标准做法import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error] : [error] }); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }注意globalThis的复用只在开发环境有意义生产环境每个 Lambda 实例仍然是独立进程。真要压到极致的并发建议用 Prisma 连接池代理或外部连接池服务。对于中小项目单例方案已经能覆盖 99% 的场景。6.4 静态生成与cookie时序问题第四个坑是登录态的时序问题。Next.js 的静态生成会缓存页面的 HTML但登录用户的 session 信息存在 cookie 里。如果一个页面在构建期被静态生成而用户在客户端进入时 cookie 已存在页面内容却是构建期的空 shell就会造成明明已登录、页面却显示游客的怪象。我的解决办法是对依赖登录态的页面强制使用动态渲染模式在页面顶部标记export const dynamic force-dynamic;同时在布局层用auth()拿服务端 session把用户信息直接传给服务端组件树。这样既能保证登录态正确又不会牺牲太多性能。后来我还遇到一个变体问题middleware 里读取 cookie 来鉴权但 Next.js 的 middleware 运行在 Edge 环境读取的 cookie 与 Node 环境的 session 校验有时不一致导致部分请求被误判。最终我把鉴权完全收敛到server/auth.ts的auth()函数里middleware 只做基础的路径拦截不在里面做复杂的 session 解析。踩完这四个坑我对 t3code 的稳定性明显更有信心了。回头总结T3 栈最大的价值不是省去了接口定义这种体力活而是把类型安全从心智负担变成了基础设施。你不再需要每天担心改字段漏了前端、加参数忘改文档、删接口牵连调用方这些失控感全部被框架拦截在编译期。如果你正准备用 Next.js 做全栈项目或者已经在 REST 模式下被类型问题折磨过强烈建议给 tRPC Prisma 一个机会按我上面的链路完整跑一遍。让类型替你把关把精力留给业务。