t3code 这个名字,圈内人一看就会心一笑。T3 Stack —— TypeScript、Tailwind CSS、tRPC,外加 Next.js 打底—— 是过去两年全栈开发社区里讨论度最高的组合之一。我最初注意到 t3code,是因为团队里有人提议用它来统一新项目的脚手架,当时的第一反应是:这不就是把几个流行库拼一起吗?真用起来才发现,这套东西的价值恰恰在于"拼"的方式。它解决的不仅是技术选型的问题,更是开发体验和类型安全从开发期贯穿到运行期的完整闭环。
这篇内容不打算重复官方文档里已经写得很清楚的东西,重点聊聊我实际搭建、改造和排错过程中踩过的坑、验证过的方案,以及 t3code 作为一套开发辅助工具集,在一个真实业务项目里到底能省多少事、又会在哪些地方卡住你。如果你正准备上手 T3 Stack,或者已经在用但总觉得哪里不顺,这篇文章应该能帮你省掉不少摸索的时间。
1. 内容整体设计与思路拆解
1.1 t3code 到底是什么,以及它想解决的问题
先明确一个概念。t3code 在我的理解里,并不是某个单一的开源框架名称,而是一套围绕 T3 Stack 的开发辅助工具集与项目模板体系。它把创建新项目时的目录结构、基础配置、通用模块拆分、类型安全约定全部预置好,让你从一个空目录到跑起来一个带数据库、带 API、带前端页面、带鉴权流程的全栈应用,只需要十几分钟。
这套东西存在的逻辑基础,是 T3 Stack 本身的设计哲学:能少装一个库就少装一个,能不引入复杂状态管理就不引入,能在编译期抓到的问题绝不留到运行期。t3code 正是把这些原则固化成了可复用的代码骨架。
它的核心价值有三层:
- 第一层是脚手架价值:不用每次新项目都从零初始化配置,Tailwind、Next.js、tRPC、Prisma 这些组合的版本兼容性问题,工具集已经帮你踩过一遍。
- 第二层是架构约束价值:目录结构、数据流方向、API 定义方式被预先规范好,多人协作时不容易出现"各写各的"导致的技术债堆积。
- 第三层是学习价值:对于想系统学习 T3 Stack 的开发者,一套组织良好的 t3code 工程比官方文档更接近"实战中它应该长什么样"。
1.2 为什么选 tRPC 而不是 REST 或 GraphQL
这是 t3code 里最核心的架构决策。很多从传统 REST 开发过来的同事,第一眼看到 tRPC 都问过同一个问题:"这不就是 RPC 吗?和直接写接口有什么区别?"
区别非常大,而且用起来才体会得到。
传统 REST 流程是:前端手动拼 URL -> 发请求 -> 拿到 JSON -> 手动写类型断言 -> 和后端对字段。任何一个字段名拼错,运行期直接 undefined,报错还得看运行时日志。
tRPC 的流程是:后端定义一个 procedure -> 前端直接 import 这个 procedure 的调用方法 -> 参数类型、返回值类型、错误类型全部自动推断。字段名拼错?编译期就红了。
这背后的原理不复杂:因为前端和后端都跑在 TypeScript 里,tRPC 利用 TS 的类型推断能力,通过一个类型安全的"路由器"对象,把后端的 API 合同直接暴露给前端。不需要像 GraphQL 那样引入一层 schema 代码生成,也不需要在前后端之间维护一份手写的接口文档。
实际体验就是:定义一次,全链路类型安全。这在 t3code 的整个设计里是第一优先级的。后面我们在 1.3 里会看到,这个设计决策是如何影响目录结构划分的。
1.3 目录结构设计与"约定大于配置"的取舍
t3code 的目录结构看起来和标准 T3 Create 有点像,但有几处关键差异值得单独说。
src/ ├── common/ # 类型、工具函数、常量(不依赖任何框架) │ ├── types/ │ ├── constants/ │ └── utils/ ├── server/ │ ├── api/ │ │ ├── routers/ │ │ └── procedures.ts # 受保护/公开 procedure 的工厂函数 │ ├── db/ │ │ ├── schema.prisma │ │ └── client.ts │ └── auth/ ├── client/ │ ├── components/ │ ├── pages/ 或 app/ # 取决于 Next.js 版本 │ └── providers/ └── shared/ # 前后端共享的 Zod schema 等这个结构的关键在于server 和 client 的物理隔离。很多自己搭 T3 项目的开发者最容易犯的错,就是把 API 路由、数据库操作、页面组件全堆在一层里,导致类型边界模糊,tRPC 的优势直接被削弱了一半。
t3code 的做法是把"数据入口"(server 端)、"数据消费"(client 端)和"数据契约"(shared 里的 Zod schema)严格分开。这样的好处是:当项目从几百行代码膨胀到几万行时,你依然能一眼看出某个数据是从哪个 API 来的、经过了什么校验、以什么形状到达组件——这个可维护性价值,在后期的迭代效率上会体现得特别明显。
2. 核心细节解析与实操要点
2.1 tRPC 路由器的创建与类型安全实现细节
tRPC 是 t3code 的骨架,所以它的初始化代码值得仔细过一遍。这里我会结合一段典型的 initTRPC 代码来分析,然后补充一些文档里不会细说的关键点。
// src/server/api/trpc.ts import { initTRPC, TRPCError } from '@trpc/server' import { z } from 'zod' import { prisma } from '../db/client' import type { Context } from './context' const t = initTRPC.context<Context>().create() export const publicProcedure = t.procedure // 需要登录才能调用的 procedure 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 } }) }) // 基础 router 工厂 export const createTRPCRouter = t.router // 输入校验的标准化方式 export const withValidation = (schema: z.ZodSchema) => t.procedure.input(schema)这段代码里有三个细节值得注意。
第一,protectedProcedure不是简单的"判断是否登录就放行",它是通过next传入一个新的ctx,把当前登录用户的信息"注入"到后续所有中间件和 resolver 里。这意味着在业务代码里,你不需要再手动从 session 里取 user,ctx.user是始终存在的——类型系统保证了这一点。
第二,Zod schema 不只是做校验,它同时承担了类型生成的功能。比如你定义了z.object({ email: z.string().email() }),tRPC 会自动把它转成{ email: string }这个 TypeScript 类型,并且保证这个类型只运行一次校验就同时约束了 API 入口和调用方。这就是 t3code 里"零手写类型"的核心机制。
第三,在 t3code 工具集中,所有 router 都会被聚合到一个根 router 里。这里有一个经验:聚合时一定要用satisfies而不是直接类型断言,这样能确保每个子 router 的类型都被完整保留,否则前端拿到的方法签名就残缺了。
2.2 Zod 校验准则:前端信任、后端真校验
Zod 在 t3code 里被广泛使用,但很多新人对它的使用有一个误区:"既然前端已经做了表单校验,后端是不是可以省了?"这是绝对错误的想法。
我的做法是:前端校验是为了用户体验,后端校验是为了数据安全,两者各管各的。t3code 里所有 procedure 的 input 都必须过 Zod,而且业务上做了一层强制——如果某个 procedure 缺少 input 校验,CI 阶段会在 lint 时直接报错。
一个典型的 user input 校验模式长这样:
const userSchema = z.object({ username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/), email: z.string().email(), age: z.number().int().min(0).optional(), }).strict() // 严格模式,拒绝多余字段 type UserInput = z.infer<typeof userSchema>.strict()是一个容易被忽略但很重要的方法。它让 Zod 在遇到 schema 之外的字段时直接抛错,而不是默默忽略。这在防止"攻击者往请求里塞多余字段"时特别有用。我在实际项目中曾经因为没加.strict(),导致用户往 profile 里塞了一个role: 'admin'字段被数据库直接存进去了——虽然前端不会发这个字段,但恶意请求完全可以绕过前端。
2.3 受保护路由与角色权限控制的实战做法
protectedProcedure 只解决了"是否登录"的问题,但真实业务几乎都会遇到"是否具备某种角色权限"的问题。t3code 的工具集里已经内置了这套扩展。
// 在 protectedProcedure 基础上派生管理员路由 export const adminProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.user.role !== 'ADMIN') { throw new TRPCError({ code: 'FORBIDDEN', message: '需要管理员权限' }) } return next({ ctx }) })这个"层层包裹"的设计是 tRPC 中间件机制的精髓。你可以从 publicProcedure 一路包到 adminProcedure、再到 tenantProcedure(多租户隔离)、再到 resourceOwnerProcedure(资源归属校验),每一层都往 ctx 里追加信息,每一层都在上一层的信任基础上再收紧权限。最终的业务 resolver 只关心自己层级的逻辑,权限链路由中间件链条统一管理。
我在实际项目中验证过,这种分层权限模型在复杂业务里非常稳。比如一个电商系统,订单相关的 procedure 可能需要同时满足:已登录、订单属于当前用户、当前用户有查看该店铺的权限——这些全部通过中间件链实现,业务代码干净得不真实。
2.4 Prisma 数据层设计:schema 先行、迁移有序
Prisma 在 t3code 里的定位是"数据层的事实标准"。它的核心价值不在 ORM 本身,而在 schema 文件带来的数据库结构的可版本化。
我强烈建议从一开始就养成好习惯:所有数据结构变动必须走 migration,禁止手改数据库表结构。
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" // t3code 默认用 PostgreSQL,但可以换成其他库 url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String? password String role Role @default(USER) posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum Role { USER ADMIN }几个实操层面的心得:
- 主键用 CUID 而不是自增 int。分布式场景下自增主键会有冲突风险,而且 CUID 不像 UUID 那么长。Prisma 的
@default(cuid())直接内置了这个方案,建议直接采用。 @updatedAt是 Prisma 自动维护的,你不需要手动在 update 时写updatedAt: new Date(),Prisma 会处理。- 关系字段两侧的查询性能差异很大。设计 schema 时要想清楚是"经常从 User 找 Posts"还是"经常从 Post 找 Author",这决定了你 relation 字段定义在哪一侧,以及查询时用
include: { posts: true }还是where: { authorId: ... }。t3code 的模板里默认两端都定义 relation,但实际写查询时应该按数据访问模式收敛。
2.5 数据库连接与查询性能的常见陷阱
用 Prisma 一段时间后,会发现一个典型性能陷阱:N+1 查询。tRPC 的 resolver 是函数组合,很容易在循环里触发多次数据库查询而察觉不到。
t3code 建议的做法是:
// 错误演示:循环里发查询 for (const item of items) { const author = await prisma.user.findUnique({ where: { id: item.authorId } }) } // 正确做法:一次查出所有作者,用 Map 做内存关联 const authorIds = items.map(i => i.authorId) const authors = await prisma.user.findMany({ where: { id: { in: authorIds } } }) const authorMap = new Map(authors.map(a => [a.id, a]))其次,用 prisma 的select或include时,永远只取你需要的字段。很多人习惯性地select: ['id', 'title', 'content', 'authorId', 'createdAt', 'updatedAt', 'views', 'tags', ...全部字段],结果数据库传了一大堆用不着的数据。t3code 工具集里的代码生成器,会根据 tRPC 的返回类型自动计算最小的 select 子句,这个优化在数据量大了以后收益非常明显。
最后一点:在开发环境打开 Prisma 的查询日志,能看到每个 resolver 执行的 SQL。这是排查性能问题最直接的手段。
DATABASE_URL=... && PRISMA_LOG_LEVEL=query npx prisma generate && npm run dev3. 实操过程与核心环节实现
3.1 从零初始化一个 t3code 项目
假设你的环境已经有 Node.js 18+、pnpm 和 PostgreSQL(也可以用 Docker 起一个临时库),初始化 t3code 的完整流程如下。
# 1. 创建项目 pnpm create t3-app@latest my-t3-app cd my-t3-app # 2. 安装额外依赖(工具集需要的模块) pnpm add zod @trpc/server @trpc/client @trpc/react-query @tanstack/react-query superjson color # 3. 配置环境变量 cp .env.example .env # 编辑 .env 填入 DATABASE_URL、NEXTAUTH_SECRET 等 # 4. 初始化数据库 pnpm prisma migrate dev --name init pnpm prisma generate # 5. 启动开发环境 pnpm dev这里要提一个关键点:T3 Stack 的表面依赖(Next.js、Tailwind、tRPC 等)都可以在官方文档找到,但 t3code 工具集的价值是它把配置决策也替你做好了。
以 Next.js 的next.config.mjs为例:
/** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, transpilePackages: ["@t3code/ui"], images: { formats: ["image/avif", "image/webp"], minimumCacheTTL: 60, }, } export default nextConfigtranspilePackages用来处理 monorepo 下的共享包,reactStrictMode在开发环境帮你暴露潜在问题,images配置则是为了性能优化。
3.2 环境变量管理的最佳实践
T3 Stack 的一个鲜明特点是环境变量治理的严格性。Next.js 14 能内联读取NEXT_PUBLIC_开头的变量到浏览器端,所以 t3code 强制要求:任何不能暴露给前端的密钥,都不得以NEXT_PUBLIC_开头。
我的习惯是把环境变量分成三类:
| 类型 | 示例 | 谁可以读 |
|---|---|---|
| 服务端私有 | DATABASE_URL, NEXTAUTH_SECRET | 仅服务端 |
| 公开环境 | NEXT_PUBLIC_APP_NAME | 服务端和客户端 |
| 本地默认值 | 无(必须显式配置) | 仅本地 |
还有一个容易踩的坑:Next.js 的.env文件虽然支持注释,也支持#开头的注释行,但解析时并不会忽略#在值后面的情况。比如DATABASE_URL=postgres://user:pass@host/db?sslmode=require # TODO这行,整个# TODO会被拼进 URL 导致连接失败。我在同事的代码里见过不止一次这种问题,现在只要看到#出现在变量值后面,就会本能地想报警。
3.3 路由权限与中间件链的完整实现示例
这里给一个稍微完整的业务场景,展示如何通过 t3code 实现多角色、多资源隔离的 API 层。
需求背景:一个内容平台,用户可以是普通作者、审核员、管理员;文章(Post)属于某个作者;审核员只能查看未发布的文章,管理员可以改任何人的文章。
// src/server/api/routers/post.ts import { z } from 'zod' import { createTRPCRouter, protectedProcedure, adminProcedure } from '../trpc' import { prisma } from '../../db/client' const postSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(10), published: z.boolean().default(false), }) export const postRouter = createTRPCRouter({ // 所有登录用户都可以查看公开文章 listPublished: publicProcedure.query(async () => { return prisma.post.findMany({ where: { published: true } }) }), // 作者查询自己的文章(草稿+已发布) myPosts: protectedProcedure.query(async ({ ctx }) => { return prisma.post.findMany({ where: { authorId: ctx.user.id } }) }), // 作者创建文章 create: protectedProcedure .input(postSchema) .mutation(async ({ ctx, input }) => { return prisma.post.create({ data: { ...input, authorId: ctx.user.id } }) }), // 审核员查看未发布文章 pendingReview: protectedProcedure.use(({ ctx, next }) => { if (!['REVIEWER', 'ADMIN'].includes(ctx.user.role)) { throw new TRPCError({ code: 'FORBIDDEN' }) } return next() }).query(async () => { return prisma.post.findMany({ where: { published: false } }) }), // 管理员删除任何文章 remove: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { return prisma.post.delete({ where: { id: input.id } }) }), })这里给读者划个重点:中间件的复用性。pendingReview这个权限判断可以直接抽成一个reviewerProcedure,之后新功能再需要审核权限,一行引入即可。这种粒度控制能力,是 t3code 里"权限模型"的核心资产。
3.4 前端 API 调用模式:React Query 集成与常用写法
T3 Stack 里 tRPC 和 React Query 深度绑定。t3code 的前端模板里已经配置好了 tRPC Provider,你直接在组件里调用就行。
// src/client/components/PostList.tsx import { trpc } from '../utils/trpc' export function PostList() { // 自动处理 loading/error/refetch const { data, isLoading, refetch } = trpc.post.listPublished.useQuery() if (isLoading) return <div>加载中...</div> if (!data) return <div>暂无文章</div> return ( <div> {data.map(post => ( <article key={post.id}> <h3>{post.title}</h3> <p>{post.content}</p> </article> ))} <button onClick={() => refetch()}>刷新</button> </div> ) }有个很实用的技巧:tRPC 的 useQuery 和 useMutation 都支持在调用成功后自动失效并重新获取相关 query。比如创建文章成功后,自动刷新列表:
const utils = trpc.useUtils() const createPost = trpc.post.create.useMutation({ onSuccess: () => { utils.post.listPublished.invalidate() // 自动让 listPublished 重新拉取 }, })这套写法比手写useEffect+fetch简洁太多,而且类型是自动同步的。新项目里我用 t3code 写前端接口调用,几乎不用打开网络面板检查请求路径对不对——类型都帮你兜底了。
4. 常见问题与排查技巧实录
4.1 tRPC 类型报错的排查思路
tRPC 做类型安全最严格,也因此会碰到一些棘手的类型报错,尤其是复杂的嵌套返回类型。比如:
Type 'GetManyArgs<...>' is not assignable to type '...'这类报错的大多数原因是 Prisma 的返回类型和 tRPC 的 output 类型不匹配。最常见的情形是:你在.query()里返回了一个PrismaPromise,但类型里却写了Promise<Post[]>。虽然 JavaScript 层面运行时是正常的,但 TypeScript 在推断时会把 Prisma 内部的特殊类型(比如Post[]带上了PostSelect的复杂泛型)带入 tRPC 类型系统,导致报错。
我自己的排查顺序是:
- 先去掉
.input(),确认是否和输入校验有关。 - 再把
.query()的返回体抽成一个独立函数,手动标注返回类型。 - 最后再套回 tRPC,确认是类型不兼容而不是 tRPC 本身的问题。
有时候问题出在Prisma 生成的$Enums类型与 Zod enum 类型冲突上。比如Role字段在 Prisma 里是 enum,但你在 Zod 里定义的是z.enum(['USER', 'ADMIN'])。两者在结构上等价,但 TypeScript 的字面量类型会被放宽成string,于是赋值时报错。解决方案是显式用z.nativeEnum(Role)来引用 Prisma 的 enum。
4.2 类型安全与数据校验的"度":什么时候该松弛
做了几年全栈,我发现一个容易走极端的点:有人认为类型安全优雅,于是把所有字段都做成强校验,结果导致业务迭代成本暴涨;有人嫌麻烦,全用 any,结果代码烂成一锅粥。
t3code 的取舍原则是:在数据边界做强校验,在组件内部做宽松处理。
举个例子,API 入口的 input 一定用 Zod 强校验,这是安全边界;但组件内部的一些临时状态,比如 filter 的字符串、临时 UI 开关,就不需要搞一个 Zod schema 去约束它,直接用 TypeScript 的string、boolean就够了。如果把校验用在所有地方,只会让代码变得繁琐,反而掩盖真正重要的边界防护。
4.3 Prisma 连接池与函数环境下连接耗尽的排查
函数计算环境(比如 Vercel、Cloudflare Workers)下跑 Prisma,最常见的故障是连接耗尽。Prisma 默认会维护一个连接池,但在 serverless 场景,每个函数实例各自维护连接池的方式反而会导致连接浪费。
我的做法是全局单例 Prisma Client:
// src/server/db/client.ts import { PrismaClient } from '@prisma/client' const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient } export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: ['error', 'warn'] }) if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma这对于本地热更新也有效——如果不做这个单例,每次开发时改代码触发热更新都会新建一个 PrismaClient,最后终端会刷出一堆warning,提示你 "There are already 10 instances running"。
生产环境还要注意数据库连接字符串里加上连接池数量参数:
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?connect_timeout=10&pool_timeout=5"connect_timeout和pool_timeout在高峰期真的能续命。没设这两个值之前,遇到数据库抖动,请求会一直挂着等连接,最终前端超时;设了之后,Prisma 会快速失败并返回可读错误,我可以在业务层做重试或降级。
4.4 前端自动更新时 zustand 状态与 tRPC 缓存的联动问题
t3code 的模板没有内置全局状态库,但实际项目里几乎都会用到 zustand 或 jotai 这类轻量状态库。这里有个常见的联动问题:当用户提交一个 mutation 更新了 server 数据,React Query 缓存虽然失效了,但 zustand 里的全局状态不会自动同步。
我在项目里的通用方案是:把服务端数据视为唯一数据源,客户端全局状态只存 UI 状态。
也就是说,文章列表、用户资料这类"来自服务端的数据",不要放进 zustand。需要时用trpc.post.listPublished.useQuery()去拿,React Query 的缓存机制会帮你处理同步问题。zustand 只负责isFilterModalOpen、currentTab这种纯粹的 UI 状态。
如果实在有跨组件共享服务端数据的需求,优先用 React Query 的useQuery配合context或 props 传递,而不是引入全局 store。一旦把服务端数据塞进全局 store,你就得手动管理它的时效性,这是很多 bug 的来源。
4.5 排查实录:一次"奇怪"的类型丢失问题
最后分享一个让我印象深刻的排查经历。
现象:某个post.listPublished在前端调用时,tRPC 自动推断的返回类型丢了author字段。运行时数据却有author,但 TypeScript 里访问data.author.name直接报错。
排查思路:
- 先看后端 router 的返回类型——
.query()里直接返回了 Prisma 的findMany({ include: { author: true } }),按理说类型应该包含 author。 - 然后检查前端调用——发现我用了
trpc.post.listPublished.useQuery(),但项目的 tRPC 文件里AppRouter的类型定义被手写了,没有用inferRouterOutputs自动推断。 - 结果:手写的类型是旧的(没有 author 字段),覆盖了 tRPC 自动推断。
这类"自定义覆盖自动类型"的问题很难靠编译器发现,因为你写的手写类型不会报错,只是会弱化类型。教训就是:永远不要手动定义 AppRouter 的类型,让它从服务端 router 自动推导。
export type AppRouter = typeof appRouter一行搞定,一劳永逸。
5. 经验总结:从"能用"到"好用"的进阶之路
t3code 这类工具集,刚上手时感觉是"帮你省了配置时间",用熟了才明白它更大的价值是约束团队写出的一致性代码。我参与过多个不同团队维护的 T3 项目,凡是结构规范、类型体系完整的,后面接手的开发效率都明显更高;凡是一开始为了求快绕过类型安全、乱放目录的,后期每次重构都像拆炸弹。
如果你刚开始尝试:
- 花半小时把 t3code 生成的代码从头到尾读一遍,尤其是 tRPC 的
initTRPC那部分,弄懂中间件是如何注入ctx的,后面很多高级玩法都基于这个。 - 不要一上来就加各种装饰器和花哨的库,先跑通"数据库 -> tRPC -> 前端"的最小闭环,再逐步扩展。
- 严格保证所有 server 端数据变更都经过 Zod 校验,这是我唯一坚持不妥协的规范。
最后再分享一个我个人的心得:如果在开发过程中频繁遇到类型不匹配的报错,先别急着用 as 断言强转,回源头看 schema 定义。大多数情况下,是你对业务的理解还不够清晰,而不是 TS 在故意刁难你。类型系统是最好的业务文档,顺着它修改代码,通常能避免很多隐藏 bug。
t3code 的价值,不在于它把多少个库拼在一起,而在于它让"前后端类型安全"从一个需要小心翼翼维护的贵族实践,变成了普通团队也能默认拥有的基础能力。希望这篇内容能帮你上手得更稳,少走几个我当年踩过的弯路。