☰
T3 Stack实战:用tRPC构建类型安全的全栈应用
2026/10/28 5:18:09 网站建设 项目流程

t3code 这个名字第一次出现在我仓库里的时候,团队里同事都以为是什么加密协议。其实就是我给一个内部全栈项目起的代号,核心含义是:用 T3 Stack 这一套组合拳写出来的完整代码。T3 Stack 在最近的全栈社区里热度很高,它由 Next.js、TypeScript、tRPC、Tailwind CSS 四件套组成,Prisma 和 NextAuth.js 经常作为数据库和认证层一起加入。我当时的问题是:到底用传统 REST 接口,还是上个 GraphQL,或者干脆试试 tRPC?折腾了一段时间后,我选择了 t3code 这条路线。如果你也正在纠结全栈项目的技术选型,或者听说过 T3 Stack 但一直没看清它怎么落地,这篇文章应该能帮你省不少时间。

1. 为什么是 t3code:从技术选型到项目定位

1.1 T3 Stack 到底在解决什么问题

先说清楚 T3 是什么。t3code 里的 T3,对应的是社区里已经比较有名气的 T3 Stack。它并不是一个所有代码都要重新实现的框架,而是一套最佳实践组合:前端页面和路由用 Next.js,语言层用 TypeScript,接口层用 tRPC,样式层用 Tailwind CSS。数据库通常接 Prisma,认证通常接 NextAuth.js。T3 这个名字来自 Theo 的随口一提,因为 TypeScript、tRPC、Tailwind 三个词的首字母都是 T,所以叫 Three T。

我最早接触到这个概念是在一个开源项目的 README 里,当时第一反应是“又是花活”。但真正在小项目里试过一次后,我的看法变了。过去写一个全栈功能,流程通常是后端定接口、写文档、给前端一份类型定义,前端再根据文档去调用。问题是文档和类型定义很容易过期,字段一改,前后端经常各改各的,最后联调时才发现对不上。T3 Stack 的核心思路就是消灭这个“翻译层”:用 tRPC 定义后端路由过程,前端会自动推导出对应的 hooks 类型。改一个字段类型,保存代码的瞬间,另一端就编译报错。这种感觉就像两个人明明说同一种方言,却偏偏请了个翻译,最后把翻译辞退直接对话,效率和安全感都上来了。

为什么我要把项目代号叫 t3code,而不是直接叫项目业务名?因为我希望这个仓库本身就是一个可复用的全栈模板,里面沉淀了我踩过的版本坑、配置细节、目录划分方式,以后开新项目直接复制改改就能用。它本质上是一个“T3 Stack 实战代码库”,既是业务项目,也是一份长期维护的技术样板。

1.2 这个项目定位:适合谁,解决什么业务

t3code 要解决的业务场景并不复杂。我做的是一个内部效率工具,核心功能包括用户登录、资源列表、筛选、新增编辑删除、以及一个简单的数据仪表盘。没有高并发的诉求,没有对外开放 API 的需求,但对开发速度要求比较高,因为内部工具的生命周期通常很短,需求变化却很快。

这类项目用传统前后端分离方案也能做,但会有很多重复劳动。分开两个仓库管理,加上接口联调、类型维护、环境部署,时间成本会翻倍。T3 这种全栈同仓库的方案,天然适合内部工具、管理后台、MVP 验证产品。你可以把后端逻辑、数据库模型、前端页面放在一个工程里,逻辑链路短,改动反馈快。我之前也做过一个对比,同样的 CRUD 功能,用 Next.js + tRPC 写,比用 Express + REST + 前端对接的时间大概省了三分之一,省下来的时间主要花在“没有类型对不上”的地方。

当然,它也不是万能的。如果你要做的是面向第三方的开放 API,需要稳定的 OpenAPI 文档给外部团队使用,那 tRPC 并不是最优选。t3code 的价值边界非常清晰:适合应用内部链路,不适合公共契约场景。后面我会专门展开这一点。

2. 项目骨架:分层与数据流

2.1 目录结构怎么摆才不容易乱

从 create-t3-app 脚手架出来的目录结构,已经帮你划好了大部分边界,但如果你没有理解它为什么要这么分,很容易在写大之后变得混乱。以一个标准 t3code 项目的 src 目录为例:

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc]/ │ │ │ └── route.ts │ │ ├── _components/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── post.ts │ │ │ │ └── root.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── react.tsx ├── .env ├── next.config.js ├── tailwind.config.ts └── tsconfig.json

