做全栈开发好几年,我试过不少脚手架和样板间:有前后端严格分离的,有按业务模块拆微服务的,也有干脆一个文件里塞满逻辑的。折腾一圈下来,最后把手头这个代号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-app@latest脚手架会问你项目名、要不要 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 守卫。
页面层很简单,在服务端组件或布局里校验 session:
import { 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 层。我自定义了一个protectedProcedure:
const 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.ButtonHTMLAttributes<HTMLButtonElement>; 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 variant="danger" onClick={...}>删除</Button>,既保留了 Tailwind 的快速表达能力,又不会让每个页面重复粘贴一长串类名。
4.2 主题变量与暗色模式的踩坑
Tailwind 默认有一套颜色体系,但实际业务系统通常需要品牌色来贯穿视觉。我在 t3code 里把主色、辅助色、背景色、边框色都提取到了globals.css:
@theme { --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 用 PostgreSQL,Prisma 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: false:
const 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 一个机会,按我上面的链路完整跑一遍。让类型替你把关,把精力留给业务。