一说 t3code很多刚接触全栈开发的朋友可能会以为是某个代码生成工具或者第三代编程规范。其实它更像是一套全栈组合拳以 TypeScript 为绝对主线把 Next.js、Tailwind CSS、tRPC 这几个以 T 开头的核心工具拧成一股绳再按需接入 Prisma、NextAuth、TanStack Query 这些配套件。这个组合在社区里通常被称为 T3 Stack而 t3code 就是围绕这套栈做工程落地时的实践代号。作为在这里面摸爬滚打过几个项目的过来人我想把从选型思路、项目初始化到上线复盘整个链路完整过一遍给正在犹豫要不要用它、或者已经入坑却卡在某个环节的朋友一点实在的参考。1. 为什么是 t3code这套全栈组合拳解决了什么问题1.1 从前后端分离到类型一路通到底的变化传统的前后端分离项目里前端是 React/Vue后端是 Java/Go/Node中间必须靠 REST 接口、接口文档和 DTO 来衔接。最痛苦的不是写接口而是改接口前端改了参数名后端不知道后端改了返回结构前端只能等联调时发现问题。t3code 的核心理念很简单——既然前后端都用 TypeScript为什么不让类型一路通到底服务端用一个 router 暴露方法客户端 import 同一份调用声明调用远程接口就像调用本地函数一样自然。我举个实际例子。后端把findUserById的返回类型从User改成User { role: string }在传统模式下前端大概率要等接口文档更新、等联调环境重新部署才会发现某个页面没展示用户角色。而在 t3code 项目中服务端 router 的类型一改前端代码里凡是用了这份返回数据但没处理role字段的地方编译阶段直接报错。这个差别不是体验提升级别而是把一类非常常见的线上故障消灭在开发期。当然T3 Stack 不是唯一做端到端类型安全的方案GraphQL 也能做但代价是要引入 schema 定义、生成客户端代码、维护一套复杂工具链。t3code 的思路是在已经有 TypeScript 的情况下不要再发明一套 schema 语言直接用 TS 类型本身作为协议。这也是它被社区称为少即是多的典型代表。1.2 每一个字母的选择都不是拍脑袋很多人以为 T3 就是三个工具拼在一起实际上每个选型都对应一个具体的工程痛点。TypeScript不用多说是整个组合的地基。真正关键的是 t3code 把它从开发期工具提升到了架构设计语言的位置。后端返回值、数据库模型、前端 Props、API 请求体全部由同一套类型系统约束错一个字段都过不了编译。Next.js解决的是渲染模型选择的问题。在它出现之前前端要上 SSR得自己搭 Express 服务、处理路由、配置构建链要纯静态博客又得另建一套工具。Next.js 把 SSR、SSG、ISR 全部塞进同一框架一个页面可以用一种渲染方式同项目内自由组合对 t3code 这类全栈应用来说正好覆盖了后台管理系统 内容展示页面 需要 SEO 的营销页等多种形态。Tailwind CSS刚出现时争议很大但我实际用下来最大的体验不是省得写 CSS而是终于没有人再争论样式该怎么分了。团队里不再需要讨论 BEM、CSS Modules 还是 Sass 目录规范所有人都对着同一个 utility class 配置工作。配合编辑器插件鼠标悬浮就能看到实际样式新人上手成本极低。tRPC是整个组合里最核心的灵魂。它做的事情夸张一点说就是把你的服务端函数变成一个可远程调用的 HTTP 接口同时完整保留函数的类型签名。不需要手写routes.ts不需要维护 OpenAPI 文档不需要在类型和运行时之间做二次转换。所有接口的数据结构就在 TS 代码里vscode 跳转一下就能看到实现。1.3 边界t3code 不适用于所有项目吹了这么多我还是得说几个不适合的场景避免大家无脑套用。如果你的项目是纯内容站没有登录、没有复杂交互、不需要提交数据用 Next.js 的 SSG 就够了没必要上 tRPC引入 tRPC 反而增加服务端和客户端之间的概念负担。如果你的公司已经有一套成熟的 Java/Go 后端微服务前端项目引入 t3code 意义不大。前端拉的都是现成接口类型安全只能靠生成 OpenAPI 类型或手写接口层类型一路通到底的优势被弱化到只剩前端内部。如果你的团队对 TypeScript 本身就不熟t3code 的学习曲线会比较陡。它不是那种零基础也能上来写的工具至少要有泛型、类型推断、模块声明的概念否则遇到编译报错会一头雾水。一句话总结t3code 最舒服的应用场景是从零开始的、以 TypeScript 为主的、前后端都由同一个小团队维护的全栈应用。这是我反复验证后得出的结论。2. 从脚手架到能跑创建 t3code 项目的完整过程2.1 create-t3-app一条命令生成的背后T3 Stack 社区提供了一个官方脚手架create-t3-app这是目前最省事的上手方式pnpm create t3-applatest my-app执行之后它会问你选哪些能力常见的选项包括NextAuth.js认证方案默认集成 Session 管理和 JWT 方案PrismaORM负责数据库建模和迁移Tailwind CSS样式方案tRPCAPI 层ESLint / Prettier代码检查与格式化我的建议是第一次尝试时不要全选。可以先选 Tailwind tRPC Prisma 跑通核心链路NextAuth 这种认证逻辑后面需要时再加。全选还有一个副作用——生成的模板文件会很多初学者容易分不清哪些文件是主干哪些是可选插件。脚手架做的事比想象中多。它会帮你初始化 Next.js 的 App Router 结构、安装 tRPC 的服务端和客户端包、生成 Prisma schema 基础文件、配置 Tailwind 的tailwind.config.ts最重要的是把 tRPC 的 provider 和 hooks 包装好。也就是说打开项目那一刻前后端类型共享的基础已经搭好了你只需要在上面加业务。2.2 目录结构为什么 server 和 trpc 要分开create-t3-app 默认生成的结构大致是这样的src/ app/ api/ trpc/ [trpc]/route.ts layout.tsx page.tsx server/ api/ routers/ post.ts root.ts db.ts auth.ts trpc/ server.ts client.ts react.tsx styles/ globals.css这个结构不是随随便便分的。src/server下的代码只允许服务端使用src/trpc是服务端和客户端共享的类型和初始化逻辑。这样划分的意图非常明确任何可能包含数据库密码、认证密钥的代码物理上都不要出现在 client bundle 里。trpc/server.ts里会创建 tRPC 实例和createTRPCContexttrpc/client.ts是客户端访问类型的地方trpc/react.tsx则负责把 tRPC 与 React Query 绑在一起生成api对象。你写的业务代码主要在server/api/routers下面。新手最容易犯的错是把数据库查询逻辑直接写在app/page.tsx里。在 App Router 中页面组件默认是服务端组件app目录下的文件又很容易被误认为可以在这里直接 query但等你要在这个页面里加交互状态比如加一个筛选下拉框组件必须转换结构此时服务端代码就得重构。规矩一点从第一天开始就把所有数据访问放到 router 里。2.3 环境变量与数据库准备最容易翻车的环节项目跑起来之前会遇到一个不可避免的坎环境变量。create-t3-app 初始化后会在.env里放几个占位符常见的有DATABASE_URLpostgresql://... NEXTAUTH_SECRET... NEXTAUTH_URLhttp://localhost:3000这里有几个关键细节。第一DATABASE_URL要搭配 Prisma 使用创建完项目后先配置好数据库连接串执行npx prisma db push把 schema 同步到数据库。如果 schema 以后要改动正式环境建议用npx prisma migrate dev生成迁移文件而不是再次db push后者会把表结构直接强改不利于版本管理。第二NEXTAUTH_SECRET千万不要硬编码。本地开发可以随便填一个长随机串部署时用环境变量注入。这个值负责签名 session一旦泄露攻击者可以伪造登录态。第三带NEXT_PUBLIC_前缀的变量会暴露给浏览器。如果你要接入第三方服务比如某个地图 SDK只在服务端使用的 key 不要加这个前缀否则会打进前端 bundle。我自己踩过一个教训把数据库地址写进了NEXTAUTH_URL导致回调地址变成数据库地址本地登录一直跳转失败。这种问题没有报错提示只能挨个变量排查浪费了一个多小时。建议项目启动前先检查一遍.env文件的名称是否完全匹配框架需求。3. 核心链路动手实践从数据模型到 tRPC 接口3.1 用 Prisma 定义第一组业务模型以一个最简单的团队内容管理工具为例我们要做用户、文章、评论三张表。Prisma schema 大概长这样model User { id String id default(cuid()) email String unique name String? posts Post[] comments Comment[] } model Post { id String id default(cuid()) title String content String authorId String author User relation(fields: [authorId], references: [id]) comments Comment[] createdAt DateTime default(now()) } model Comment { id String id default(cuid()) content String postId String post Post relation(fields: [postId], references: [id]) authorId String author User relation(fields: [authorId], references: [id]) }写完之后执行npx prisma migrate dev --name init数据库表就建好了。这一步没啥稀奇的但 t3code 的关键在于这些模型类型会自动同步到 tRPC 的返回类型里。你在 router 里返回prisma.post.findMany()前端拿到的类型就是附带所有关联字段的完整 Post 类型不需要额外写 interface。使用 Prisma 的一个经验是不要在 N1 查询的边缘反复横跳。比如要展示文章列表和每篇文章的评论数新手很自然会写成循环里查评论实现效果正确但请求量爆炸。正确做法是用include或写聚合查询。tRPC 不会帮你优化数据库查询它只是类型安全地暴露查询结果。3.2 用 tRPC 写第一个接口路由在server/api/routers/post.ts里定义一个 routerimport { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const postRouter createTRPCRouter({ list: publicProcedure .query(async ({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, take: 20, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session?.user?.id ?? , }, }); }), });tRPC 的过程分两种query用于读数据mutation用于写操作。它和 REST 的关系简单粗暴——你不需要自己规定 GET、POST、PATCH 的语义query 就对应 GET 语义mutaton 就对应 POST/PUT/PATCH 语义。一个看似简单的设计省掉了大量关于 REST 风格和接口路径的团队争论。输入参数用 zod 做校验也是一个很妙的点。zod 的 schema 在运行时参与数据校验同时它的 TS 类型可以直接推断出input的类型。也就是说前端传错字段类型编译阶段就报错而真正到达服务端的数据又经过 zod 二次校验双保险。写完 router 后别忘了在root.ts里注册import { postRouter } from ./routers/post; export const appRouter createTRPCAppRouter({ post: postRouter, }); export type AppRouter typeof appRouter;export type AppRouter这行决定了整个链路类型共享的起点我们马上就会看到它如何在客户端生效。3.3 前端调用就像调用本地函数在客户端组件里用 tRPC 生成的 hooks 调用接口use client; import { api } from ~/trpc/react; export function PostList() { const { data: posts, isLoading } api.post.list.useQuery(); if (isLoading) return div加载中.../div; return ( ul {posts?.map((post) li key{post.id}{post.title}/li)} /ul ); }创建文章的流程也不复杂const utils api.useUtils(); const createPost api.post.create.useMutation({ onSuccess: () { utils.post.list.invalidate(); }, }); function handleSubmit() { createPost.mutate({ title, content }); }utils.post.list.invalidate()是 tRPC 内置的缓存失效逻辑。mutation 成功后它会自动让对应 query 重新请求保证列表刷新。这里内置了 React Query 的缓存机制跨页面切换时数据不会重复请求这个收益在开发时感受不明显但用户量上来之后 API 压力和响应速度的差距会很明显。3.4 容易卡住的隐藏坑RSC 边界与 contexttypeScript 新手在 t3code 项目里最常遇到的报错是在服务端组件里直接调用api.post.list.useQuery()然后编译器提示 hook 不能在 server component 里使用。因为 Next.js App Router 的默认组件是 Server Component而 React Query 是客户端状态方案两者天然冲突。解决办法有两个。一是给组件加use client标记让它变成客户端组件二是用服务端调用的方式通过createCaller在服务端直接执行 router 方法。如果你的页面需要初始数据并且想优化首屏更推荐用 createCallerimport { createCaller } from ~/server/api/root; const caller createCaller({ session: null, db }); const posts await caller.post.list();这里有个容易忽略的点调用createCaller时要自己传 context因为服务端没有经过 Next.js 的请求上下文中转。我一开始在很深的目录层级调用发现ctx.session一直是 null排查半天才意识到是 context 构造的问题。另一个隐藏坑是 session 在 mutation 里的使用。publicProcedure默认不强制登录如果你在createmutation 里直接取ctx.session.user.id前台可能拿到空字符串。正确做法是使用protectedProcedure或者先在前端判断登录状态。不要认为 t3code 会自动帮你做权限控制它只是给了你类型安全权限还得自己想清楚。4. 上线之前的加固认证、缓存与部署细节4.1 NextAuth 集成与会话类型扩展create-t3-app 默认集成了 NextAuth但要让它跑通业务通常要扩展 Session 类型。默认情况下ctx.session.user只有 id、name、email 这几个字段如果你需要额外塞一个role就要修改类型声明。在项目里加一个types/next-auth.d.tsimport { DefaultSession } from next-auth; declare module next-auth { interface Session { user: { id: string; role: string; } DefaultSession[user]; } }这一步不写ctx.session.user.role永远是类型报错状态。许多新手会在这里卡住一边怀疑自己配置错了一边在网上搜next-auth 自定义 session 类型最后发现只是少写了一个 d.ts 文件。NextAuth 的 session 策略我建议优先用 database session 而不是 JWT尤其是在 tRPC 场景下数据库 session 可以直接从ctx.session里同步获取最新用户状态JWT 则需要自己处理过期和刷新逻辑。代价是每次请求都会查一次 session 表但对中小项目来说这个开销可以接受。4.2 服务端预取与 React Query 缓存边界t3code 默认把 React Query 作为数据请求层这套机制在前端交互上非常顺畅。但如果你在服务端渲染时直接调用api.post.list.useQuery()因为 Next.js 会执行服务端组件代码数据不会自动注入 React Query 的缓存客户端 hydration 后又会重新请求一次。这不是 bug是很多全栈新手理解偏差导致的首屏加载两次数据。更稳妥的方案是开头提过的 createCaller 预取。在服务端组件里先取数据并传给客户端组件作为初始 propsconst caller createCaller({ session: null, db }); const initialPosts await caller.post.list(); return PostList initialData{initialPosts} /;客户端使用useQuery时把initialData传进去React Query 就不会在 hydrate 后再发请求。这里要注意initialData与最新的服务端状态可能不同步适合对实时性要求不高的场景。如果需要实时性就接受 revalidate 周期或者轮询。4.3 部署数据库迁移脚本与构建顺序把项目部署到生产环境最容易出错的就是数据库迁移。本地开发用prisma migrate dev会同时生成迁移文件和同步数据库。生产环境不能这么干因为容易在构建过程中意外改动数据库结构。正确做法是# 部署前执行 npx prisma migrate deploy然后在部署平台设置构建命令npx prisma generate next buildprisma generate必须在next build之前执行否则 Prisma Client 可能找不到最新 schema构建会报PrismaClientInitializationError。我见过不止一次有人把顺序写成next build prisma generate本地没问题生产构建就是随机失败。如果部署在国内服务器上用 PM2 管理进程建议把迁移放到独立的部署脚本里不要放在应用启动时的postinstall钩子中。因为 PM2 重启应用时会频繁触发脚本数据库表结构被反复检查既慢又容易因并发导致锁表。4.4 bundle 体积与请求合并的实测心得上线前我习惯用next/bundle-analyzer看一遍打包产物。t3code 项目容易遇到的一个体积陷阱是把 Prisma Client 的完整引擎打进了服务端 bundle。虽然 Prisma 本身针对 Next.js 做了适配但如果你的服务器部署平台与本地环境架构不一致可能触发引擎下载问题。解决方向有两个一是用 Prisma 官方推荐的outputFileTracingIncludes配置把必要的引擎文件打进产出二是考虑换用prisma-client-js的 library engine而不是 binary engine部署体积会小很多。tRPC 方面有个容易被忽略的功能请求合并。当前端在树形组件中同时发起多个 query 时默认行为是逐个 request 并行请求。打开 httpBatchLink 配置tRPC 会把同一个 tick 内的多个请求合并成一个 HTTP 请求减少握手次数代价是接口响应时间会取最长的那一个。对于移动端网络环境或不稳定的公网合并收益很明显。import { httpBatchLink } from trpc/client; const trpcClient createTRPCClientAppRouter({ links: [ httpBatchLink({ url: /api/trpc, }), ], });4.5 中间件与访问控制的缺口很多 t3code 项目在写的过程中会出现一个念头反正我有 typeScript 类型保护路由和权限差不多搞定了吧实际上类型安全与安全边界完全是两回事。publicProcedure就是公开的任何人都能调用。要加上权限控制正确做法是在 router 里对特定 procedure 使用protectedProcedure或者用 tRPC 中间件统一校验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 } }); });这个中间件同样可以在server/api/trpc.ts中定义然后业务 router 引用它。如果你用的是默认 create-t3-app它实际上已经生成了protectedProcedure实例很多人在业务里没注意就直接用了 publicProcedure。上线前我强烈建议全局搜索一遍publicProcedure逐个确认哪些接口真的可以被匿名调用。5. 一次真实项目复盘t3code 范式能不能复制到别处5.1 两周功能开发的真实数据与感受我在上一个项目里用 t3code 从零搭了一个小型团队知识库功能包括用户登录、分类管理、文章发布、评论、关键词搜索、后台统计。从脚手架创建到主要功能可用大约花了两周半。如果放在以前用 React Express REST 的开发模式下我估计要打五到六折的节奏还得算上联调时间。让我印象最深的是改需求环节。产品经理要把文章列表的排序从纯时间倒序改成处理权重排序字段要新增一个pinned。我改了 Prisma schema、跑迁移然后在 router 查询里加排序条件前端列表不用动类型全部自动同步。整个改动加起来十几分钟没有去翻接口文档也没有让前端同事等接口更新。这种顺畅感在传统前后端分离项目里几乎不可能出现。当然也有付出代价的时候。团队里有新人不是特别熟悉 TypeScript 的复杂类型比如infer、typeof appRouter、泛型在 tRPC 里的层层包装。他经常在调用 tRPC hooks 时报类型错误学了一个星期才逐渐适应。如果你是带新人的角色我建议在项目启动前先安排几天的 TS 基础培训不要假设所有人都会用高阶类型。5.2 类型即文档团队协作模式的改变t3code 用久了会意识到它其实改变了团队协作方式。传统项目里后端要先写接口文档前端照文档开发两边在联调阶段反复对齐字段。t3code 里后端定义好 router前端在 IDE 里直接看到所有参数和返回类型的提示接口文档更多退化为一个建议文档真正的规范由代码本身表达。这种模式对团队的要求是代码评审时特别关注类型定义。如果新接口的返回类型很随意或者一个 router 的入参用了大而全的 object 类型会影响所有调用方。我在团队里定的规矩是每个公共 router 的输入参数必须有 zod schema返回类型尽量明确不要在返回对象里塞无关字段。这个规矩基本替代了过去对契约文档的审查。5.3 我不建议照搬的部分警惕过度抽象学到后面我发现 t3code 有一个隐性风险你可能会过度追求类型安全而引入一堆抽象层。比如有些人会在 tRPC 的 procedure 外面再包一层 service、再引入依赖注入容器理由是要解耦业务。这种抽象在 t3code 默认模板里完全多余它会让本来很直观的 router 调用链变得难以追踪最终反而破坏类型即文档的优势。t3code 本身设计的哲学是少即是多能直接用 TS 类型表达的就不要引入 schema 转换层能在 router 里写清楚的业务逻辑就不要为了结构清晰拆出七八个文件。你要想清楚这套栈提供的类型安全已是一种约束它约束了大量低级错误但还留下足够空间让你引入真正必要的架构——前提是你知道自己为什么要引入。如果你未来需要在团队里大规模推广 t3code我会建议先让团队成员读完 tRPC 的整个文档再跑一遍 create-t3-app 的示例页面而不是直接扔进一个复杂业务项目。基础概念不牢固时前端组件、后端路由、数据库三者交错出来的报错信息会让人非常崩溃。最后说一点我自己的体会。t3code 让我最舒服的瞬间永远不是它能跑起来的那一刻而是改完一个后端字段类型后前端同事说我没动但你返回的类型变了我趁机把相关页面一起优化了。这种跨层级的安全感是单独学一个框架给不了的。如果你也想体验不用一开始就搭大项目拿一个几十行数据的内部小工具练手就够了。等你在小项目里把 tRPC 的 query、mutation、invalidate 这几个概念玩熟再扩展到复杂业务自然就能体会到这套组合真正厉害的地方。