这个目录的关键点在于:把所有“服务端相关”的代码收敛到 src/server 下,把“tRPC 客户端相关”的初始化收敛到 src/trpc 下,页面组件放在 src/app 里。这样你打开一个文件就能快速判断它运行在哪个环境,是服务端逻辑还是客户端逻辑,是数据访问还是纯 UI。很多项目越写越乱,就是因为把 tRPC 路由、数据库操作、页面请求逻辑混在一起,最后连一个 getServerSideProps 都塞满了业务代码。

在 t3code 里我自定义的分层习惯是:routers 里只做参数校验和与业务相关的编排,复杂的数据库查询逻辑拆到独立函数里,页面组件里尽量不直接写数据访问逻辑,而是调用由 tRPC 生成的 hooks。这样做的直接好处是,未来如果想换掉数据库或者调整查询逻辑,页面层完全不需要动。

2.2 一次查询请求是怎么走完的

看到这么多目录,新手容易懵:一次查询到底从哪进、到哪出?我根据实际运行流程整理了一下。当用户在浏览器里调用trpc.post.all.useQuery()时,实际发生的事情是:

浏览器中的 tRPC client 把请求打包成一个 HTTP POST,发送到 Next.js 提供的/api/trpc/post.all端点。服务端 route handler 解析请求,取出 json 数据,找到 postRouter 里的 all 过程,执行里面的业务逻辑。业务逻辑里通过 context 拿到 Prisma client,查询数据库,然后把结果序列化成纯 JSON 返回给前端。前端拿到数据后,因为类型是编译期推导出来的,所以组件里能直接感知到 data、error、isLoading 的状态。

这个链路的重点是 context。tRPC 的 context 是在每个请求进入 router 之前执行的一段代码,它负责把数据库 client、当前登录用户信息、请求头等放进上下文对象里。后续所有 procedure 都可以从 ctx 中取出这些依赖。我在创建 t3code 时,context 的代码大概是这样的:

import { getServerSession } from "next-auth"; import { authOptions } from "@/server/auth"; import { db } from "@/server/db"; import { createTRPCContext } from "@trpc/server"; export const createContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db, session, headers: opts.headers, }; };

这里有个容易踩的坑:createContext的返回类型会直接影响后面所有 router 里ctx的类型推导。如果你在这里返回了错误的字段名,比如把 session 写成 user,前端不会报错,但在路由里写ctx.session.user.id时 TypeScript 会立即给出一堆红色波浪线。类型推导的好处就在这,它不是运行时才暴露,而是在你写代码的瞬间就逼你修正。

2.3 为什么我不为 t3code 写一堆 REST API 路由

以前我写 Next.js API routes 的时候,会在pages/api或app/api下面建一堆route.ts,然后每个文件里写 GET/POST 的 handler,再手动做 Zod 校验、错误处理。这套模式最大的问题是:每个接口都需要重复处理“取参、验参、查库、包装返回”的流程。如果做十个资源,就要维护十个文件,如果做几十个资源,代码会立刻膨胀。

tRPC 的思路是把接口定义和函数调用抽象在一起。你不用再面对“HTTP 方法 + URL 路径”的概念,而是思考“这个过程入参是什么、出参是什么”。每个 router 里的一组 procedure 就是一组接口,但它们以 TypeScript 函数的形式被消费。t3code 项目的所有请求都汇聚在同一个/api/trpc端点下,后端只负责按请求路径路由到对应 procedure。这样既不牺牲 URL 语义,又彻底解决了重复代码的问题。

我不是说 REST 不好。REST 的语义非常适合资源公开、跨语言、跨团队协作。但在 t3code 这种前后端同仓库、同语言的内部项目里,REST 的“清晰接口边界”反而成了一种负担:你维护接口文档和类型定义的成本,已经超过了它带来的好处。tRPC 本质上是一种 RPC,它的选择逻辑很简单——应用内部的通信链路越短越好,跨应用边界的通信才需要更标准的协议。

3. 核心功能实现:跑通第一条完整业务链路

3.1 从零初始化项目:脚手架与依赖安装

t3code 是从官方的 create-t3-app 脚手架开始搭的。初始化命令很简单:

pnpm create t3-app@latest t3code

执行过程中会问你选哪些模块。我的选择是:Next.js App Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth.js。如果你不确定,推荐只勾选自己真正用到的模块。很多人会一股脑全勾,结果项目里堆了一堆没用的配置,反而增加理解成本。

初始化完成后,脚手架会自动生成一套已经配置好的基础代码,包括 tRPC 的初始化、Prisma client 的实例、NextAuth 的接入、Tailwind 的全局样式。这一步看起来简单,其实是整个项目最值得感谢的地方。因为这些库之间的版本兼容问题非常多,手动整合往往要在 “React 19 适配” “Next.js 15 中间件改动” 这些坑里泡几天,而脚手架已经提前帮你处理掉了大部分。

