
t3code 这个项目名最早是我在一次全栈项目重构时随手起的。当时团队里新老项目混着 TypeScript、JavaScript、各种风格的 API 封装每次新起一个服务都要重新折腾一遍脚手架痛苦指数极高。后来我把整套东西沉淀成了一个可复用的工程模板取名 t3code意思是一套带编码规范、类型安全链路和标准化工程结构的 T3 技术栈样板。如果你正被环境配置、类型割裂、接口联调效率低这些问题折磨或者想找一个能直接抄作业的全栈起点这篇就把 t3code 从设计思路到落地细节完整拆给你看。1. 为什么把全栈工程化押注在 T3 这套组合上1.1 T3到底指什么一句话能说清的技术选型先解释一下名字。T3 最早指 Theo 提出的 T3 Stack核心是 TypeScript、Tailwind CSS、tRPC 三件套再加 Next.js 作为框架底座Prisma 作为数据库访问层。t3code 这个名字就是从这里来的——我把它理解成自带编码纪律Code Discipline的 T3 工具链。我见过太多全栈项目死在技术栈选择太多上。前后端分离要维护两套类型定义光接口文档同步就够喝一壶用 REST 又要考虑 OpenAPI 生成、版本管理如果再加上状态管理选 Redux 还是 Zustand、样式方案用 CSS Modules 还是 Sass每个新人进来都要先花一周熟悉我们团队的取舍逻辑。T3 这套组合最吸引我的地方是它把端到端类型安全作为第一原则。从数据库表结构、服务端 API 到前端页面组件类型定义是同一份来源中间不需要手写任何接口类型。这不只是少写几行代码的问题——它把一类最常见的 bug字段名拼错、类型对不上直接消灭在了编译期。1.2 从能跑到好维护t3code 想解决的四个现实问题拿我重构的那个老项目举例前端一个 service 层后端一个 controller 层中间以 JSON 互相传数据。某个字段从userId改成ownerId前端 greps 半天还漏改了一处线上直接白屏。这种问题不是靠细心能解决的而是架构层面的类型割裂。t3code 在设计之初就明确要解决四个问题第一类型割裂。所有数据类型从 Prisma Schema 单一来源推导tRPC 自动把服务端类型带到前端。第二环境复杂度。一个新成员加入项目不用再装数据库客户端、配 Redis、起两个端口的服务一条命令把开发环境全部拉起来。第三编码规范不一致。ESLint 规则、Prettier 配置、提交信息规范全部内置团队不需要争论到底用单引号还是双引号。第四部署路径模糊。从 Dockerfile 到 Nginx 配置、数据库迁移流程全部固化开发环境和生产环境行为一致。这四点不是靠某一个框架解决的而是整套工程化约定。t3code 的定位就是把这些约定变成默认值让团队的每个人都走在同一条技术路径上。你可以把它理解成公路上的护栏——它不替你开车但保证你不会掉下悬崖。2. t3code 的整体骨架目录结构、数据流与关键约定2.1 冷启动一个项目后的目录全解析t3code 按下述目录结构组织。我直接贴出核心部分方便你对照自己的项目理解。t3code/ ├── prisma/ │ ├── schema.prisma # 唯一的数据库模型定义源 │ └── migrations/ # 所有 SQL 迁移文件 ├── src/ │ ├── pages/ # Next.js 页面路由 │ │ └── api/ # 仅用于 tRPC 或 NextAuth 的 API 挂载 │ ├── server/ │ │ ├── api/ # tRPC 路由定义根路由 子路由 │ │ │ └── routers/ # 按业务域拆分比如 userRouter、postRouter │ │ ├── db/ # Prisma Client 单例 │ │ ├── auth/ # 认证与鉴权逻辑 │ │ └── trpc.ts # tRPC context 与中间件封装 │ ├── styles/ │ │ └── globals.css # Tailwind 入口 │ └── utils/ │ └── api.ts # 前端 tRPC 客户端封装 ├── .env.example # 环境变量模板 ├── docker-compose.yml # 本地数据库、缓存一体化启动 ├── Dockerfile └── package.json这里有个我特别坚持的约定src/server作为后端代码的唯一目录所有涉及数据库、鉴权、业务逻辑的代码都收在里面页面目录只负责 UI 与数据获取。这样划分的好处是当项目逐渐变大你不会面临PR 里改个字段却不知道影响到了哪些层的困境。早期随手把 Prisma 调用写进任意文件的做法后期都要加倍偿还。2.2 端到端类型安全的链路Schema、API 到页面的数据流怎么串起来理解 t3code 的钥匙是看透一条完整的数据流。从定义数据库模型开始// prisma/schema.prisma model User { id String id default(cuid()) email String unique name String? createdAt DateTime default(now()) posts Post[] } model Post { id String id default(cuid()) title String content String author User relation(fields: [authorId], references: [id]) authorId String published Boolean default(false) }然后写一个 tRPC 路由// src/server/api/routers/post.ts import { z } from zod; import { createTRPCRouter, publicProcedure } from ../trpc; export const postRouter createTRPCRouter({ list: publicProcedure .query(async ({ ctx }) { return ctx.prisma.post.findMany({ include: { author: true }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), })) .mutation(async ({ ctx, input }) { return ctx.prisma.post.create({ data: { title: input.title, content: input.content, author: { connect: { id: ctx.session?.user?.id } }, }, }); }), });注意这里我根本没有手写Post的返回类型。前端调用时TypeScript 会直接推断出list返回的类型是Post { author: User }。如果你在后端改了字段名前端立刻飘红而不是等接口联调时才发现。前端的使用方式也很直接// src/pages/index.tsx import { api } from ../utils/api; export default function HomePage() { const posts api.post.list.useQuery(); return ( div {posts.data?.map((post) ( article key{post.id} h2{post.title}/h2 p{post.author.name}/p /article ))} /div ); }这就是 t3code 最核心的卖点useQuery返回的post.author.name是天然带类型的IDE 能自动补全不用翻接口文档、不用看后端代码。对复杂项目来说这个体验是革命性的——它把联调从对着文档猜字段变成了编译期保证类型正确把问题解决环节前移了。2.3 为什么选择 tRPC 而不是 REST 或 GraphQL我经常被问现在 REST 是主流GraphQL 也很流行为什么 t3code 用 tRPC关键在于开发效率和类型安全的平衡。REST 需要手动维护类型定义或使用 codegenGraphQL 的代码生成器能解决类型问题但引入了额外的查询语法与缓存复杂度。tRPC 的思路很朴素既然前后端都是 TypeScript直接通过 RPC 调用不就行了它没有像 GraphQL 那样创建一个完整的查询语言而是让函数签名跨网络流传。不过 tRPC 也有个硬前提前后端共享同一份类型来源。如果你的项目是前后端完全分离、需要为第三方开放 API那 tRPC 并不合适。t3code 的定位是内部全栈应用——管理后台、内部工具、MVP 产品——这种场景下 tRPC 几乎是效率最优解。它也支持 REST 风格的单路由暴露但对核心业务场景全栈类型安全的价值远超 REST 带来的标准协议优势。用生活类比REST 像两个说不同语言的人通过信件沟通GraphQL 像叫了个翻译并自定义了一套方言tRPC 则是两个人直接用视频电话。沟通成本天差地别但前提是两端的人得会说同一种语言都是 TypeScript。3. 核心实现细节从脚手架配置到业务功能落地3.1 基础设施配置Next.js TypeScript 的严格模式取舍t3code 的 Next.js 配置看似普通有几处细节值得展开。第一TypeScript 严格模式全开。我知道很多项目为了省事会把noUncheckedIndexedAccess关掉或者把strict设成false。我的观点是既然选择 T3就接受类型系统带来的摩擦。用strict: true加noUncheckedIndexedAccess: true会让数组访问变成T | undefined一开始会很烦但能逼你把边界情况考虑清楚。这个摩擦在前期远比上线后出线上 bug 的代价小。// tsconfig.json { compilerOptions: { strict: true, noUncheckedIndexedAccess: true, noFallthroughCasesInSwitch: true, target: es2022, module: esnext, moduleResolution: bundler } }第二路径别名。t3code 统一用/指向src/比如/server/db、/utils/api。这个不光是打字少还避免了一大堆../../../的相对路径混乱。你可以在项目里搜一下没有一条 import 是相对路径开头这让代码移动时的心智负担大幅降低。关于 ESLint 与 Prettier我在 t3code 里直接把eslint-config-next、eslint-config-prettier和eslint-plugin-tailwindcss都接上了。这里要特别提醒eslint-plugin-tailwindcss会检测className 里的 Tailwind 类名冲突和重复这个插件能早发现问题尤其适合团队多人同时维护一个设计系统时避免出现p-4 p-8这种一边覆盖一边全乱的代码。3.2 tRPC 路由器设计业务模块的初始化套路t3code 里我采用的是按业务域拆分路由器。根路由汇总所有子路由// src/server/api/root.ts import { createTRPCRouter } from ./trpc; import { postRouter } from ./routers/post; import { userRouter } from ./routers/user; export const appRouter createTRPCRouter({ post: postRouter, user: userRouter, }); export type AppRouter typeof appRouter;每次新加一个业务模块套路是固定的先在routers/下建一个xxx.ts定义xxxRouter再回到root.ts挂载。这套模式的好处是你不会因为项目膨胀而把所有路由堆在同一个文件里git 冲突也少。在 tRPC 路由设计上我有几条经验用publicProcedure明确意图。即使暂时不做鉴权也要显式写出来后续加权限时只需要换一个封装的 procedure业务代码不用动。Input 校验必须用 Zod。千万别图省事只写z.any()。tRPC 的输入校验是在运行时执行的不校验等于把安全问题留给下游消费者。query 幂等mutation 只做写操作。这个我踩过坑之前有人在 query 里直接改数据导致 React Query 的缓存与后端状态不一致排查了半天。一个常见的误区是把 tRPC 路由当作传统 Controller 来写一个路由函数塞了几百行业务逻辑。t3code 的建议是路由只做参数校验 编排真正的业务逻辑可以抽到server/services/目录。这样做测试也容易直接单测 service不用起一个 tRPC 客户端。3.3 Tailwind 样式方案与组件层的协作方式样式方案我选 Tailwind主要是因为它跟 t3code 的约束优于配置理念一致。Tailwind 的 Utility First 能大幅度减少我们团队为一个按钮纠结 .btn-primary 还是 .button--primary 名字的时间。不过在项目变大之后纯 Tailwind 也有一个痛点JSX 里的 className 越来越长、重复率很高。我在 t3code 里的解法是组件级 Tailwind 封装 少量 CSS 变量定义设计令牌。具体做法是把设计系统中的颜色、字体、间距用 CSS 变量定义在globals.css然后通过 Tailwind 的tailwind.config.ts映射成语义化 token// tailwind.config.ts import type { Config } from tailwindcss; export default { content: [./src/**/*.{js,ts,jsx,tsx}], theme: { extend: { colors: { surface: rgb(var(--surface) / alpha-value), primary: rgb(var(--primary) / alpha-value), }, }, }, } satisfies Config;这样在设计图上看到主色按钮你写的是bg-primary text-white而不是在色板里找十六进制色值。换主题、适配暗色模式只需要改 CSS 变量不需要全局替换类名。组件层我不强制使用任何组件库但在 t3code 里预留了src/components/ui/目录放 Button、Input、Modal 这类基础组件。它们都封装了状态逻辑和样式业务页面只管组合不要自己重复造轮子。你会发现这个结构跟 shadcn/ui 类似没错t3code 默认也推荐以 shadcn/ui 作为基础组件来源——它有极高的可定制性规避了传统组件库换皮肤等于重写的坑。4. 构建、部署与依赖管理的实操备忘4.1 包管理器选择与依赖锁定策略在 t3code 里我用了 pnpm。原因很直接它的软链接机制让装包速度快得多还天然支持 monorepo虽然单项目用不到。更重要的是 pnpm 的dev环境安装严格区分开发依赖与生产依赖部署时能显著减小镜像体积。package.json里有个关键字段是packageManager我强制指定了 pnpm 版本。这能避免团队里有人用 npm 或 yarn 安装后生成不同的 lockfile间接产生在我电脑上没问题的窘境。{ packageManager: pnpm8.15.4, scripts: { dev: next dev, build: prisma generate next build, start: next start, lint: next lint, db:push: prisma db push, db:migrate: prisma migrate dev } }注意build脚本里我特意先执行prisma generate。很多项目在 CI 里总是忘记这一条导致类型全部标红或者运行时提示找不到 Prisma Client。加上这一句后CI 环境只要pnpm install pnpm build就能稳定复现本地构建结果。4.2 Docker 部署与数据库迁移的顺序问题部署阶段最容易翻车的就是数据库迁移与容器启动的顺序。假如你的应用启动时会查某个表而迁移还没执行容器就会 CrashLoop。t3code 的解法是在 Docker 镜像里内置迁移命令以启动命令的形式执行迁移后再启动应用# docker-entrypoint.sh #!/bin/sh echo running migrations... pnpm db:migrate echo starting server... pnpm start这样每次新版本发布时容器启动前会先执行迁移把数据库结构更新到与代码匹配的版本。不过这个方案也有个度的问题高并发多副本部署时多个容器同时执行迁移可能重复执行同一张迁移表。为此 t3code 里建议单副本应用直接启动前迁移简单可靠多副本应用单独跑一个 migration job 或手工在发布流水线中执行prisma migrate deploy应用容器只负责启动。另外prisma migrate deploy和prisma migrate dev是两码事。前者在 CI、生产环境执行已有的迁移文件不会生成新的迁移后者是开发时交互式的还可能触发重置数据库。写 CI 脚本时千万别把这两个弄混了否则生产数据会面临风险。Docker 多阶段构建我用了两层构建阶段完整安装依赖并编译 Next.js运行阶段只保留node_modules生产依赖与.next构建产物。配合 pnpm 的--prod镜像体积能从 1.2G 压到 300M 左右。也别把.env打进镜像用 docker compose 或 Kubernetes 的 env 注入避免密钥泄漏到镜像仓库。4.3 环境变量管理从 .env.example 到运行时校验t3code 里另一个容易忽视但实际很折磨人的点是环境变量。前端打包时NEXT_PUBLIC_开头的变量会被直接内联进 JS 代码所以一定不要在里面放任何密钥。服务端环境变量则在运行时读取。我习惯用 Zod 做环境变量校验——对又是 Zod。在src/server/env.ts里定义一份运行时校验规则启动时如果缺少关键变量直接抛错并给清晰的错误信息而不是等到某个 API 被调用时悄悄失败// src/server/env.ts import { z } from zod; const envSchema z.object({ DATABASE_URL: z.string().min(1), NEXTAUTH_SECRET: z.string().min(1), NEXTAUTH_URL: z.string().url(), }); export const env envSchema.parse(process.env);这样任何一个新环境本地、staging、prod在启动时都会做一次体检。把它加到tRPCcontext 里也行但最直接的是在服务端入口处 import 一次。如果项目接入 CI/CD还可以在流水线里跑一个env:check脚本提前暴露缺失变量。5. 真实项目里的坑类型报错、热更新与权限校验的排查链路5.1 tRPC 泛型推断失败从报错到根因的完整排查过程先说一个让我印象深刻的 bug。某个页面上api.post.list.useQuery()一直飘红报错的类型是Property author does not exist on type Post。但我明明在路由器里include: { author: true }了。排查第一步先看 Prisma 是否有类型更新。原来我在改了 Prisma Schema 之后忘了重新执行prisma generate导致 Prisma Client 的类型定义还是旧版。这个坑在开发过程中特别容易踩——你改了 schema 文件但不能只保存文件必须重新生成 Client。执行pnpm db:push之后Prisma Client 的类型更新了但错误还在。于是继续排查第二步tRPC 路由里include的返回类型是否被正确推断。结果发现我把postRouter定义在被旧类型缓存影响的模块里开发服务器一直在用旧的类型产物。重启next dev之后类型恢复正常。第三步我发现这条链路频繁类型陈旧的根源是我用的编辑器 TypeScript Server 可能在长时间运行后缓存了旧的.tsbuildinfo。随手清理一下重启 Windows/Linux 下的 TS ServerVSCode 里TypeScript: Restart TS server就解决了。这个坑的教训是遇到类型错误优先检查 Prisma Client 与 TS Server 的状态而不是怀疑代码逻辑。特别在 monorepo 或使用 workspace 的环境中陈旧缓存出现的概率会成倍上升。5.2 权限中间件的时序问题一个容易被忽略的坑t3code 里权限控制是通过 tRPC 中间件实现的。我在trpc.ts里封装了一个protectedProcedure里面先取 session再查用户不符合条件就抛 UNAUTHORIZED。// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import { getServerSession } from next-auth; import { prisma } from ../db; import { env } from ../env; const t initTRPC.create({ context: async ({ req, res }) { const session await getServerSession({ req, res, authOptions }); return { session, prisma }; }, }); const isAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: ctx.session } }); }); export const protectedProcedure t.procedure.use(isAuthed);看起来很简单但这里有坑中间件里只校验了有没有登录没有校验登录后是否还有权限访问这个资源比如用户 A 想改用户 B 的数据。我早期一个项目里只做了登录校验结果用户改个 id 就能越权直到安全测试才暴露。t3code 的设计是登录校验通用化资源所有权校验放在业务路由内单独验证。比如编辑文章的流程update: protectedProcedure .input(z.object({ id: z.string(), title: z.string().optional(), })) .mutation(async ({ ctx, input }) { const post await ctx.db.post.findUnique({ where: { id: input.id } }); if (!post || post.authorId ! ctx.session.user.id) { throw new TRPCError({ code: FORBIDDEN }); } return ctx.db.post.update({ where: { id: input.id }, data: input }); }),中间件负责你是谁路由负责你能不能动这条数据。这个分离原则很关键也是权限系统最常见的坑。5.3 热更新失灵与 Tailwind 样式不生效的怪异组合遇到过一个诡异现象改了globals.css或者 tailwind.config页面热更新一切正常但样式就是不变。排查了半天才发现是 Tailwind 的content配置没把新创建的目录包含进去。Tailwind 只会扫描content数组里指定的文件如果你新增了src/components/ui/这样的目录而配置文件里只写了./src/**/*.{js,ts,jsx,tsx}其实理论上能覆盖但某些情况下如果你用了非标准扩展名比如.vue或自定义文件就会出问题。这种事多发于初期配置了严格 content 数组后面目录结构变了没同步更新。遇到样式热更新但就是不变的情况顺带检查浏览器的缓存与 Next.js 的.next缓存。rm -rf .next pnpm dev能解决掉一大半疑难杂症。别心疼那几十秒重启时间比起在错误方向排查一个小时重启几乎总是划算的。6. 我的实操体会把 t3code 应用到新项目的前几条建议t3code 这个名字听起来像一个工具但对我来说它更接近一套工程化审美。经过十几个项目的实战验证我不敢说它适合所有场景但有几点在迭代中沉淀下来的体会值得分享给你。第一严格模式别在项目中期才开。从 t3code 诞生第一天起就开严格模式和代码写了三个月后再开严格模式是两个完全不同的工程量。前期多花半小时处理类型报错后期省下的远不止半小时。第二RPC 函数名即文档。t3code 的约束下前端一个useQuery就把数据链路完整呈现了后端路由也收敛在routers/下。新人上手时我建议先花 10 分钟通读root.ts里挂载了哪些模块再看看每个路由的 Input 校验 Zod 定义——这比任何接口文档都直观。当然这不代表完全不用文档。对于复杂业务规则注释和 ADR架构决策记录依然需要。第三不要轻易把 tRPC 路由直接暴露到公网。它本质上让前端函数式调用后端如果服务是面向未经认证的公众用户你的输入校验和安全检查必须更严格。t3code 位于内网管理后台或经过 BFF 层转发是常见姿势。最后如果你准备用 t3code 启动一个新项目流程我建议是先npx create-t3-applatest my-app——这是官方脚手架选的配置基本对应 t3code 的思路——之后把src/server/api、prisma/schema.prisma、tailwind.config.ts换成这套实践。我很少真的从零手写配置文件那样太慢也容易遗漏细节。至于项目跑起来之后的下一步我推荐先把用户-登录-基础 CRUD这条链路完整调通再补业务模块。很多半路放弃 T3 的人都是因为第一步就搭错了链路导致后面处处觉得别扭。把类型安全链路从后端数据库一路通到前端组件让第一行业务代码就跑在这条链路上后面的体验会顺畅非常多。t3code 对我来说真正的价值不是某一个具体配置而是它逼着我用类型系统、依赖约束和目录规范把一个全栈项目中容易出问题的环节提前到开发期解决。这条路没有银弹但如果你愿意接受它带来的一部分限制它会给你远超预期的开发效率和安全感。