去年底我接了一个内部工具的项目,前后端分离、REST接口、手写类型定义那一套我实在太熟了,但工期砍到三周,还得带一个不太熟React的同事。纠结了两天后我决定赌一把,用当时风头正盛的T3 Stack重写整个项目。说实话,作为一个常年写Go和Python后端的开发者,我对TypeScript全栈一直有点偏见,觉得"类型安全"在前后端之间就是个笑话。但这三周下来,我必须承认:t3code 这个技术组合——Next.js、tRPC、TailwindCSS、Prisma,确实改变了我对全栈开发的整个认知。这篇文章不是我给T3 Stack写软文,而是一个怀疑论者被现实教育后的完整复盘。我会从选型逻辑讲起,把初始化、tRPC的类型安全实现、部署踩坑这些关键环节全部分享出来,希望能帮到那些和我一样,正在纠结要不要切换到这套技术栈的朋友。
1. 从"后端优先"到T3 Stack:一次转型背后的选型逻辑
1.1 我对TypeScript全栈的原始偏见
在深入了解T3 Stack之前,我的技术栈非常固定:后端用Go写REST API,前端用React + Redux Toolkit,中间层靠OpenAPI规范维护契约。这个组合的痛点实在太明显了——每增加一个接口,我都要在三个地方重复劳动:后端定义请求/响应结构、前端再写一遍对应的interface、联调时还要开着Swagger UI手动比对字段。一旦后端改了字段名,前端编译不会报错,只有运行时才炸,而且炸得毫无预兆。
我尝试过用openapi-generator自动生成前端类型定义,但那玩意生成的代码丑到没法看, Redux的action和slice部分还是得手动维护。很长一段时间里,我的开发日常就是"写接口、复制类型、修联调bug"三件事无限循环,真正的业务逻辑根本没写几行。
所以我最初听说T3 Stack时,第一反应是:又一个前端圈子的自嗨产物。Next.js我多少了解,Tailwind也见过,但那tRPC是个什么鬼?不定义REST路由、不发HTTP请求、就用函数调用?这听起来就像是把前后端代码强行揉在一起,完全违背了我坚持的分层原则。
1.2 让我动摇的三个具体瞬间
真正促使我深入调研T3 Stack的,是三件具体的事。
第一件事是我看了一个Prisma的Demo。一个schema文件定义数据模型,自动生成类型安全的客户端,数据库字段的增删改查全程有补全和编译期校验。这对被手动写SQL和scan结构体折磨了多年的人来说,冲击力不亚于第一次见到ORM。
第二件事是tRPC的官网一句话:"Build typesafe APIs, without an API layer."(构建类型安全的API,但不需要API层。)我当时觉得这是营销话术,可是仔细想了一下——如果前端直接调用后端函数,那么请求参数和响应结果天然共享同一份类型定义,REST层存在的意义就只剩"网络传输"这一件事了。
第三件事是Next.js的App Router。Server Component可以在服务端直接查询数据库并以props形式传给前端,这意味着我可能再也不需要为了一个表单页面专门写一个query接口。
这三个点叠加在一起,让我决定找个小项目试水。我选的试验品是一个需要登录、带增删改查的管理后台:用户管理、角色分配、操作日志。正好覆盖了路由、鉴权、CRUD、多表关联这些典型场景。
1.3 核心选型原则:少即是多,但不多也不少
T3 Stack的全称是The T3 Stack,它的选型原则是"Simplicity, Modularity, Full-Stack Type Safety"——简单、模块化、全栈类型安全。它不追求把所有工具都塞进项目里,而是挑选那些能互相弥补短板的组件:Next.js负责全栈框架,React负责UI,tRPC负责API调用,Prisma负责数据访问,Tailwind负责样式,NextAuth负责鉴权。每个工具都只做好一件事,但组合起来就是完整的闭环。
如果你也是服务端背景,第一次接触这套体系时最容易犯的错误是:用REST的思维理解tRPC,用传统前后端分离的思维组织目录结构。我后面会专门讲这个,这里先不展开。总之我实际的感受是:T3 Stack不是为了"看起来流行"而存在的,它的每一项取舍都有非常具体的痛点作为背景。
2. create-t3-app 初始化全过程:一步步拆解每一步的作用
2.1 脚手架命令和各选项的真实含义
T3 Stack官方提供了一个脚手架create-t3-app,运行命令后会有交互式问答。很多人用这个脚手架都是闭眼一路Next,结果产物里一堆用不到的东西,然后转头骂它"太重"。我建议你在初始化前先搞清楚每个选项到底在干什么。
npm create t3-app@latest交互式问答大概是这样的:
Project name:项目名,会作为文件夹名和package.json里的name。Use TypeScript:默认true,T3 Stack的存在意义就是类型安全,不建议关。Use Tailwind CSS:默认true,后面我会说为什么推荐保留。Use tRPC:建议选上,这是T3 Stack的核心。如果关掉它,其实就是"Next.js + Prisma"项目,没必要叫T3。Use Prisma:默认true,ORM层就靠它。Use Next.js App Router:默认true,这是当前Next.js的推荐模式。Use NextAuth.js:如果你做需要登录的应用,选上。如果纯静态展示,可以后面再加。Strict mode:默认开启,TypeScript的严格模式,强烈建议保留。ESLint / Prettier:都选上,省得自己配。
我第一次初始化时选了个全部默认,结果项目里同时生成了src/pages和src/app两套目录结构(版本不同行为不同),并不完全符合App Router的预期。所以我的建议是:认真读每个问答的解释,不要跳过。
2.2 生成项目的目录结构与各层职责
初始化完成后,项目的src目录结构大致如下:
src/ ├── app/ # Next.js App Router页面 │ ├── api/ │ │ └── trpc/ │ │ └── [trpc]/ │ │ └── route.ts # tRPC的HTTP入口 │ ├── layout.tsx # 全局布局 │ └── page.tsx # 首页 ├── server/ │ ├── api/ │ │ └── routers/ │ │ ├── post.ts # 业务路由(示例帖子) │ │ └── root.ts # 根路由聚合 │ └── db/ │ └── prisma.ts # Prisma客户端单例 ├── trpc/ │ ├── caller.ts # 服务端调用tRPC的封装 │ ├── react.tsx # React Hooks绑定 │ └── server.ts # tRPC服务端初始化 └── styles/ └── globals.css # Tailwind全局样式这套结构的核心思想是"server code and client code分离但共享类型":server/下放所有只在服务端运行的代码(数据库访问、业务逻辑),trpc/下定义API的router和类型,前端通过trpc/react.tsx导出的hooks调用这些router。你可能注意到没有"controllers"或"services"目录,因为在T3 Stack中,router直接承担了控制器和部分服务层的职责。
我第一次把业务逻辑拆得特别细,什么Repository层、Service层、Controller层,硬是把T3项目套进了传统的N层架构。结果就是写了一大堆中间层代码,而tRPC本身允许router之间互相调用,再加中间层纯属画蛇添足。后来我学乖了:业务简单时,直接写在router里;业务复杂时,抽到server/api/services/单独文件里,router只做参数校验和结果映射。
2.3 Prisma schema与数据库迁移的衔接
T3 Stack默认使用Prisma,初始化后需要你自己prisma db push或执行迁移。schema文件在prisma/schema.prisma,默认只有一个Post模型。这里我建议你根据业务定制。
以我的项目为例(用户 + 角色),schema大概是:
generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" // 或 "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String? role Role @default(USER) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }定义好了之后,运行npx prisma db push直接把schema推送给开发库,然后npx prisma generate生成类型客户端。这两个命令记住就行:开发期改schema用push,多人和生产环境迁移用migrate。
有一个非常容易踩的坑:改完schema之后忘记重新生成Prisma Client,导致代码里访问新增字段报类型错误。我在对接第三方登录时加了一个providerAccountId字段,然后IDE里怎么写都报错,折腾了半天才发现是没跑prisma generate。这个动作不是自动的,每次schema变更后都得手动执行一次。
3. 端到端类型安全的核心:tRPC的调用链与边界控制
3.1 从"没有REST层"的角度理解tRPC
tRPC的具体概念很多人讲了,我换一个角度:传统REST中,后端写一个/users/:id接口,返回JSON,前端通过fetch去拿,然后你手动标注返回类型。这个过程的本质是:后端定义了一个"数据传输格式",前端和后端各维护一份对格式的理解,格式变了,全靠人肉同步。
tRPC的做法是:前端直接"调用"一个后端函数,函数名、参数类型、返回值类型全部由TypeScript编译器保证。它不需要中间的数据契约文件,因为类型就存储在代码里,编译期就能检查出API不匹配。
// server/api/routers/user.ts export const userRouter = router({ getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.user.findUnique({ where: { id: input.id }, include: { posts: true }, }); }), });前端调用时:
const { data } = trpc.user.getById.useQuery({ id: "abc" }); // data的类型自动推断为 User & { posts: Post[] } | null输入校验用Zod,z.object({ id: z.string() })既是运行时校验,也是编译期类型。如果我把id的校验改成z.number(),前端所有用字符串调用的地方会全部编译报错——这就是T3 Stack宣称的"从数据库到UI的端到端类型安全"。
3.2 mutation、query和procedure的选择时机
tRPC里有三个核心概念,对应传统后端的三类接口:
query:读操作,无副作用。对应GET。mutation:写操作,有副作用。对应POST/PUT/DELETE。subscription:实时推送,对应WebSocket场景。tRPC也支持,但T3 Stack默认不配置WebSocket,大多数内部工具用不到。
procedure(过程)是tRPC对"一个API端点"的抽象。你通过protectedProcedure、publicProcedure这类变量来定义访问权限。比如:
// server/api/trpc.ts export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, user: ctx.session.user } }); });这个protectedProcedure会在调用真正执行前检查session是否存在。这就是中间件机制,和Express/Koa的中间件思想一样,只不过受TypeScript保护。
我强烈建议你从第一天就区分query和mutation。哪怕一个写操作暂时只做"假更新",也请用mutation。这样后面接缓存、乐观更新、日志审计时会省很多事。tRPC还会强制你区分,查询和写操作的hooks方法名不同(useQueryvsuseMutation),算是好习惯的强约束。
3.3 Server Component与tRPC的三种正确用法
Next.js App Router引入了Server Component,它运行的代码只存在于服务端,不会打包进浏览器。这一点对tRPC的使用方式影响很大,三种用法对应三种场景:
用法一:Server Component中直接调用tRPC的caller
// app/users/page.tsx import { api } from "~/trpc/server"; export default async function UsersPage() { // 在服务端直接调用tRPC查询,不会产生HTTP请求 const users = await api.user.getAll(); return <UserList users={users} />; }这相当于把tRPC当服务端内部的函数调用,不走网络,也没有序列化开销。注意这个api对象来自~/trpc/server,是服务端专用的caller封装。
用法二:Client Component中通过hooks调用
"use client"; function AddUserButton() { const utils = trpc.useUtils(); const addUser = trpc.user.create.useMutation({ onSuccess: async () => { await utils.user.invalidate(); }, }); return <button onClick={() => addUser.mutate({ name: "xiaoming" })}>Add</button>; }这是最常规的方式,前端通过fetch到/api/trpc这个HTTP入口,走完整的请求-响应链路。
用法三:Server Component和Client Component混合
Server Component负责服务端数据获取,Client Component负责交互。此时Client不要重复请求Server已经拿到的数据,应该把数据作为props传给Client,或者用trpc.useUtils()在Client侧做乐观更新和失效刷新。
我最开始犯过一个错误:在一个Server Component页面里先通过api.user.getAll()拿到用户列表,又在该页面加载了一个Client Component,这个Client Component里再次调用trpc.user.getAll.useQuery()拿同一份数据。结果就是页面加载时浏览器向tRPC发了一次多余的请求,数据从服务端到客户端传了两遍。正确做法是:服务端能拿到的数据不要前端重复拿,前端只需要管用户触发的写操作和实时刷新场景。
4. 实战中踩过的坑:NextAuth、会话失效与服务端组件边界
4.1 NextAuth的Session在Server Component里拿不到
T3 Stack默认集成了NextAuth,初始化时选上它,生成的代码里已经有[...nextauth]/route.ts,并且tRPC的context里已经接好了session。看起来一切天衣无缝,但我在Server Component里获取session时翻车了。
我写了一个服务端的布局组件,想根据登录状态渲染不同的导航栏:
// app/layout.tsx import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; export default async function RootLayout({ children }) { const session = await getServerSession(authOptions); // 这里打印session,结果是null }我用了最新版的NextAuth(Auth.js v5),它的BETA版API和v4不同,getServerSession需要从next-auth导入,但T3 Stack默认生成的代码里,authOptions是定义在server/auth.ts的。问题在于:新版NextAuth的getServerSession对App Router的Server Component暂时有兼容性问题,不能直接调用,必须通过服务端中间件或在tRPC context里取session。
你可能会想:"那我先把页面改成Client Component,用useSession拿总行吧?" 也行,但那样一来,整个布局树都得变成Client渲染,违背了Server Component的性能优势。
我最终的解法是:把session的读取下沉到中间件层,在middleware.ts里验证token,并把用户信息传给Header组件:
// middleware.ts export { default } from "next-auth/middleware"; export const config = { matcher: ["/dashboard/:path*"], // 需要登录保护的路径 };同时tRPC的protectedProcedure再次校验session,双保险。如果只是RSC(Server Component)里要拿用户邮箱,我改成在页面内通过tRPC调用一个小query返回当前用户信息,避免直接依赖next-auth的API在RSC中的兼容性。
4.2 tRPC路由在Fastify的挂载和HTTP上下文处理
默认情况下T3 Stack依赖Next.js自带的API路由(src/app/api/trpc/[trpc]/route.ts)。但我的项目最终部署在Fastify自定义服务器上,需要自己处理tRPC入口。这一步如果只看默认脚手架代码会非常懵,因为默认代码长这样:
import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; const handler = (req: Request) => fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter, createContext: createTRPCContext, }); export { handler as GET, handler as POST };用的是Web标准fetch适配器(@trpc/server/adapters/fetch),这个适配器设计上类比RSC到Next.js的集成,所以挂到Fastify时要自己包一层。实际做法是:
// custom-server.ts import fastify from "fastify"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; const app = fastify(); app.all("/api/trpc/*", async (req, reply) => { // 把Fastify的request/response转换为Web标准Request/Response const request = new Request(`${req.protocol}://${req.host}${req.url}`, { method: req.method, headers: req.headers as unknown as HeadersInit, body: req.raw?.readable ? req.raw : undefined, }); const response = await fetchRequestHandler({ endpoint: "/api/trpc", req: request, router: appRouter, createContext: createTRPCContext, }); reply.code(response.status).headers(Object.fromEntries(response.headers)).send(await response.text()); });这里踩的最大的坑是body读取:Fastify默认会把POST请求的body解析成JSON对象,但fetchRequestHandler期望的是Web请求体的Raw ReadableStream。如果你直接传req.body给new Request(),会遇到"Body is not a valid ReadableStream"之类的错误。解决办法如上,读取req.raw这个Node原生流。
如果不想折腾这些,建议直接用Next.js默认的部署方式(Vercel或Node.js自托管),Fastify这种自定义服务器适合有特殊中间件需求的场景。在《快餐式原型到生产级后端》那种追求速度的项目里,我后来多数情况还是用默认的Next.js server,开发省心很多。
4.3 Server Component与tRPC query的数据刷新问题
一个很常见的场景:页面是Server Component,里面展示了数据库的数据,同时有一个"刷新"按钮,点击后希望数据更新。如果你在Server Component里用caller调用tRPC,数据是"一次性"的,页面刷新才能重新获取。要让前端按钮触发服务端数据刷新,你需要一点妥协:
方案一:把数据展示区域拆成Client Component,用trpc.user.getAll.useQuery()获取数据,并用useMutation完成写操作后调用invalidate()使缓存失效。这是最tRPC原生的做法。
方案二:保持Server Component渲染,按钮作为Client Component,点击时调用router.push刷新页面(或Server Actions)。
我强烈推荐方案一。因为tRPC的queryClient内置了一套完整的缓存、失效、乐观更新机制,如果你不用它,等于把最顺手的武器扔了。
有个细节:T3 Stack初始化的src/trpc/react.tsx里有一行unstable_httpBatchLink,这是tRPC的请求批处理特性。多个query在同一个事件循环内发起时,tRPC会把它们合并成一个HTTP请求发出去。但批处理有个前提:每个请求的method必须是GET或POST且不能互相冲突。我遇到过一个诡异的问题:列表页同时调用了5个query,最后一个怎么都拿不到数据。排查半天发现是其中两个query用了useQuery,一个用了useMutation却因为手滑放在了disabled: false的状态,导致mutation在页面加载时就触发,干扰了批处理的队列。从那时起我规定:mutation绝不放在组件顶层触发,必须显式由用户交互调用。
5. 生产环境部署与性能调优:从构建产物到Docker镜像的持续迭代
5.1 构建阶段的"类型检查陷阱"
T3 Stack项目部署的第一步是next build。如果你在代码里有任何类型错误,这一步会失败。这是好事,因为很多REST项目是"build成功但运行时接口炸",T3把检查点提前了。但副作用是:你写的纯前端组件如果引了一个不存在的Prisma字段,构建直接报红,连warning都没有。
有一次我在一个Client Component里想显示user.createdAt.toLocaleDateString(),但Prisma返回的createdAt类型是Date,Next.js在服务端序列化时把Date转成了ISO字符串,传到客户端变成string,然后toLocaleDateString就变成not a function。这个错误在构建时不会报(因为类型层面都是string | Date的兼容问题),运行时才炸。解决办法是在RSC里提前格式化好,或安装superjson这类序列化方案。T3 Stack默认自带superjson,但如果你在schema里用了Decimal这种Prisma特有类型,还是要注意序列化边界。
5.2 Docker多阶段构建的镜像体积优化
我的部署目标是一台云主机,用Docker Compose跑。默认next start方式构建时,最痛苦的是node_modules体积。T3项目的依赖很多,直接COPY到生产镜像能到2GB+。我的做法是多阶段构建,最终镜像只保留产物和production依赖。
# 依赖阶段 FROM node:20-alpine AS deps WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile # 构建阶段 FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npx prisma generate && pnpm build # 运行阶段 FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/package.json ./package.json EXPOSE 3000 CMD ["pnpm", "start"]这里特别要注意Prisma的两件事:一是npx prisma generate必须在build阶段重新执行,因为Prisma Client会生成与当前架构匹配的引擎二进制;二是运行时还需要schema文件,所以prisma目录必须拷进运行时镜像,否则迁移时会报schema找不到。
最终镜像大概300MB,虽然不比Go二进制苗条,但在Next应用里已经算可以接受的。
5.3 数据库连接池耗尽问题与Prisma连接复用
生产环境跑起来之后,我遇到过一个特别隐蔽的问题:部署在Linux服务器上的应用半夜偶发"Timed out acquiring a connection from the pool",错误在Prisma和tRPC之间来回跳。排查过程让我印象深刻。
原因链条是这样的:Next.js开发模式下会频繁热重载,每个tRPC请求都可能创建新的PrismaClient实例,连接池跟着膨胀。T3 Stack考虑到这一点,在src/server/db/prisma.ts里用globalThis做单例:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;但生产环境是长期运行的Node进程,理论上globalThis不会回收,连接池问题应该不存在。真正的问题出在我的部署脚本:Docker Compose里每次healthcheck失败后会自动重启容器,重启瞬间旧进程没有优雅退出,新旧进程同时抢数据库连接,而Prisma默认连接池上限是num_cpus * 2 + 1,几个实例一叠加就爆了。
最后我做了三件事解决:第一,给Prisma的连接池配置显式设小一点:
new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL, }, }, // 连接池大小按需调整 // 默认的内置连接池不需要额外配置,但可以在url中加connection_limit参数 });第二,Docker Compose里加stop_grace_period: 20s,给旧进程足够时间释放数据库连接;第三,数据库sidecar容器加了连接数上限的告警。这个坑和T3本身关系不大,但如果你用Prisma部署,十有八九会碰上一次。
5.4 缓存层的引入:为什么最后我用React Query的缓存而非Redis
很多人一谈性能就想上Redis做API缓存。T3 Stack里其实自带了一个"隐形缓存"——tRPC的前端queryClient(React Query)。你调useQuery时,相同参数的结果默认会缓存5分钟,且不会发第二次请求。这就是T3应用在列表页频繁切换时依旧流畅的原因。
如果你做了"写操作后调用invalidate()",React Query会精确地只让依赖该数据的query重新请求,而不是粗暴地整页刷新。这套机制的粒度比传统的Redis缓存细多了,因为它在应用层理解"数据依赖关系"。
对我来说,在项目初期先利用好React Query的缓存,比一开始就上Redis更合理。只有跨服务共享的数据(比如多个实例都要读的用户权限)才值得放到Redis。T3 Stack过滤掉了这一层复杂性,让你先专注于业务,是我喜欢它的原因之一。
6. 个人心得:什么时候该选T3 Stack,什么时候不该
经过了这三周实战,我可以负责任地说:T3 Stack不是银弹,但它非常适合特定的项目类型。如果你做的是内部管理系统、原型验证、MVP、中后台工具,或者是一个"必须快速上线且后端逻辑不复杂"的项目,T3 Stack能把你从"写接口、对类型、修联调"中解放出来,让你把时间花在实际业务上。类型安全带来的效率提升,在我这种长期维护REST API的人看来,是实打实的。
但如果你是以下情况,建议慎选:
- 你的系统有大量独立的微服务,API要面向多个异构客户端,tRPC反而会让你把路由层写成胶水。
- 你已经有成熟的REST API团队和生态,重新引入tRPC需要对全团队做持续的约定和规范。
- 你非常在意前端包体积和极致首屏性能,tRPC的批处理和hooks封装肯定比手写fetch重。
- 你的数据流模型特别复杂,需要灵活的事件发布订阅架构,tRPC的强类型反而变成了约束。
回到我最开始的问题——从后端开发者的角度转到T3 Stack,难吗?我觉得真正的门槛不在技术,而在于放下"前后端分疆而治"的执念。你不再需要定义API契约,因为契约就是类型本身;你不再需要反复重启后端服务去验证字段,因为你编译过了基本就通了。这一切让开发变得无聊了许多——但也正是这种无聊,让我能专心打磨真正的用户价值。如果你厌倦了无休止的接口联调,强烈建议找个内部小项目试试t3code这套组合拳。