1. "t3code"这套代号背后,到底藏了什么样的技术选型逻辑
如果你最近在技术社区里刷到t3code这个词,大概率会被各种解读弄得云里雾里。有人把它理解为一套脚手架,有人把它当作某个开源项目的仓库名,还有人干脆把它当成一个"代码即产物"的缩写来用。从我自己的实操经验来看,t3code最合理的落点其实是T3 Stack的延伸——它代表一套以 TypeScript 为骨架、以"端到端类型安全"为最高优先级的技术方案,核心成员包括 Next.js、Tailwind CSS、Prisma、NextAuth.js、tRPC 这几个项目。
为什么这套组合值得拿出来专门写一篇长文?我先说一个最直观的感受:用 t3code 这套栈开发一个全栈应用,你在编写业务逻辑的绝大多数时间里,根本不需要打开浏览器去调试接口返回的数据结构。类型从数据库表定义一路流到前端组件的 props,任何一个字段变化,编译器会直接在编辑器里用红波浪线告诉你。这种感觉和传统"前端写接口对接、后端给接口文档"的开发模式相比,完全是两个时代的工作流。
为了不把话题聊得太抽象,我用一个具体的场景来拆解。假设你要做一个团队内部的任务管理工具,需求是:成员登录、创建任务、分配负责人、更新状态、看板视图。用传统模式,你至少要维护:一套数据库表结构、一套后端接口文档、一套前端请求层类型定义、一套后端参数校验规则,以及 token 的存储和刷新逻辑。而用 t3code 的思路,你只需要定义一个 Prisma schema,tRPC 会自动根据这个 schema 生成路由的输入输出类型,NextAuth 接管会话,前端调用时几乎不用手动写类型断言。
当然,t3code并不是一个官方统一的正式名称,更像社区里对这套实践路径的简称。但正因为如此,我认为深入聊聊它的技术细节才更有价值。本文不会停留在"装个脚手架跑个 demo"的层面,而是从选型理由、目录结构设计、数据层闭环、认证边界、部署调优、真实踩坑这六个方向,把这套内容完整的拆开给你看。
2. 初始化一个 t3code 项目:create-t3-app 的选项取舍与环境配置
2.1 安装命令与交互式选项
推荐的做法是直接使用create-t3-app官方的脚手架,命令如下:
pnpm create t3-app@latest my-t3-app注意包管理器,我强烈建议使用 pnpm,原因后面会专门说。交互式提示会让你选择需要的模块,选项包括:
- Next.js(默认包含,App Router 或 Pages Router 可选)
- TypeScript(强制启用,这是整套方案的基石)
- Tailwind CSS(建议勾选,notion 风格的小工具做样式会非常快)
- Prisma(数据库 ORM,强推)
- NextAuth.js(登录认证,推荐选 JWT 策略)
- tRPC(API 层,核心模块)
- 如果你不需要某个模块,可以不勾选,但那样就失去了"端到端类型安全"的意义,我不太建议那样做。
2.2 一个容易被忽略的坑:包管理器的选择
如果你在初始化项目后立刻遇到"依赖安装缓慢"或"node_modules 结构异常"的问题,多半是用了 npm 或 yarn 1.x。T3 Stack 内部大量依赖 workspace 和 peer 依赖的提升行为,pnpm 的符号链接结构能避免运行时"找不到模块"这类问题。
实测下来,同一份依赖清单,pnpm 的安装速度大约快 30%,磁盘占用少一半。更重要的是,Next.js 的 standalone output 模式在 pnpm 下表现的更稳定,特别是在 Docker 部署时,不会出现 node_modules 里缺失某个间接依赖的诡异问题。
2.3 初始化后的目录结构,读透它你就成功了一半
脚手架生成的目录并不复杂,但每个目录存在的意义都值得说明:
├── prisma │ └── schema.prisma ├── src │ ├── pages │ │ ├── api │ │ │ ├── auth │ │ │ │ └── [...nextauth].ts │ │ │ └── trpc │ │ │ └── [trpc].ts │ │ └── index.tsx │ ├── server │ │ ├── api │ │ │ ├── routers │ │ │ │ ├── post.ts │ │ │ │ └── user.ts │ │ │ └── root.ts │ │ └── db.ts │ └── utils │ └── api.ts ├── tailwind.config.ts └── tsconfig.json重点解释几个容易误解的点:
src/server/api/routers:你的所有业务路由都放在这里,root.ts做聚合导出。注意,路由的划分不是按页面,而是按领域模型。比如任务是核心,就建一个task.ts,不要在路由层模拟 URL 分级。src/server/db.ts: Prisma Client 的单例导出。这个文件的写法非常关键,直接关系到你本地开发时会不会出现"连接数被耗光"的问题。推荐写法:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;src/utils/api.ts: 这是前后端类型连通的核心枢纽。它导出两个东西:一个 React Query 客户端,一个 tRPC 客户端。你在任意组件里调用trpc.task.getAll.useQuery(),返回的data类型就是后端路由定义的输出类型,不需要你再写一份interface Task { ... }。
2.4 环境变量的配置细节
项目的.env文件会自动生成,但很多新人会在这里踩坑:
DATABASE_URL="mysql://user:password@localhost:3306/t3code" NEXTAUTH_SECRET="openssl rand -base64 32 生成的值" NEXTAUTH_URL="http://localhost:3000"第三个变量NEXTAUTH_URL,部署到生产环境后必须改成你的真实域名。如果忘了改,登录跳转会一直指向 localhost,而且 NextAuth 的 callback 会报invalid callback URL。我第一次部署时就卡在这个问题上,排查了整整一个下午。
3. 数据层设计:Prisma schema 的建模痛点与 tRPC 路由封装
3.1 先设计 schema,再谈业务代码
t3code 的开发节奏通常是从schema.prisma开始的。继续拿任务管理工具举例,我常用的模型设计包含用户、任务、项目、评论。其中用户和任务是多对多关系:
model User { id String @id @default(cuid()) name String? email String? @unique image String? tasks Task[] @relation("AssigneeTasks") created DateTime @default(now()) } model Task { id String @id @default(cuid()) title String status Status @default(TODO) projectId String? project Project? @relation(fields: [projectId], references: [id]) assigneeId String? assignee User? @relation("AssigneeTasks", fields: [assigneeId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum Status { TODO IN_PROGRESS DONE }设计时要注意的一个点是Prisma 的枚举类型不支持在 schema 变更后自动迁移默认值。比如你给Status枚举新增一个ARCHIVED,旧记录的 status 字段在数据库层面是合法的,但在应用层枚举中不存在,查询时直接报错。经验是先跑一次prisma generate,再手动处理一下存量数据,或者干脆避免枚举的频繁扩展。
3.2 数据层的两个"隐形杀手":N+1 查询与嵌套写入
使用 Prisma 最常遇到的问题就是 N+1 查询。原因很简单—— Prisma 默认不会像 TypeORM 那样懒加载关联数据,你写include的时候要非常克制。我见过不少人在列表页直接:
const tasks = await prisma.task.findMany({ include: { assignee: true } });如果任务有 50 条,Prisma 会生成 50 条查询去分别查 assignee。开发者看着代码很简单,数据库却被打满了。正确的做法是在数据量小的场景下,直接include没问题,但如果数据量大,就应该用select只取需要的字段,或者把关联查询拆开成两个独立的查询,再在内存里合并。
tRPC 的这一层设计还有一个容易被误解的地方:tRPC 只是一层 RPC 协议,它本身不提供缓存能力。你需要在客户端配合 React Query 的staleTime和gcTime参数来做请求缓存。比如任务是实时性要求高的模块,staleTime设置为 0;用户信息这种变化极少的,可以设置staleTime: 60_000。
3.3 路由封装:输入校验是第一步,也是最重要的一步
tRPC 路由的下层结构一般是"输入校验 + 数据库操作 + 返回结果"。经验告诉我:在 tRPC 中,永远使用 zod 做输入校验,而不是手写 if-else。
const createTaskRouter = t.router({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), projectId: z.string().cuid().optional(), assigneeId: z.string().cuid().optional() }) ) .mutation(async ({ ctx, input }) => { const task = await ctx.prisma.task.create({ data: { title: input.title, projectId: input.projectId, assigneeId: input.assigneeId, createdBy: ctx.session.user.id } }); return task; }) });这里有一个关键点:protectedProcedure决定了这个接口必须登录才能调用。它内部其实是一个中间件,检查ctx.session是否存在。如果你在客户端调用了未登录的接口,tRPC 抛出的错误会被 React Query 捕获,界面上不会白屏,而是返回一个标准的UNAUTHORIZED错误。
3.4 一个提升维护性的习惯:把"查询"和"变更"分开
tRPC 里查询用.query,变更用.mutation,但多人协作时,常常有人把修改操作误写成.query。这不仅是规范问题,还会导致浏览器预加载请求把数据改了。我通常会在 code review 时要求:任何包含create/update/delete的方法必须使用.mutation,并且名字以动作结尾。这件事可以从工具层面拦截,用 ESLint 自定义规则或者利用 tRPC 的类型保护来限制,反正规则定清楚之后团队几乎不会再犯这个错误。
4. 认证与会话边界:NextAuth + tRPC 的协作模式与安全细节
4.1 Session 还是 JWT?取决于你的实时性需求
NextAuth 支持两种会话策略:数据库 Session 和 JWT。t3code 的模板里默认选 JWT,这背后的考虑是:JWT 无状态,不需要在数据库里维护 session 表,应对水平扩展更方便。缺点是如果你要强制踢掉某个用户,JWT 是无法立即失效的,必须等过期时间。
我的做法是:中小型内部工具用 JWT,因为受众小,不需要严格的吊销机制;面对 C 端用户的产品,我会改成 Database Session,因为可以支持"退出所有设备"这类功能。
4.2 在 tRPC 里拿到当前用户
在 tRPC 的 context 里,session是自动从 NextAuth 注入的。写法如下:
export const createTRPCContext = opts => { const session = getServerSession(authOptions); return { session, prisma }; };然后在路由里就能通过ctx.session.user.id拿到用户 ID。这里有一个经常踩的坑:getServerSession只能在服务端调用。如果你在客户端写useSession(),拿到的是一个 Promise,直接传给 tRPC 的useContext会导致 context 为空,然后所有 protectedProcedure 都会报错。
4.3 Cookie 的安全属性,部署前必须检查
NextAuth 默认的 cookie 名称是next-auth.session-token。在本地 http 环境下,cookie 的Secure属性不要求强制开启;但一旦部署到 HTTPS 环境,如果浏览器拒绝写入这个 cookie,登录成功回调就会立刻跳回首页,看起来像"登录无效"。检查方式很简单:浏览器开发者工具里看 Application -> Cookies,如果Secure没有勾上,就需要在authOptions里配置:
cookies: { sessionToken: { name: "next-auth.session-token", options: { httpOnly: true, sameSite: "lax", path: "/", secure: process.env.NODE_ENV === "production" } } }4.4 权限模型:不要用中间件处理所有事
很多人会用 Next.js 的 middleware 做全局鉴权,比如在src/middleware.ts里拦截所有页面请求。但你要清醒的认识到,middleware 只能做"页面前置路由"级别的拦截,它管不了 tRPC 路由级别的授权。
正确的做法是:tRPC 路由内部自己控制权限。比如一个任务的创建者才允许删除它:
delete: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { const task = await ctx.prisma.task.findUnique({ where: { id: input.id } }); if (!task) throw new TRPCError({ code: "NOT_FOUND" }); if (task.createdBy !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } return ctx.prisma.task.delete({ where: { id: input.id } }); })tRPC 的错误代码映射到 HTTP 状态码是有规律的:NOT_FOUND -> 404、FORBIDDEN -> 403、UNAUTHORIZED -> 401。React Query 默认不会区分这些错误,你需要在错误回调里判断error.data.code来给用户展示不同的提示。
5. 性能与部署环节:从一个本地可跑的 demo 变成真正能上线的服务
5.1 用 standalone 模式做 Docker 化部署
Next.js 默认的构建产物中node_modules依然存在,镜像体积可能超过 2GB,根本没法快速发布。打开standalone模式后,构建产物中会生成一个独立的server.js,配合outputFileTracing只包含运行所需的文件,镜像体积可以压缩到 200MB 以内。
具体配置在next.config.mjs中:
const nextConfig = { output: "standalone" };然后 Dockerfile 里只拷贝.next/standalone和.next/static,再用一个轻量 Node 运行即可。我实测过,在 1GB 内存的小机器上,这个模式可以非常流畅的运行。
5.2 数据库连接池:Prisma 在 Serverless 环境下的致命伤
如果你把项目部署在 Vercel 这样的 Serverless 平台上,直接使用new PrismaClient()会遇到一个问题:每个 Lambda 实例都会创建独立的数据库连接,短时间内连接数会被打爆。解决方案有两种:
第一,使用 Prisma Accelerate,它是一个代理层,负责连接池和缓存查询结果,这种做法额外引入了一个基础设施,但最省心。
第二,自建连接池,例如用 PgBouncer 或 MySQL Proxy,在数据库前面加一层连接池服务。这种方案调整成本较大,适合有自建运维经验的团队。
我自己的经验是:中小项目直接用 Prisma Accelerate 免费额度足够;等到并发量真的上来,再考虑迁移到自建连接池。不要在一开始就造轮子。
5.3 边缘函数的陷阱:tRPC 不能在 Edge Runtime 正常运行
Next.js App Router 里,我们可以给 route handler 设置export const runtime = "edge"。但tRPC 依赖 Node.js 的某些原生模块,比如@trpc/server里的 node-fetch 相关依赖,它在 Edge Runtime 下会直接报错。所以如果你的项目用了 tRPC,API 路由只能保留 Node.js Runtime(默认),不要为了追求低延迟把它改成 Edge。这一点很容易被性能优化的文章带偏,我踩过这个坑。
5.4 定时任务与后台队列:别把异步逻辑塞进 API 路由
T3 Stack 没有内置任务队列。如果你想做"任务到期自动提醒",比较常见的做法是:
- 单独部署一个 Node 脚本(比如用
node-cron)连接同一个数据库,定期扫描数据库里的任务状态并推送。 - 用 Vercel Cron Jobs 触发一个专门的 API 路由来执行这些逻辑,但要注意路由的执行时间限制(免费计划 10 秒)。
不要直接在 tRPC mutation 里发起setTimeout或异步任务,因为 Serverless 函数的生命周期不保证这类逻辑能执行完。
5.5 构建体积分析,别让 Tailwind 拖慢首次加载
使用 Tailwind CSS 时,如果开启了purge,生产环境的 CSS 已经很小了。但有一个隐藏问题:Tailwind 默认的preflight样式会重置所有元素的边框和外距,这影响的是渲染表现,不是体积。真正影响体积的是你content配置里包含的范围。如果content配置里写的是"./src/**/*.{ts,tsx}",它会把所有类型的文件都纳入扫描,包括一些不包含类名的纯逻辑文件。虽然不影响功能,但会让开发环境的 HMR 变慢。建议精确指定:
content: ["./src/pages/**/*.{ts,tsx}", "./src/components/**/*.{ts,tsx}", "./src/app/**/*.{ts,tsx}"]6. 我踩过的几个坑,以及这套方案后续怎么演
6.1 热更新后 Prisma Client 不生效
改完schema.prisma后,忘了执行prisma generate,然后发现所有数据操作还是旧结构,代码也不报错。这个坑非常隐蔽,因为 TypeScript 类型是根据旧的 Prisma Client 生成的,新字段根本不会被编译器提示。解决方式很简单:每次改 schema 后立即pnpm prisma generate,并把这个命令挂在postinstall脚本里,保证 CI 或新成员拉代码后不会漏。
6.2 类型引用的循环依赖:router 之间互相 import
你的业务增长后发现taskRouter需要userRouter里的某个内部函数,而userRouter又反过来引用taskRouter,这时候 TypeScript 会报循环依赖。我的建议是:把公共逻辑抽离到一个单独的service层,不要在 router 之间直接互相调用。tRPC 本身的 router 结构是扁平的,它不思虑服务之间的依赖图,复杂时维护性会下降。
具体到一个例子:任务列表页面需要显示"分配给当前用户的未完成任务",这种跨模块查询应该在 taskRouter 里定义getMyAssignedTasks,而不是试图从 userRouter 里暴露一个通用的tasks方法。
6.3 中间件在 App Router 下的新写法
如果你用的是 Next.js 14 的 App Router,src/pages目录会退化为 API 路由专用的位置。t3code 项目和这个兼容得很好。需要注意的一点是middleware.ts的写法在 App Router 下定义了新的约定:它默认导出一个middleware函数,不能使用 Node.js 的某些 API,比如fs。如果你在 middleware 里读.env,会报错。解决方式是使用process.env注入的环境变量,而不是读取文件。
6.4 这套方案还能怎么长
t3code的价值不是固定在"Next.js + tRPC + Prisma"这个组合里。我现在的项目里已经把它演变成了:
- monorepo 结构:用 Turborepo 把
apps/web、apps/api、packages/db拆开,数据库 schema 和业务路由抽到共享包里。 - 测试策略:在 tRPC router 上直接写单元测试,不需要起服务,因为 router 就是一个纯函数式对象,调用时传入 mock 的 ctx 即可。
- 事件驱动:当业务需要给用户发通知时,在 mutation 里打一条"事件记录",由后台任务去消费。这个改动不会破坏类型安全,因为事件负载本身也用 TS 类型和 zod 定义了。
坦率地说,t3code 并不是什么银弹,它的核心价值就一条:把类型安全贯彻到整个请求链路里,让编译器和编辑器替你挡住一大部分低级错误。如果你恰好受够了"切接口文档、对字段名、调类型"的日子,不妨用它重写一个内部系统试试。大概率你会回不到从前那种"定义一层 data 再满世界 any"的写法了。