
1. t3code这套代号背后到底藏了什么样的技术选型逻辑如果你最近在技术社区里刷到t3code这个词大概率会被各种解读弄得云里雾里。有人把它理解为一套脚手架有人把它当作某个开源项目的仓库名还有人干脆把它当成一个代码即产物的缩写来用。从我自己的实操经验来看t3code最合理的落点其实是T3 Stack的延伸——它代表一套以 TypeScript 为骨架、以端到端类型安全为最高优先级的技术方案核心成员包括 Next.js、Tailwind CSS、Prisma、NextAuth.js、tRPC 这几个项目。为什么这套组合值得拿出来专门写一篇长文我先说一个最直观的感受用 t3code 这套栈开发一个全栈应用你在编写业务逻辑的绝大多数时间里根本不需要打开浏览器去调试接口返回的数据结构。类型从数据库表定义一路流到前端组件的 props任何一个字段变化编译器会直接在编辑器里用红波浪线告诉你。这种感觉和传统前端写接口对接、后端给接口文档的开发模式相比完全是两个时代的工作流。为了不把话题聊得太抽象我用一个具体的场景来拆解。假设你要做一个团队内部的任务管理工具需求是成员登录、创建任务、分配负责人、更新状态、看板视图。用传统模式你至少要维护一套数据库表结构、一套后端接口文档、一套前端请求层类型定义、一套后端参数校验规则以及 token 的存储和刷新逻辑。而用 t3code 的思路你只需要定义一个 Prisma schematRPC 会自动根据这个 schema 生成路由的输入输出类型NextAuth 接管会话前端调用时几乎不用手动写类型断言。当然t3code并不是一个官方统一的正式名称更像社区里对这套实践路径的简称。但正因为如此我认为深入聊聊它的技术细节才更有价值。本文不会停留在装个脚手架跑个 demo的层面而是从选型理由、目录结构设计、数据层闭环、认证边界、部署调优、真实踩坑这六个方向把这套内容完整的拆开给你看。2. 初始化一个 t3code 项目create-t3-app 的选项取舍与环境配置2.1 安装命令与交互式选项推荐的做法是直接使用create-t3-app官方的脚手架命令如下pnpm create t3-applatest my-t3-app注意包管理器我强烈建议使用 pnpm原因后面会专门说。交互式提示会让你选择需要的模块选项包括Next.js默认包含App Router 或 Pages Router 可选TypeScript强制启用这是整套方案的基石Tailwind CSS建议勾选notion 风格的小工具做样式会非常快Prisma数据库 ORM强推NextAuth.js登录认证推荐选 JWT 策略tRPCAPI 层核心模块如果你不需要某个模块可以不勾选但那样就失去了端到端类型安全的意义我不太建议那样做。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_URLmysql://user:passwordlocalhost:3306/t3code NEXTAUTH_SECRETopenssl rand -base64 32 生成的值 NEXTAUTH_URLhttp://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 数据层的两个隐形杀手N1 查询与嵌套写入使用 Prisma 最常遇到的问题就是 N1 查询。原因很简单—— 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 下会直接报错。所以如果你的项目用了 tRPCAPI 路由只能保留 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 Routersrc/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的写法了。