装完依赖后第一件事不是写业务代码,而是先跑一遍pnpm dev,确认页面能正常打开、tRPC 的 health 查询能连通。如果这一步都通不过,先解决环境问题,再动业务代码。基于我自己带人的经验,这一步花 10 分钟做健康检查,比后面排查半天强得多。

3.2 数据建模与第一次迁移

t3code 的数据库模型以 Prisma schema 为准。我定义的第一版模型很简单,一个用户对多个资源记录:

model User { id String @id @default(cuid()) name String? email String? @unique image String? posts Post[] accounts Account[] sessions Session[] } model Post { id String @id @default(cuid()) title String content String? published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

写完 schema 后执行迁移命令:

npx prisma migrate dev --name init

这里有一点非常关键:Prisma 的 migrate 命令不只是创建表结构,它还会生成 client 代码。你在写 router 时输入的ctx.db.post.findMany()能获得完整类型提示,依赖的就是这个生成过程。如果你改了 schema 却没有重新生成 client,TypeScript 会提示你找不到对应属性,这是新手最容易困惑的问题之一。我个人的习惯是每次改完 schema,都顺手跑一遍npx prisma generate,再回来写代码。

迁移完成后,可以用npx prisma studio打开一个可视化的数据库管理界面,检查数据表和数据是否符合预期。这一步在开发期特别方便,能直观看到数据库里到底存了什么,比在终端里敲 SQL 高效得多。

3.3 写一个带权限控制的 tRPC Router

在 t3code 项目里,最核心的后端代码是 router。它既承担参数校验,也承担业务逻辑编排。下面这段代码是我在项目里使用的 postRouter:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure, } from "@/server/api/trpc"; import { TRPCError } from "@trpc/server"; export const postRouter = createTRPCRouter({ all: publicProcedure.query(({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, include: { author: { select: { name: true, image: true } } }, }); }), byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id }, include: { author: true }, }); if (!post) { throw new TRPCError({ code: "NOT_FOUND" }); } return post; }), create: protectedProcedure .input( z.object({ title: z.string().min(1, "标题不能为空"), content: z.string().optional(), }) ) .mutation(({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });

这段代码里有几个值得留意的点。第一,publicProcedure和protectedProcedure是 tRPC 里非常实用的权限抽象。public 表示任何人可调用,protected 表示必须登录后才能访问。第二,输入校验用的是 Zod,并且在校验不通过时抛出的错误会自动转换成 400 响应,不需要你手动处理。第三,TRPCError的 code 类型是限定的,写错字符串编译器会直接提示,这比普通 REST 接口里随手 throw 一个 Error 要安全得多。

为什么我不在页面里直接操作 Prisma?理论上 Next.js Server Component 里是可以直接查数据库的,但对于需要权限控制的页面,很容易漏掉校验。把所有数据库操作收敛到 tRPC router 里,等于在每个操作入口都放了一道统一检查和日志记录点,代码审计时也更清晰。

3.4 前端页面怎么消费这些接口

在页面组件里,初始化好的 tRPC client 已经把后端 router 类型推导到了前端。我们不需要手动引入任何请求函数,直接用 hooks 即可:

"use client"; import { trpc } from "@/trpc/react"; export function PostList() { const { data, isLoading, error } = trpc.post.all.useQuery(); const addPost = trpc.post.create.useMutation({ onSuccess: () => { trpc.useUtils().post.all.invalidate(); }, }); if (isLoading) return <p>加载中...</p>; if (error) return <p>加载失败:{error.message}</p>; return ( <ul> {data?.map((post) => ( <li key={post.id}> {post.title} - {post.author?.name} </li> ))} </ul> ); }

这段代码其实就是整个 t3code 使用体验的缩影。让我感到舒适的地方在于:post.all返回的数据结构直接出现在data的类型里,如果你在 Prisma 模型里改了字段名,前端这里会立刻报错,你不会等到运行时才发现。useMutation的onSuccess里手动 invalidate 了列表查询,这个操作等于告诉 tRPC:“刚才的缓存失效了,重新拉一次”。这是 t3code 里我用到最多的模式:变更成功后刷新相关列表。

要注意的是,文件顶部的"use client"是必须的,因为 tRPC hooks 依赖 React 的客户端状态管理,不能放在 Server Component 里。很多第一次用 Next.js App Router 的人会把所有组件默认当服务端组件,然后在里面调用 useQuery,结果直接报错。我的经验是把“页面容器”做成服务端组件,把“需要交互的区域”拆成独立的客户端组件,两者通过 props 传数据,这样既吃到了服务端渲染的好处,又保留了交互能力。

3.5 部署时的环境变量与构建注意点

t3code 整个项目跑通业务后,离真正可用还差一步:部署。最常见的方式是 Vercel,但你要提前把环境变量配置好。核心变量有三个:

  • DATABASE_URL:数据库连接字符串,在生成 Prisma client 时必须存在。
  • NEXTAUTH_SECRET:加密 session 的密钥,可以用openssl rand -base64 32生成。
  • NEXTAUTH_URL:当前部署环境的地址,Vercel 会自动注入,但本地开发时需要手动配置。

部署前建议先跑一遍pnpm build,不要等到 Vercel 构建失败再回来看。我在本地构建时踩过一次坑:因为.env里没有配置NEXTAUTH_SECRET,构建阶段 getServerSession 尝试解密时直接抛错,整个 package 阶段卡住。后来我把所有必须的环境变量都以示例形式写进了.env.example,并在 README 里注明“没有这些值项目跑不起来”。这是一件小事,但能帮你省掉非常多的生产事故。

4. 踩坑实录:t3code 开发中的典型问题

4.1 tRPC 版本升级引发的前端 hooks 失效

t3code 项目运行不到两个月,我把 tRPC 从 v10 升到 v11,结果前端所有.useQuery()突然全部报类型错误。查了文档才发现,v11 调整了初始化方式,原来的createTRPCNext被改成了createTRPCReact,类型引用路径也变了。 升级前我建议你看一眼官方的 migration guide,不要直接改依赖版本号。

我的处理方式是先在一个分支上跑完pnpm dlx @trpc/upgrade这类迁移工具,再检查所有 import 路径。这里有个小经验:tRPC 的版本跟 Next.js 的版本经常会有联动,如果你用的是可以自动升级的脚手架模板,不要锁定一个小版本不动,也不要追最新版本。正确做法是固定一个大版本,比如 v11,然后在小版本间保持稳定,等有重大更新时再专门安排时间升级。

4.2 Prisma 在 serverless 环境的连接数膨胀

这个问题在 Vercel 上部署 t3code 的时候特别明显。每个函数实例启动时都会创建 Prisma client,如果使用传统数据库,连接数很快会被打满,页面表现为有时能打开有时直接 502。解决方向有两个:一是把连接池参数写到 DATABASE_URL 里,让 Prisma 通过 PgBouncer 这样的连接池访问数据库;二是考虑用 Prisma Accelerate 这类服务,在数据库前面提供一个代理层。如果你的数据量不大,最简单的方案是确保 Prisma client 是全局单例。create-t3-app 默认生成的 db.ts 里其实已经做了全局缓存,但很多人会在自己的代码里重新new PrismaClient(),导致连接暴涨。

我检查 t3code 代码时,特意搜索了一遍new PrismaClient,确保它只出现在src/server/db.ts里。所有 router 应该只通过 context 里的db访问数据库,而不是自己直接 new。这一点写进团队规范后,连接数问题基本就消失了。

4.3 NextAuth session 在中间件里取不到

t3code 里有一个场景:某些页面需要登录才能访问,我习惯在 Next.js 中间件里做跳转判断。但实测发现,中间件运行的 Edge 环境并不支持直接读取数据库 session,getServerSession在里面拿不到值。这个问题让我折腾了很久,最后解决办法是:不要在中间件里依赖完整的 session,而是用 JWT 策略,在authOptions的callbacks里把关键用户信息写进 token,中间件里只解析 token。

callbacks: { async jwt({ token, user }) { if (user) token.id = user.id; return token; }, async session({ session, token }) { if (session.user) session.user.id = token.id as string; return session; }, }

这段代码看着简单,但它解决的是 t3code 里“页面权限控制”和“数据库记录用户”两个核心问题。权限判断在中间件层完成,数据操作在 router 层完成,两边各司其职。

4.4 Tailwind 动态拼接类名不生效

在写 t3code 的仪表盘组件时,我用了一个动态类名拼接函数,比如bg-${color}-500,结果颜色死活不生效。原因很简单:Tailwind 的 JIT 编译只会扫描源码里完整的类名字符串,动态拼接出来的类名它看不到,自然也不会生成对应样式。

解决方法有两个:要么在配置里使用 safelist 明确列出所有可能用到的颜色类;要么把条件判断写成完整类名的对象映射。我后来选择了后者,因为 safelist 需要手动维护,容易漏。完整类名的方式虽然代码长一点,但一眼就能看出所有可能状态。

4.5 Context 里字段类型不匹配引发的连锁报错

项目早期,有人在createContext里直接写死了session: null来调试,结果所有受保护路由的类型全部报错。因为 tRPC 的类型是自动推导的,context 返回 null,表示“这个路由永远不可能有登录用户”。后来我明白了:context 里 session 的类型应该由 NextAuth 的 Session 类型自动推导,不能手动写死或者偷懒。正确做法是让 provider 和 context 都基于同一个类型来源,这样改一处,另一端马上感知。

这类问题最大的麻烦在于错误提示往往出现在页面调用处,而不是 context 定义处。你看到一堆组件里的红色波浪线,但找不到根因。我的排查习惯是:先从报错信息最底层的 import 路径开始看,再一层层向上追踪类型来源,而不是在组件里瞎改。

5. t3code 实测评价与适配建议

5.1 开发体验对比:tRPC、REST、GraphQL

在 t3code 里反复使用 tRPC 之后,我整理了一张对比表,方便后续选型时参考:

维度tRPCRESTGraphQL
前端类型安全极强,编译期推导弱,依赖手写类型中,依赖 codegen
学习成本低,像函数调用低,但概念多较高,需要理解 schema
调试难度中,日志偏黑盒低,直接看 URL较强,有 GraphiQL
生态成熟度中等,社区快速成长极高高
适用场景同仓库全栈、内部工具开放 API、跨团队多端复杂查询聚合

我的感受是:tRPC 不是来替代 REST 的,而是给“前后端同仓库、同语言”的场景一个更高效的选项。如果你要做一个长期对外服务的公共 API,REST 依然是稳妥选择。但如果你只是要把团队内部工具做出来,tRPC 的开发效率和类型安全性真的会上瘾。

5.2 代码量和性能的真实感受

以 t3code 里的 Post 资源为例,我从模型定义到前端列表展示,所有代码加起来不到一百行。如果用传统的 controller、service、repository 模式拆分,单是样板文件就会超过这个数量。tRPC 把样板代码压缩到了最低,所以你可以把精力集中在真正的业务逻辑上。性能方面,因为所有请求都走同一个 HTTP 端点,理论上会在日志统计上多一个路由字段,但实际网络开销和普通 JSON 接口几乎没区别。

我实测过一个小接口的平均响应时间,大概是 8ms 到 20ms 之间,这跟同等的 Express 接口没有什么明显差异。因为 tRPC 只不过是在 HTTP 之上增加了序列化协议,底层的数据库查询和网络传输才是瓶颈。

5.3 什么时候别用 t3code 方案

写这么多推荐的话,我也要泼一盆冷水。以下几种情况不建议把它用在生产项目里:

第一,如果你的产品需要给第三方提供 OpenAPI 文档,tRPC 的协议不是业界标准,外部接入方会非常困惑。第二,如果团队是多人前后端分离,前端是 React 以外的技术栈,比如 Vue 或小程序,tRPC 没法直接发挥类型推导优势。第三,如果业务有极其复杂的权限模型,需要针对不同客户端隐藏字段,REST 和 GraphQL 的按资源暴露能力反而更成熟。

t3code 这个方案的黄金位置,是“一个人或一个小团队把一个全栈应用从零做到上线”的场景。它让你少操心接口层,多关注产品本身。这个定位不是缺点,而是一种清醒。

6. 一些我觉得真正重要的经验

项目写到后期,我对 t3code 的整体感受已经从“技术很酷”变成了“组合很稳”。最让我留意的不是某个库有多强,而是它们之间的边界有多清晰。Next.js 管渲染,TypeScript 管类型,tRPC 管数据流,Prisma 管数据库,Tailwind 管样式,每个工具都在自己擅长的位置上,几乎没有越界。

最后分享两个让我受益最多的小习惯。第一,在写任何 router 之前,先把 Prisma schema 想清楚,字段设计不够好,后面所有 router 都要跟着改,成本极高。我建议 schema 定义用 10 分钟想清楚,而不是上手就写,等迁移了三版再来调整关联关系。第二,遇到诡异 bug 时,先检查依赖版本和.env,再检查代码逻辑。t3code 这类现代全栈项目的坑,一半以上来自环境或版本不兼容,而不是你写的逻辑有问题。

如果你也准备开一个新的内部项目,我建议你直接试一次这套组合。第一次用 tRPC 的时候,你可能会觉得“连接口文档都不写,心里不踏实”,但坚持把一个完整功能做完,大概率会回不去。代码是长在类型系统上的,数据结构一变,整个链路会同时提醒你,这种安全感就是 t3code 给我的最大价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询