t3code 并不是某个 GitHub 仓库的名字我这么说是因为最早看到这个词时也以为是个项目代号查了一圈才发现大家口中的“t3code”实际指向的是一整套全栈 TypeScript 的工程组合实践TypeScript 管类型、Tailwind CSS 管样式、tRPC 管接口三个核心东西首字母全是 T合成 T3后面跟个 code就是在说“按这套组合写代码”。我这两年做内部项目一直用 t3code 做代号团队新人也靠这个代号去理解技术栈选型和代码库组织。这篇文章想把 t3code 拆开讲清楚为什么是这三个东西凑在一起、最小工程怎么搭、类型安全怎么从数据库一路流到 UI以及真正上线时最容易在哪几个地方翻车。适合正在了解全栈 TypeScript 方案的开发者也适合想给团队统一技术选型脚手架的同学参考。1. t3code 是什么T3 组合背后解决的问题与选型逻辑1.1 一个越来越长的接缝问题做 Web 开发几年的人应该都有这种感觉现在的全栈应用真正的复杂度不在单个组件而在前后端之间那条看不见的“接缝”。传统 REST 架构里前端写fetch后端写路由两边各维护一份数据类型——最常见的结果是接口文档过期、字段命名不一致、后端改了字段类型前端还在按老结构渲染。等到联调阶段一打开页面就白屏或报错排查的时候先要猜是“字段名对不上”还是“数据真的为空”。接缝越长猜的成本越高这也是为什么很多团队会引入 Swagger、GraphQL 或者大量 DTO 转换层本质都是在给这条接缝做缝合。我最早尝试的解决方案是生成 API 文档 手写类型声明但用下来发现文档生成很快又会和真实实现脱节。后来换了 GraphQL类型安全确实提升了可付出的代价是要学一套 Schema 语言还要处理 codegen、缓存、嵌套查询复杂度一个人写还行团队一起维护就变得很重。t3code 的出发点很简单能不能把这条接缝直接消掉——不写接口文档不定义接口 schema不生成类型声明让类型自己从服务端函数一路“长”到前端组件里。TypeScript、Tailwind、tRPC 这个组合就是奔着这个目标去的。1.2 为什么偏偏是这三样先说 TypeScript。这个没什么争议类型系统是整个链条的地基。在 t3code 里TypeScript 不光是给你编辑器提示用的它还是前后端之间真正的“契约文档”——只要两边编译通过接口的字段和类型就不可能对不上。我的经验是只要项目里真正启用了严格模式并且让类型数据流跨过进程边界很多从前要在联调阶段才能暴露的问题在开发阶段就会被编译器直接拦住。Tailwind CSS 放在组合里经常被当成一个“样式工具”而忽略。它真正解决的是另一个问题让样式层不会破坏类型层的纯净。如果你用传统的 CSS Modules 或者 styled-components样式文件和组件文件往往是分离的类名拼接、主题变量、样式覆盖这些东西都会在代码库里形成第二套“隐性类型系统”。Tailwind 用原子类直接写在标记里样式和组件结构放在同一个地方没有额外的一层需要维护。它不会让你的业务类型更安全但会让整个工程的心智负担更小。tRPC 是整套组合里最核心的变量。它不要求你像 GraphQL 那样定义 schema也不要求你像 REST 那样为每个资源设计 URL。你只需要在后端写一个普通函数把它挂到 router 上然后在前端直接用import { api } from ~/trpc/react调用。类型通过 TypeScript 的typeof推断自动从 service 传到 client中间没有 codegen没有手写类型。换句话说你删掉一个字段前端所有用到它的地方立刻标红。当然tRPC 能这么顺还离不开它下面的 Next.js。t3code 把这五个东西叠在一起Next.js 管页面和路由、TypeScript 管类型、Tailwind 管样式、tRPC 管 API、Prisma 管数据库访问。前两个你可能已经熟得不能再熟真正决定这套方案体验上限的是你怎么理解 tRPC 和 Prisma 之间的协作方式。1.3 t3code 的适用边界哪些场景该用、哪些不值得聊技术选型必须先聊边界。t3code 不是银弹我用下来的感受是它最适合“全栈同构”的应用比如内部管理系统、SaaS 后台、个人项目的完整产品前后端都是 TypeScript跑在同一套代码库里。如果以下情况占了一条我会建议慎重你的 API 要开放给外部第三方调用那就需要公开稳定的契约描述tRPC 的隐式类型推断帮不上外部消费者你的客户端不是 TypeScript比如小程序原生端、iOS/Android 原生 App这套类型穿透就失效了你的团队里有大量只写后端、不接触 TypeScript 的成员引入 tRPC 反而会拉高协作门槛API 流量极大且需要独立扩容前后端拆成独立服务部署tRPC 在一个进程内共享类型的方式就行不通。简单说t3code 选择用“进程内共享类型”换“跨端类型安全”。只要你的边界足够清晰这个交换非常划算。2. 从零搭建一个 t3code 示例工程初始化步骤与启动陷阱2.1 快速起手create-t3-app 的选项怎么选t3code 已经有一个官方的脚手架叫create-t3-app。起一个新项目最快的方式是npm create t3-applatest交互式命令行会让你填项目名然后是一系列选项勾选。我的建议是第一次先无脑把核心项勾上Next.js、TypeScript、Tailwind CSS、tRPC、Prisma认证先用 NextAuth 或者暂时不选都行。这里有一个新人最容易忽略的细节create-t3-app生成的模板里prismaschema 文件默认只有一个Post示例模型tRPCrouter 也只有一个postRouter。很多人初始化完就直接开始删代码写自己的业务结果删着删着把root.ts里的createCallerFactory或者trpc/react.tsx里的createTRPCReact引用链删断了一编译就报错。我的建议是先把模板跑通跑通之后再动刀。具体做法是初始化完成后先不删任何文件直接npm run dev打开页面确认示例列表能加载出来再开始改造。这样至少能保证“最小闭环”在你的环境里是通的。2.2 目录结构与三段式配置初始化完成后你会看到一个非常统一的目录结构。我建议花十分钟把它看懂因为接下来写代码基本就是在这几个固定位置里打转src/ app/ # Next.js 页面和路由 server/ api/ routers/ # 各个业务模块的 router root.ts # 聚合所有 router trpc.ts # tRPC 实例、context、procedure 定义 db.ts # PrismaClient 单例 trpc/ react.tsx # 前端 hooks 封装api server.ts # 服务端 caller 封装 env.js # 环境变量校验 prisma/ schema.prisma # 数据库模型这套结构有两条隐藏规则。第一业务代码尽量往server/api/routers里塞页面组件只负责调用第二类型定义从server往trpc方向流动前端不允许自己定义“服务端数据形状”。只要守住这两条后续维护基本不会乱。2.3 启动前必须补的环境变量t3code 模板对环境变量是强制校验的这是它能“类型安全到底”的最后一块拼图。模板里有个env.js用 zod 定义了每个环境变量的类型和必填性。启动或构建的时候如果缺少变量进程会直接报错退出。所以初始化完之后第一件事是找到根目录的.env.example文件cp .env.example .env然后打开.env填上真实值。最基础的配置是这样的DATABASE_URLmysql://root:passwordlocalhost:3306/t3code NEXTAUTH_SECRET自己生成的一串随机值 NEXTAUTH_URLhttp://localhost:3000如果是本地用 Docker 起 MySQL 或 PostgreSQL记得先建好数据库再跑npx prisma db push不然 Prisma 会在连接阶段报database does not exist。这个报错本身很直白但新手经常把原因归结到“密码错了”或者“端口错了”实际就是库没创建。2.4 首次跑通后先验三件事每次新到一个 t3code 环境我习惯固定做三件事来确认基础链路没问题这三件事也是我推荐给团队新人的标准动作检查类型检查跑npm run typecheck如果模板里没有这个脚本就直接npx tsc --noEmit。确认编译器没有任何报错后再启动业务开发。typecheck 是你和类型系统之间的握手协议跳过它等于裸奔。检查样式输出打开页面随便改个 Tailwind 类名确认样式实时生效。如果类名没生效多半是tailwind.config的content字段没覆盖到对应目录。检查 tRPC 链路在页面里加一个api.post.all.useQuery()把返回的数据渲染出来。如果数据能出来说明 tRPC context、路由挂载、数据库连接整条链路都是通的。这三件事做完你的开发环境才算真正起来了。很多人在这个阶段踩坑都是因为跳过了第一个 typecheck带着一堆类型报错往下写最后问题越积越多。3. t3code 的代码骨架拆解类型安全怎么在链路里流动3.1 一条请求从数据库到 UI 的完整路径t3code 里最让人惊喜的体验是当你写完整条链路后回头看会发现几乎所有“类型”都不是你手动定义的而是从数据源头推断出来的。一条典型的 tRPC 请求路径是Prisma 从数据库查询返回一个符合 schema 的类型这个类型被 router 里的 procedure 作为返回结果tRPC 通过typeof把 router 的类型推断给前端前端useQueryhooks 拿到的data天生就带着完整的字段提示。举个例子prisma/schema.prisma里定义一个Todo模型model Todo { id Int id default(autoincrement()) title String done Boolean default(false) createdAt DateTime default(now()) }然后server/api/routers/todo.ts里写一个查询 procedureimport { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const todoRouter createTRPCRouter({ list: publicProcedure.query(({ ctx }) { return ctx.db.todo.findMany({ orderBy: { createdAt: desc }, }); }), });前端组件里这样调用use client; import { api } from ~/trpc/react; export function TodoList() { const { data, refetch } api.todo.list.useQuery(); return ( ul {data?.map((todo) ( li key{todo.id} input typecheckbox checked{todo.done} readOnly / span{todo.title}/span /li ))} /ul ); }你有没有注意到data的每一项是id、title、done、createdAt这些字段我全程没有写任何接口类型声明。todo.done是 boolean你在编辑器里把鼠标悬停上去就能看到如果 schema 里改了字段名前端立刻标红。这就是 t3code 消除接缝的方式——类型从数据库模型开始一层一层长到按钮里。3.2 procedure 和鉴权公开接口与登录接口的两种写法tRPC 里最核心的概念叫procedure我理解它就是一个“带类型上下文的函数”。模板里默认给你准备了两种 procedurepublicProcedure和protectedProcedure。publicProcedure就是任何人都能调的接口适合登录页、公开信息之类的场景export const helloRouter createTRPCRouter({ greeting: publicProcedure .input(z.object({ name: z.string() })) .query(({ input }) { return 你好${input.name}; }), });protectedProcedure则会在调用前检查 session没有登录直接抛UNAUTHORIZEDexport const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });这种 gentry 式的写法很简单关键在于context。tRPC 在执行 procedure 前会先调用createContext把数据库实例、session、headers 等塞进上下文。每个 procedure 都能拿到但它并不是通过参数层层传递而是从ctx里直接取。如果你需要拿当前登录用户 ID 做数据过滤就直接const todos await ctx.db.todo.findMany({ where: { userId: ctx.session.user.id }, });对没登录的人屏蔽数据、对登录的人按人隔离整个过程都是类型安全的这在 REST 里要写很多样板代码。3.3 前后端类型同步为什么不需要 codegen用过 GraphQL 的朋友应该知道 codegen 那一套定义 schema、生成类型、生成 hooks每次改了 type 都要重新跑一遍生成命令。t3code 里完全没有这个步骤因为AppRouter的类型本身就带着所有 procedure 的类型信息。关键在root.ts这行export type AppRouter typeof appRouter;然后在trpc/react.tsx里把AppRouter作为泛型传给createTRPCReactAppRouter()。之后前端所有 hooks 的类型都是由AppRouter推导出来的改一个输入 schema、换一个返回字段前端类型会在保存文件的瞬间同步更新。这也意味着一个硬性规则类型推断依赖实际的函数实现所以你想让前端拿到什么类型就得让服务端函数真的返回什么值。任何“先定义一个类型再写个不匹配的函数”的做法在这里都会被编译器抓出来。3.4 一个最小的 todo 模块从 schema 到表格下面我完整展示一个 todo 模块需要碰的几个文件方便对照着写。第一个是创建 todo 的 mutationimport { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const todoRouter createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1, 标题不能为空), }), ) .mutation(async ({ ctx, input }) { return ctx.db.todo.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), });前端表单提交use client; import { useState } from react; import { api } from ~/trpc/react; export function NewTodoForm() { const [title, setTitle] useState(); const utils api.useUtils(); const create api.todo.create.useMutation({ onSuccess: async () { setTitle(); await utils.todo.list.invalidate(); }, }); return ( form onSubmit{(e) { e.preventDefault(); create.mutate({ title }); }} input value{title} onChange{(e) setTitle(e.target.value)} / button typesubmit新增/button /form ); }mutation 成功之后一行utils.todo.list.invalidate()就能让 todo list 自动重新拉取不需要手动刷新页面也不需要写事件总线。这套体验在 CRUD 场景里非常顺手。4. 实践中踩过的坑RSC、连接池、环境变量与部署4.1 App Router 下 tRPC 和 Server Component 的交锋Next.js App Router 时代很多人会遇到一个很别扭的场景Server Component 里想用 tRPC hooks结果报错说useQuery只能在 Client Component 里用。tRPC 的 React hooks 底层依赖 React Query而 React Query 又是基于 hook 的库天然只兼容 Client Component。所以你在 Server Component 里拿不到 tRPC 的useQuery。解决办法有两条路第一条是给组件加use client声明把它变成客户端组件。适合纯前端交互型 UI比如表单、图表、列表筛选。第二条是使用createCaller在 Server Component 里直接调用服务端函数把数据作为 props 传给子组件import { createCaller } from ~/trpc/server; export default async function Page() { const todos await createCaller().todo.list(); return TodoTable initialData{todos} /; }然后TodoTable里再用api.todo.list.useQuery(undefined, { initialData: todos })。这样首次渲染走服务端数据直出后续刷新走客户端缓存两全其美。但要注意服务端调用会直接访问ctx里的db和session如果页面里有需要登录态的数据记得在服务端也把 auth 上下文带上否则拿不到 session 对应的数据。4.2 Prisma 在开发模式下的连接池告警t3code 里 PrismaClient 的创建默认是带全局缓存的模板里这段代码很多人不仔细看const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: [query, error, warn], }); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }这么写的原因很简单Next.js 开发模式热更新很快每次代码变更模块都会被重新加载。如果每次都new PrismaClient()你会看到数据库连接数一路暴涨最后 MySQL 直接报Too many connections整个数据库卡死。我踩过这个坑后在团队里立了个规矩不许在业务代码里new PrismaClient()只允许从db.ts导入。另外生产环境部署到 Serverless 平台时要注意 Prisma 引擎的初始化时机冷启动阶段最好不要在请求处理函数里才创建 client模块顶层创建能省不少耗时。4.3 T3 Env 校验在构建期的冷启动报错t3code 的env.js会在构建时跑 zod 校验如果你在 CI 环境里没有把DATABASE_URL等变量灌进去构建会在最早期就失败。报错信息一般是“环境变量缺失”的一堆红色 JSON。排查思路是先在本地跑一遍next build确认环境没问题再看 CI 的平台环境变量配置。还有一个我常用的技巧在env.js里给非敏感变量写默认值比如NEXT_PUBLIC_APP_NAME: z.string().default(t3code)这样 CI 构建就不容易被这种非关键变量卡住。但敏感变量数据库地址、密钥绝对不要写默认值宁可在构建期报错也不要让代码带着空密钥部署上去。4.4 部署到 Vercel 时的几个细节t3code 部署到 Vercel 非常丝滑但有几个细节不处理你会卡在一条报错上很久。第一数据库迁移要在部署前跑。Vercel 的 build 命令一般会设置成npx prisma migrate deploy next build注意是migrate deploy而不是migrate dev。migrate dev会尝试创建影子数据库来比较 schema生产环境通常没有这个权限会直接报错。如果是初期快速迭代、不在乎历史迁移记录也可以用npx prisma db push但正式项目我建议老老实实走 migration。第二需要在next.config里把 Prisma 标记为服务端外部包。不同 Next.js 版本写法有差异有的版本是experimental.serverComponentsExternalPackages新版本改成顶层serverExternalPackagesconst nextConfig { serverExternalPackages: [prisma/client], };第三如果你的项目使用数据库 provider 是 SQLite那 Vercel serverless 环境下的文件系统是只读的SQLite 基本不可用。我见过有人因为本地 SQLite 跑得很顺部署到线上之后所有数据库操作全部失败。t3code 要用对场景生产数据库建议直接选 MySQL 或 PostgreSQL 这类远程数据库服务。4.5 其余小坑清单再列几个我实际遇到过的零散问题路径别名~t3code 默认用~/指向src/如果你在 tsconfig 里改了 paths记得trpc/react.tsx、server/api/trpc.ts这些模板文件的导入路径要同步改否则构建报错。Tailwind 的darkMode和主题变量模板生成时通常在根布局里引入了 CSS 变量改了主题色之后有些组件看起来没变多半是变量名没对上去globals.css检查一下。React Query 版本冲突tRPC 11 依赖 React Query 5如果你同时装了一些依赖 React Query 4 的库很可能出现重复包实例表现症状是 hooks 不触发、缓存失效。优先统一版本。mutation 后数据不更新原因通常是忘写了invalidate()。tRPC 不会自动帮你刷新列表你得显式让相关 query 失效。这个行为初看有点烦习惯之后会发现它其实是可控性的优点。5. 把 t3code 用顺手的工程化清单5.1 命名与代码组织约定t3code 这套方案代码量写起来很轻但正因为轻团队规范跟不上就容易“散”。我目前在团队里定的几条规定供参考每个业务域一个 router 文件比如userRouter、orderRouter聚合在root.ts里禁止在页面组件里直接createTRPCRouter。所有输入参数用 zod 定义schema 写在 procedure 附近的input位置不放单独文件除非多个 procedure 共用。前端组件按“服务端/客户端”分层命名服务端组件默认不写use client只有交互组件才标记。查询和变更的命名用语义化动词list、byId、create、update、remove避免出现getTodos、fetchTodoList这种风格打架。这些规定没有特别深的道理核心是让代码在拿到手的第一眼就知道“去哪找、怎么改”。一个工程方案的长期体验往往取决于这种约定的一致性。5.2 调试 tRPC 请求的实用技巧t3code 的前端 hooks 底层是 React Query所以调试的时候可以活用 React Query DevTools。在根布局挂一个ReactQueryDevTools initialIsOpen{false} /就能在浏览器里看到每个 query 的状态、缓存时间、最近拉取时间。后端侧的调试相对简单因为 tRPC 的每条请求本质还是 HTTP POST/api/trpc/*。在浏览器 Network 面板能看到每个请求的完整 payload 和响应配合PrismaClient({ log: [query] })能直接在服务端控制台看到 SQL 语句。如果某个查询结果不符合预期先看 SQL再看返回 JSON基本能定位 90% 的问题。还有一个隐藏技巧tRPC 的错误信息默认会带code和堆栈。在开发环境可以把t实例的isDev打开让错误信息完整暴露到 console。但生产环境记得关掉避免把内部堆栈泄露给客户端。5.3 React Query 缓存调优前面提到过 tRPC 的 hooks 默认依赖 React Query默认行为是staleTime: 0也就是每次组件挂载都会重新拉数据。对于列表页这种场景没问题但如果你有大量高频率请求会给数据库带来不必要的压力。我的调整方案是在trpc/query-client.ts里设置全局默认new QueryClient({ defaultOptions: { queries: { staleTime: 10 * 1000, refetchOnWindowFocus: false, }, }, });再针对实时性要求高的页面单独设置staleTime。比如消息中心可以设staleTime: 0配置中心可以设5 * 60 * 1000。一个原则越不经常变化的数据缓存时间越长但要注意 tRPC 的 context 如果依赖登录态缓存别跨用户串了数据。5.4 给团队落地时的规范建议最后说说团队落地的事。如果要把 t3code 推广到团队我建议不要一上来就要求所有人掌握全部原理而是给两个“输入”和三个“输出”输入是模板代码、输出是业务模块。团队新人第一次写功能时给一个最小任务比如“新增一个带分页的文章列表”要求只能动routers目录和页面组件目录不许改trpc.ts、root.ts、env.js。这样能快速建立“加 router、挂 root、写组件”的模式认知。等你发现他们开始主动质疑“这里为什么不写成单独的函数”时再给讲 tRPC 的上下文机制和 React Query 缓存原理这时候理解速度会快很多。整体来看t3code 这套组合对中小型全栈项目非常友好它压缩了接口层的沟通成本让类型检查替人盯住接缝也让数据库、API、UI 三层代码保持高度一致。要说它有什么学习曲线主要的难点反而不在 TypeScript 或 Tailwind而是理解 tRPC 这种“把函数直接暴露给前端”的思维方式——想通了后面的开发体验值得你为它忍受前面一周的不适应。最后分享一个我自己一直用的习惯每次从模板开始新项目时先把prisma/schema.prisma的示例模型全部删掉换成真实业务模型然后立刻创建对应 router、跑一遍端到端链路。这个动作大概花二十分钟但它能保证你的项目从一开始就走在正确的类型轨道上而不是等写了几十个组件之后再返工。