t3code 这名字乍一看没什么信息量但如果把全栈开发圈子这两年的趋势捋一遍你会发现它指向的其实是一个特别清晰的方向让一个 Web 项目从数据库到页面全程都活在 TypeScript 的类型系统里。t3code 是我用 T3 Stack 这套技术组合搭建的全栈项目代号这篇文章就是一次完整的项目复盘从为什么选这套技术、怎么初始化、核心链路怎么写到部署踩坑、后续扩展全都在里面。如果你是一个正在纠结技术选型的全栈开发者或者刚学完 React 和 TypeScript 却不知道前后端数据流怎么串起来的新人这篇文章应该能给你一条可以直接照做的路径。t3code 不是一个框架它是一套让类型安全贯穿整条链路的工程实践——改一次数据库模型前端联调时直接编译报错而不是上线后跑出运行时异常。就冲这一点我认为它值得好好聊一聊。1. t3code 是个什么样的项目一场围绕类型安全的全栈实验1.1 先搞清楚 t3 到底是哪三个 Tt3code 里的 t3说的是 T3 Stack。官方给出的名字来自三个以 T 开头的核心库TypeScript、Tailwind CSS、tRPC。不过实际用起来这套组合通常还会带上 Next.js 和 Prisma所以它其实是一套32的完整全栈配置。这五个东西的定位分别是Next.jsReact 框架负责页面渲染、路由、服务端组件、API 能力TypeScript全项目的类型系统基底Tailwind原子化 CSS负责样式tRPC客户端与服务器之间的类型安全通信层PrismaORM负责数据库访问并把数据库表结构变成 TypeScript 类型。很多人第一次听到 tRPC 会以为这是个冷门库实际它现在已经是类型安全全栈的主力方案之一。t3code 这个项目之所以用 T3不是因为它网红而是因为这五个工具解决的是同一条主线下不同节点的同一个问题类型不能断。页面这边定义了一个组件属性数据库那边少了一个字段编译器会在你保存文件的瞬间告诉你哪里对不上。1.2 它想解决的真正痛点接口联调不再靠事后补救我做过不少老式前后端分离项目最头疼的场景是后端在数据模型里加了一个字段或者把某个字段的类型从 string 改成 number但接口文档没有及时更新。等前端拿到最新联调包浏览器直接报 undefined或者更隐蔽一点——字段存在但类型不对页面显示了一段诡异的拼接结果。这类问题的根因只有一个类型信息在前后端边界被扔掉了两边各自维护一套看起来一样但实际会漂移的定义。t3code 的做法是让类型穿过网络边界。前端调用一个远程函数它的入参出参类型直接来自后端代码中间没有任何手写的接口签名、没有重复定义的 DTO、没有需要人工同步的 OpenAPI 文档。你改一行后端逻辑只要类型变了前端编译期立刻红一片IDE 里就能看到错误。这不是魔法而是 TypeScript 的类型推导能力配合 tRPC 的运行时封装做到的。1.3 什么样的读者适合参考 t3code 的做法按我的经验下面三类人最适合从这套方案里拿到实际收益被前后端类型割裂折磨过的全栈开发者尤其是那种一个人要维护 React 前端、Node 后端、数据库脚本三个工程的刚学完 React TypeScript想做一个带数据库、带 API 的完整项目但不知道从哪下手的新人。t3code 的初始化脚手架能把大量配置琐事直接解决掉正在做技术选型想在 Next.js 的全栈能力和纯后端 API 服务之间做权衡的团队。当然如果你们团队已经重度使用 GraphQL 或者 OpenAPI 生成客户端代码这套方案不一定需要强切。但如果你想在一个中小型项目里快速建立一条高质量的数据链路t3code 给出的答案很实用。2. T3 那套组合为什么能凑到一起选型背后的真实取舍2.1 Next.js 不只是 React 框架它是前后端合一的工程底座t3code 的第一层地基是 Next.js。很多前端同学对 Next.js 的印象停留在SSR 框架上但在我把它放进 T3 组合之后我发现它更大的价值是一个工程里同时容纳页面、服务端逻辑和 API 入口。App Router 出现以后你用 Server Component 在服务器端直接取数渲染用 Client Component 处理交互甚至可以在同一个路由文件里声明动态渲染策略。这套能力对 t3code 意味着什么呢它意味着部署可以非常薄。你不需要单独维护一个 Node 后端服务也不用为 CORS 头疼——页面和 API 在同一个域名下同一个 Next.js 进程里。从这个角度说选 Next.js 不是因为它是默认选项而是因为全栈类型安全需要一个能统一执行环境的宿主。2.2 tRPC像调用本地函数一样调用后端而不是去拼 URLREST 接口模型发展了这么多年本身没什么大毛病但它在 TypeScript 项目里有一个天然的断点前端永远不知道 GET /article/123 到底返回什么。你可能会说有 OpenAPI 生成 SDK 可以解决。是的但代码生成意味着构建链路上要多一步而且很多工具生成的类型跟后端实际逻辑之间还是有缝隙。tRPC 的做法很直接不用手写 HTTP 路由而是把后端逻辑组织成一个个 procedure你可以简单理解为一个可被远程调用的函数前端通过类型安全的方式直接调用。这里的关键机制是 TypeScript 的 typeof 能力和类型推断——前端 import 一个 router 的类型然后传给 React Query 的封装层整个调用链的类型就自动通了。我做 t3code 时对比过两种方案感受很直观方案类型同步方式前端调用方式主要成本REST 手写类型人工维护容易漂移手动拼 URL fetch每次改接口都要检查两端GraphQL 生成类型服务端生成 schema 和客户端代码写 GraphQL 查询语句额外引入一层运行时和 schema 管理tRPC直接由后端函数类型推导调用导入的函数项目结构和调用边界要按约定组织GraphQL 很强大但如果你不需要复杂的聚合查询、不需要跨服务合并数据它带来的那套 schema 和解析器其实是一种负担。tRPC 胜在轻——没有字符串查询没有手写 generator 配置就是一个函数调用。2.3 Prisma 是数据库层的类型反射镜t3code 的另一个核心底座是 Prisma。它解决的是数据库访问层的类型问题。Prisma 的用法是先写一个 schema.prisma 文件定义模型、字段和关系然后运行迁移命令生成数据库表结构同时生成一套完整的 TypeScript 类型。此后你写查询代码时Prisma client 返回的对象天然带类型不用再额外定义一遍实体接口。我第一次用 Prisma 的时候最深刻的印象是schema 文件变成了整个数据库的唯一真相源。以前我要维护 ER 图、建表 SQL、后端实体类、前端类型定义四份东西现在只需要改 schema然后让 Prisma 生成对应的 client 类型tRPC 的 procedure 返回值类型又自动从 Prisma 的类型里推导出来前端再拿过来用——整条链路的类型就像四根管子对上了接口没有一处需要人工抹胶水。2.4 Tailwind 和 zod组合里容易被低估的两个配角很多人提到 T3 会盯着 Next.js、tRPC、Prisma 看但我实际用下来Tailwind 和 zod 同样重要。Tailwind 解决的是样式系统问题整个项目不需要维护分散的 CSS 文件组件结构本身携带样式信息这让全栈项目在代码审查时非常轻松。zod 则是输入校验的主力——tRPC procedure 的入参校验可以直接用 zod schema 定义运行时校验和编译期类型同源不需要写两套。这五个库在 t3code 里互相咬合少一个都会在某个环节出现类型断点。这也是 T3 Stack 被称为组合拳而不是工具集的原因。3. 从脚手架到首个页面初始化过程的每个关键决策3.1 create-t3-app 的每个选项到底该怎么选t3code 的起点是官方的脚手架工具 create-t3-app。这个命令会把上述几个库一次性配好省掉大量琐碎配置。我实际执行的时候选项是按照当前版本来的每个选择背后都有明确的理由npm create t3-applatest t3code脚手架会让你勾选一系列功能。我当时的推荐组合是Next.js默认使用 App Router这是目前的主力模式TypeScript默认开启Tailwind CSS打开后面所有样式都用它Prisma打开t3code 的核心链路需要数据库tRPC打开这是 T3 的招牌NextAuth如果项目有登录需求就打开。当时我为了先跑通核心链路暂时没有选后面需要认证再加Drizzle 或 Prisma 二选一我选 Prisma因为它的类型生成体系在这个组合里更成熟。有个细节很多人容易忽略脚手架会让你选择experimental flags之类的东西比如是否启用 Next.js 的 experimental 功能。对于普通项目我不建议开实验特性稳定压倒一切等新特性转正再升级不迟。3.2 第一次跑起来后的目录结构谁的职责谁负责脚手架生成的项目目录非常规整我第一次打开时其实有点职业快感。核心结构大致是src/ app/ # 页面路由 server/ api/ # tRPC router 定义 db.ts # Prisma client 单例 trpc/ server.ts # 服务端调用封装 client.ts # 客户端调用封装app 目录是 Next.js 的页面层server/api 是全项目后端逻辑的集中地trpc 目录是客户端和服务端之间的通道。这种分层的价值在于你不要再自己发明一套前端目录、后端目录框架已经把边界画好了。新手最容易犯的错是往页面组件里直接写数据库查询这在 t3code 里会非常别扭——正确做法是把数据库和业务逻辑放在 server 层页面只管调用。3.3 T3 风格的环境变量宁可在编译期报错也不让运行期裸奔t3code 的脚手架会生成一个 env 文件里面引入了一个在 T3 生态里很典型的机制用 zod 对环境变量做编译期校验。你没有配置 DATABASE_URL项目直接拒绝启动并在控制台明确告诉你缺了哪一项。这个设计看起来很偏执但实际相当省心。比如我本机开发最常用的配置就是给 Prisma 一个数据库连接地址。如果你没有现成的 PostgreSQL最简单的起一个本地容器docker run --name t3code-postgres -e POSTGRES_PASSWORDpostgres -e POSTGRES_USERpostgres -e POSTGRES_DBt3code -p 5432:5432 -d postgres:16-alpine然后 .env 里写DATABASE_URLpostgresql://postgres:postgreslocalhost:5432/t3code?schemapublic这里有一个值得养成的习惯所有从环境变量读取的配置都不要直接 process.env.XXX 散落在代码里而是统一在 env 文件里校验一次、导出为一个类型安全的对象。以后你换环境、加变量只需要改这一处全项目立刻感知。3.4 快速验证第一条链路能不能通初始化完成之后我不会先去写业务页面而是先验证一条最小链路在数据库里创建一张表写一个 tRPC procedure 读数据然后在页面上显示出来。这相当于把整条数据管道先打通以后剩下的工作都只是往管道里加组件和逻辑。这一步检查通过说明 Next.js、Prisma、tRPC、React 的衔接没有问题后面写业务时会顺畅很多。4. 核心链路实战一条数据从数据库到页面的完整路径4.1 先定义 Prisma 模型我在 t3code 里做了一个简单的文章系统第一张表是 Article。schema.prisma 里长这样model Article { id String id default(cuid()) title String content String published Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt }有了模型之后执行迁移命令让数据库结构和代码同步npx prisma migrate dev --name init执行完后Prisma 会生成一套 Article 类型。这一步跑完数据库表已经建立TypeScript 类型也已经准备好。4.2 tRPC 里定义一个查询 procedure接下来在 server/api/routers/article.ts 里写业务逻辑import { z } from zod; import { createTRPCRouter, publicProcedure } from ../trpc; export const articleRouter createTRPCRouter({ listPublished: publicProcedure.query(async ({ ctx }) { return ctx.db.article.findMany({ where: { published: true }, orderBy: { createdAt: desc }, }); }), getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { return ctx.db.article.findUnique({ where: { id: input.id } }); }), });这里值得展开说两点。第一publicProcedure 表示无需登录就能调用后面如果要加权限改成 protectedProcedure 并在中间件里校验登录态即可。第二getById 的入参用了 zod 做运行时校验前端传错字段、传错类型后端会在运行时直接拒绝同时编译期也不会让你传进来。这种运行时和编译期双重检查正是 T3 组合的价值。4.3 在页面里调用useQuery 接管数据状态t3code 的前端数据流是建立在 React Query 之上的。页面组件里这样调用use client; import { api } from ~/trpc/react; export function ArticleList() { const { data, isLoading, error } api.article.listPublished.useQuery(); if (isLoading) return div加载中.../div; if (error) return div出错了{error.message}/div; return ( ul {data?.map((article) ( li key{article.id}{article.title}/li ))} /ul ); }api.article.listPublished 这个方法是直接从后端类型推导出来的不是手写的请求函数。你甚至不需要知道请求 URL 长什么样也不用处理 JSON 序列化。这就是 T3 让我最舒服的一点调用远程逻辑的心态已经变成调用本地函数。4.4 服务端组件里怎么拿数据用 Server Caller 避免重复请求App Router 场景下很多页面是服务端渲染的。如果页面是 Server Component就不能用 useQuery但可以直接在服务器端调用 tRPCimport { api } from ~/trpc/server; export default async function ArticlePage() { const articles await api.article.listPublished(); return ( main {articles.map((article) ( article key{article.id}{article.title}/article ))} /main ); }这一步看似简单其实解决了全栈开发里一个很隐蔽的问题如果页面同时需要服务端渲染和客户端交互数据千万别在两处各取一次。Server Caller 的方式可以让服务端取完数据后直接把初始数据交给 React Query 作为缓存初始值客户端组件后续交互时不需要重新加载。5. 样式与交互Tailwind 和组件封装是怎么组织起来的5.1 为什么在 t3code 里我几乎不写 CSS 文件如果你打开一个传统 React 项目常见的是 components 目录里一堆 .css、.scss 文件命名还要小心翼翼。t3code 项目用了 Tailwind 之后我基本不写独立的样式文件了。页面上直接写 classflex gap-4 text-sm text-gray-600 这样的原子类组件与样式紧密结合删组件时样式跟着一起删不会留下死代码。Tailwind 的 JIT 模式会在编译期扫描源码中的类名只生成用到的样式所以生产包非常小。不过我后来维护项目时也发现团队里如果完全不约束一个组件的 className 会变得很长。我的建议是把重复出现的组合抽成小组件。比如文章卡片组件外层排版、内层字体颜色都在组件里写一次页面其他地方直接复用既保持了 Tailwind 的灵活也维护了整洁度。5.2 列表页从零到好看一个卡片组件的心智写交互界面时我的关注点是数据状态而不是样式。t3code 的文章列表页由三个层次组成外层页面负责布局中间列表组件负责循环渲染最底层的文章卡片负责单条外观。卡片组件长这样export function ArticleCard({ article }: { article: Article }) { return ( div classNamerounded-xl border border-slate-200 p-4 shadow-sm transition hover:shadow-md h3 classNametext-lg font-semibold text-slate-900{article.title}/h3 p classNamemt-2 line-clamp-3 text-sm text-slate-600{article.content}/p div classNamemt-3 text-xs text-slate-400 {new Date(article.createdAt).toLocaleDateString()} /div /div ); }这里唯一要注意的是 hover 效果和行数截断这类细节Tailwind 的过渡类和 line-clamp 提供了现成方案不用手写那些容易出错的 CSS hack。整个过程基本就是先确认数据字段再按层次排版效率比夫妻店式前端开发高不少。5.3 客户端组件和服务端组件的边界一张容易踩脏的桌子App Router 模式下默认所有组件是服务端组件。一旦你在组件里用了 useState、事件监听、浏览器 API就必须在文件顶部写 use client。 t3code 项目里最容易遇到的问题就是在服务端组件里尝试调用 useQuery结果编译失败。这不是框架限制你而是为了强制你分清渲染职责——服务端负责取数和首屏客户端负责交互和动态更新。实操时我的判断标准只有一条这个组件是否需要交互状态。如果只是展示从数据库读到的数据就用服务端组件如果要做搜索框、分页、筛选这类用户操作就加 use client。混合场景则把外层设为服务端组件内部包一个客户端组件。6. 部署上线与运维最容易翻车的几个环节6.1 数据库迁移上线前必须做的一件事本地开发时跑 prisma migrate dev 没有压力但生产环境千万别让应用启动时自动改数据库结构风险太大。正确做法是发布前手动或通过 CI 执行npx prisma migrate deploy这个命令会读取 migrations 目录里的文件把未应用的变更按顺序执行。我早期的教训是先 build 了镜像再跑 migration结果 build 阶段就需要 DATABASE_URL 来生成 Prisma client而迁移文件还没执行导致新查询引用新字段直接失败。合理顺序应该是先 migrate deploy再启动新版本应用。如果用了蓝绿部署或者滚动发布更要把迁移当作独立步骤不要和启动命令混在一起。6.2 环境变量多少个环境就需要多少份配置t3code 的本地、预发布、生产环境都需要各自独立的 .env。因为有 T3 那套 zod 校验机制任何环境缺少必备变量都会拒绝启动这反而保护了线上——不会出现忘了配数据库地址但服务还半死不活跑着的尴尬。我在部署时通常用一个变量清单变量用途示例DATABASE_URL数据库连接postgresql://...NEXTAUTH_SECRET认证签名如果启用认证随机长字符串NEXTAUTH_URL站点地址https://example.com一个环境的变量最好由同一个来源管理比如 CI 的 secret 面板或部署平台的环境变量设置不要手动修改线上服务器配置文件否则下一个接手的人会很痛苦。6.3 用 Next.js 的 standalone 模式把镜像瘦下来部署 Next.js 项目最常见的做法是直接跑 next start但这样每次构建时会把整个项目源码和依赖都塞进镜像。在 t3code 项目的 Docker 部署里我在 next.config.ts 中开启const nextConfig { output: standalone, };这样构建产物会生成一个 .next/standalone 目录里面只包含运行所需的最小文件镜像体积能缩小不少。配合多阶段构建的 Dockerfile部署体验很好。有个细节standalone 目录里不包含 .next/static需要单独复制静态资源。6.4 上线后的第一课区分请求错误和运行时异常全栈类型安全解决的是开发期的类型问题但运行时错误依然存在。t3code 上线后你要能快速分清两类错误数据库连接失败、第三方服务超时这类基础设施问题和业务逻辑 bug。React Query 会把 tRPC 抛出的错误对象透传回来我在前端统一做了错误处理——基础设施错误给用户一个稍后重试的提示业务错误则提示具体表单问题。日志里也要把 tRPC 的 path 和 input 打出来不然根本不知道是哪个 procedure 触发的。7. 我在 t3code 上踩过的坑四条完整的排查链路7.1 Prisma 在容器里冷启动特别慢第一次把 t3code 部署到容器里时我注意到 API 首次请求要等好几秒后续请求就正常了。排查链路是先看日志确认慢在数据库查询还是初始化再用 trace 工具看 Prisma engine 的启动耗时最终定位到 Prisma 在容器中默认使用的查询引擎需要动态加载二进制冷启动时开销明显。解决方案是调整 Prisma 的引擎模式显式配置二进制路径并利用容器启动时的健康检查预热连接。具体做法是建一个启动脚本在服务正式接流量之前执行一次简单的数据库查询把连接池和引擎先热起来。很多人忽略了这一步我建议任何用到 Prisma 的容器化项目都要做一次预热。7.2 服务端组件里误用 useQuery 导致的编译失败开发 t3code 时遇到过一个很典型的报错在未声明 use client 的文件里调用了 useQueryNext.js 的编译结果直接不可用。一开始我以为是安装的依赖有问题反复重装了几次包最后才发现是渲染边界的问题。排查思路是先看报错信息里提到的组件文件确认是不是服务端组件然后看数据到底是仅展示还是交互式获取。如果是仅展示改用 Server Caller 方式在服务端取数如果是交互式给组件文件加上 use client。这个坑特别容易出现在列表页加搜索框的场景——列表数据服务于服务端渲染搜索又需要客户端状态正确的做法是把搜索框单独抽成客户端组件列表主体继续用服务端组件。7.3 Tailwind 动态拼接类名不生效写过 Tailwind 的朋友可能都遇到过class{bg-${color}-500}这种写法在运行时完全不生效。原因是 Tailwind 编译阶段扫描的是源码字符串不会去执行你的 JS 逻辑动态拼接出的类名不在编译列表里。t3code 里做标签颜色的时候我也踩了。后来改成直接写完整的类名映射表比如const colorMap { red: bg-red-500 text-white, blue: bg-blue-500 text-white, green: bg-green-500 text-white, } as const;然后把整个类名字符串写进模板。这样 Tailwind 能扫描到类型安全也能保住。遇到这种问题第一反应不要去查为什么类名没生效而是检查你的类名是不是在源码里以完整字符串形式存在。7.4 开发环境热更新越来越慢t3code 开发到后期保存一次文件要两三秒才刷新挺影响心情。我排查后发现主要原因是路由文件和 tRPC 类型定义形成了比较深的依赖链改一个公共类型dev server 要把相关模块都重新编译一遍。我用两个办法解决了一是按 Next.js 官方建议把serverExternalPackages配置好让某些不参与热更新的依赖脱离编译二是把 React Query 的 query key 和 tRPC 的操作合理分区减少界面组件对 server 类型的直接依赖。其实真没必要求快求全一个项目里如果所有前端组件都 import server api 类型热更新一定慢因为类型变化会传导到所有依赖节点。8. t3code 往后还可以怎么长扩展方向与我的重估8.1 给 t3code 加功能时我从哪里下手t3code 作为一套全栈骨架扩展方向其实很清晰。按优先级排我建议这么走加认证把 NextAuth 配置好再定义 protectedProcedure在中间件里做登录校验。后端一个泛型保护函数所有敏感接口直接换掉 publicProcedure 即可类型安全依然贯穿加权限在 tRPC 的 createContext 里注入当前用户信息然后在每个 procedure 里校验角色别把权限写在页面层否则接口层裸奔加文件上传用预签名 URL 的方式让前端直传对象存储tRPC 只负责签发凭证不要让大文件经过 Node 进程加后台管理复用同一套 database 和 router另开一个 app/admin 路由组页面样式继续用 Tailwind 的组件化思路。这套骨架最大的优势是每加一类功能核心数据流保持不变类型安全依然覆盖整条链路。新功能不破坏旧结构这在长期维护里价值很大。8.2 重新评估一遍所有项目都适合 T3 组合吗把 t3code 完整跑过、部署过、踩过坑之后我对这套组合的认识也更客观了。它不是一个银弹而是有明确适用边界的方案非常适合中小型全栈应用、内部系统、个人作品集、需要快速验证的创业项目不太适合大量对外提供的开放 API 服务、超大规模微服务拆分、前后端团队完全分开且各自部署的场景。如果你碰到的是后两类情况传统 REST OpenAPI 或独立 GraphQL 层可能更合适毕竟团队协作边界和部署拓扑不一样。T3 组合的强项是一个人或一个小团队把整条链路握在手里一旦团队大到你需要给外部开发者提供稳定的 API 文档情况就会发生变化。8.3 如果重新把 t3code 做一遍我会改什么基于目前的经验如果再开一个类似 t3code 的项目我至少会改两处。第一初始化时一步到位把 NextAuth 加进来。哪怕第一版不需要登录也比后面再改 protectedProcedure 方便。T3 的 type-safe auth 配置在项目后期接入时改动面比想象中大——你要改 context、改 router 泛型、改页面取用户方式。第二我会更早引入测试。类型安全能拦截很多错误但业务逻辑的坑它拦不住。尤其是 tRPC router 里的查询逻辑值得用真实的测试数据库写一遍集成测试。没有测试的类型安全只保证了接口形状没有保证数据结果符合预期。说到底t3code 给我的收获不只是一个技术方案。它让我重新审视了全栈开发里类型到底应该站在什么位置——不是给 IDE 看的装饰品不是文档的替代品而是开发过程中天然的沟通桥梁。最后分享一个我这轮项目里最受用的小习惯每次写完一个 tRPC procedure我都会主动去页面组件里检查调用处的类型是否自动正确。如果编译器什么错都没报说明后端到前端的链路是干净的如果反复报错我反而会高兴——那些错误帮我在上线前就排掉了好几处隐患。这套方案不是没有学习成本但当你体验过一次改模型、跑迁移、前端自动报错的闭环之后就很难再回去了。