t3code 是我最近几个月一直在打磨的一个个人项目,一句话概括:一个坚持类型安全优先的全栈 TypeScript 应用脚手架。给不熟悉的朋友解释一下,你可以把它理解成一套"开了箱就能跑"的前后端一体化开发模板,核心组合是 Next.js + tRPC + Tailwind CSS + Prisma,也就是圈子里常说的 T3 技术栈,t3code 则是我在这套组合之上沉淀出来的工程化迈克件。它解决的问题其实挺具体:现在的全栈开发平均一个人要维护前端路由、后端接口、数据库表结构、鉴权逻辑四套东西,任何一个环节类型对不上,线上就等着亮红灯。t3code 的核心思路是让 TypeScript 类型从数据库一路贯穿到页面组件,中间不需要手写任何 API 契约和类型定义,改一处,编辑器立刻提示你其他地方哪里要跟着改。
这篇内容适合正在做全栈 TypeScript 项目、或者对类型安全有执念的开发者看,也适合受够了一堆 boilerplate 代码、想直接用一套成熟方案快速起项目的人参考。我会把 t3code 的选型逻辑、核心实现、完整实操流程和我在真实项目里踩过的坑都拆开聊,代码可以直接抄走改。
1. 项目缘起与整体设计思路
1.1 为什么会有 t3code 这个项目
事情得从我带的一个中型项目说起。团队用的是传统的前后端分离架构:前端 React,后端 Node.js 写 REST API,数据库由后端同学单独维护。表面上看没什么问题,但真正跑起来之后痛点非常集中——前端和后端各维护一份类型定义,接口文档稍有滞后,前端拿到的最新字段后端还没更新,一联调就是一下午。后来我尝试了 OpenAPI 自动生成类型,确实能解决一部分问题,但引入 swagger 注解、生成链路、提交钩子,整个工程的复杂度反而上去了。
t3code 的出发点就是把这些胶水层全部干掉。我在想一个问题:既然前后端都用 TypeScript,为什么不能让类型定义只有一个源头,然后所有地方共享?顺着这个思路找下去,tRPC 几乎是标准答案。它允许你在服务端定义函数,然后在客户端像调用本地函数一样调用这些远程函数,类型通过 TypeScript 的推断机制自动传递,不需要手写任何 API 层代码。结合 Prisma 直接操作数据库,类型又从表结构一路推到了业务函数。整个链路的数据类型只有一个权威源头,其余全部推导,这就是 t3code 想给你的核心体验。
1.2 技术选型:为什么是这四个核心组件
很多朋友问过我为啥不选别的组合,比如 Next.js 配 GraphQL,或者 NestJS 配 Swagger。我的回答是:t3code 的目标不是"功能最多",而是"心智负担最小"。
- Next.js:提供文件路由、服务端渲染、静态生成和 API 路由,一套框架把页面和服务端函数放在同一个工程里,部署天然简单。选它不选纯 Vite + Express 的原因在于,全栈应用里页面渲染、SEO、服务端逻辑往往耦合紧密,拆成两个工程维护成本会翻倍。
- tRPC:这是我的最爱。没有 schema 定义、没有 codegen、没有装饰器,纯粹靠 TypeScript 的类型推断搞定端到端类型安全。调用远程函数就像调用本地异步函数,IDE 自动补全和错误提示跟本地代码完全一致。
- Prisma:数据库 ORM 里类型体验最好的一个。schema 文件即数据模型的权威定义,迁移工具链成熟,查询 API 完全类型化,配合 tRPC 几乎没有认知断层。
- Tailwind CSS:样式方案选择很多,但 Tailwind 胜在"约定大于配置",工具类直接写在模板里,不会出现"这个组件的样式定义在哪个文件"的搜索成本。
这四个组件配合起来,整套数据流是:Prisma Schema → 数据库表 → tRPC 路由函数 → Next.js 页面组件,全程一个类型系统,没有多余的中间产物。
1.3 t3code 的目录设计
一个 T3 风格项目跑起来之后,目录结构大致是下面这样的:
my-app/ ├── prisma/ │ └── schema.prisma # 数据模型唯一权威来源 ├── src/ │ ├── pages/ # Next.js 文件路由 │ │ ├── api/ # tRPC 入口挂载在这里 │ │ └── index.tsx │ ├── server/ │ │ ├── api/ # tRPC 路由定义 │ │ │ └── routers/ │ │ ├── db.ts # Prisma Client 实例 │ │ └── auth.ts # 鉴权配置(可选) │ ├── styles/ │ │ └── globals.css # Tailwind 入口 │ └── utils/ │ └── api.ts # tRPC 客户端封装 ├── .env # 环境变量 └── package.json这个目录体现了一个非常重要的设计原则:服务端代码集中放在 server 目录下,页面只通过 tRPC 客户端访问能力,不直接操作数据库。很多类型不安全的问题,本质上都是因为服务端逻辑散落在页面里,出了问题根本不知道数据从哪来。t3code 通过目录约定把这条边界焊死,新增功能时团队自然遵循相同的路径,几乎不需要解释。
2. 核心实现:类型安全如何贯穿全链路
2.1 tRPC 路由层设计
tRPC 的核心概念是 router 和 procedure。router 是路由集合,procedure 是具体接口。以用户功能为例,路由定义大概是:
// src/server/api/routers/user.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const userRouter = createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { return ctx.db.user.findUnique({ where: { id: input.id }, select: { id: name: true, email: true }, }); }), updateProfile: protectedProcedure .input(z.object({ name: z.string().min(1).max(50) })) .mutation(async ({ ctx, input }) => { return ctx.db.user.update({ where: { id: ctx.session.user.id }, data: { name: input.name }, }); }), });这里有人会问,tRPC 不是号称不需要 schema 吗,怎么又整出 zod 了?其实 zod 不定义 API 契约,它只负责两个事情:一是在运行时校验输入数据的合法性(z.string().uuid() 会拒绝非 UUID 格式),二是通过 z.infer 把运行时校验器反推成 TypeScript 类型。tRPC 的输入类型不是手写的 interface,而是从 zod schema 推断出来的,所以唯一权威源依然只有一个。
第二个关键点是 publicProcedure 和 protectedProcedure 的区分。protectedProcedure 是 tRPC 中间件机制的一个典型用法——它先检查 session 里是否有用户,没有就直接抛错,有才继续往下执行。这个中间件在 t3code 里是内置的,新路由只要声明自己是 protected,鉴权逻辑就会被强制检查,不需要在每个接口里手动写 if (!user) throw。
2.2 Prisma 数据模型与数据库迁移
Prisma 的 schema 文件是数据层的心脏。以常见的博客系统为例:
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String @unique posts Post[] createdAt DateTime @default(now()) } model Post { id String @id @default(cuid()) title String content String author User @relation(fields: [authorId], references: [id]) authorId String published Boolean @default(false) }这里有几个细节值得注意。主键我倾向用 cuid 而不是自增 int,因为自增主键在分布式环境下容易暴露业务量,而且多实例部署时可能出现冲突;cuid 是字符串但有序生成,作为 URL 参数也不会被穷举。email 加了 @unique 约束,数据库层面兜底了业务上的"一个邮箱一个账号"规则,比只在应用层校验可靠得多。
数据库迁移命令是npx prisma migrate dev --name init,它会自动对比 schema 和目标数据库的差异,生成迁移 SQL 并执行。需要强调的是,迁移文件要提交到代码仓库,因为在多环境(开发、测试、生产)部署时,迁移顺序和记录是所有环境数据库结构保持一致的唯一线索,丢了迁移历史几乎是灾难。
2.3 Next.js 页面与 tRPC 的衔接
页面组件这边,t3code 使用 @tanstack/react-query 作为 tRPC 的底层数据请求库。tRPC 本身只负责函数调用和类型传递,实际的数据缓存、重试、并行请求都由 react-query 接管。一个典型页面的写法是:
// src/pages/index.tsx import { api } from "~/utils/api"; export default function HomePage() { const { data, isLoading } = api.user.getById.useQuery({ id: "..." }); if (isLoading) return <div>加载中...</div>; if (!data) return <div>用户不存在</div>; return ( <div> <p>{data.name}</p> <p>{data.email}</p> </div> ); }你可以仔细看这个流程:api.user.getById.useQuery 这个函数,useQuery 前面的对象路径是自动提示出来的,根本没有字符串形式的 URL,没有手动拼参数,没有响应类型断言。如果服务端把 email 字段改名或删除,前端页面编译时直接报错,连跑起来都不用。这就是 t3code 最值钱的部分——把"联调"这个环节变成了编辑器的即时反馈。
与此同时,t3code 默认开启了 Next.js 的 React Server Components 能力,对于无需交互的只读页面,可以让请求在服务端完成,浏览器拿到的就是完整 HTML,对 SEO 和首屏渲染都很友好。写的时候只需要注意:组件里凡是用了浏览器 API(localStorage、window 之类)或者 useState/useEffect 的,标注 "use client" 即可,否则默认就在服务端跑。
2.4 Tailwind 样式体系与主题定制
Tailwind 在 t3code 里承担的是纯样式层的约定管理。很多团队觉得 Tailwind 的 class 容易把模板写乱,我实际用下来的感受是:复杂度不是 Tailwind 带来的,而是组件拆分粒度不合适带来的。t3code 的模板里定义了一套组件类,用 @apply 把高频样式组合收敛成语义化类名,比如:
/* src/styles/globals.css */ @tailwind base; @tailwind components; @tailwind utilities; @layer components { .btn-primary { @apply inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500; } .card { @apply rounded-lg border border-gray-200 bg-white p-6 shadow-sm; } }这样页面里写的是className="btn-primary"而不是一长串工具类,既有 Tailwind 的原子化收益——样式的修改和删除不会污染全局,又保持了代码可读性。主题定制方面,可以在 tailwind.config.ts 里扩展 colors、spacing 等设计令牌,让前端同学只认语义令牌(primary、danger、surface)而不是具体色号,后续换肤或品牌调整成本会低很多。
3. 完整实操:从零初始化一个 t3code 项目
3.1 初始化与依赖安装
t3code 提供了基于 create-t3-app 的初始化脚本,执行完下面这行,一个完整项目就起来了:
npx create-t3-app@latest my-app交互式命令行会让你勾选需要的模块,我建议基础阶段全部选上,后面用不上再删。初始化完成后,进入项目安装依赖:
cd my-app npm install这里的依赖其实分三层:运行时依赖(next、react、@trpc/server、@trpc/client、@prisma/client、zod 等)、开发依赖(typescript、tailwindcss、prisma、eslint、prettier 等)以及 tRPC 相关的 react 适配层(@tanstack/react-query、@trpc/react-query)。安装完立刻跑一个npm run dev,看到 Next.js 的欢迎页说明基础链路没问题。
3.2 配置环境变量与数据库
t3code 的风格是一切配置显式化。项目根目录有个 .env.example 文件,复制成 .env 后填以下几个关键变量:
# .env DATABASE_URL="postgresql://user:password@localhost:5432/my_app?schema=public" NEXTAUTH_SECRET="你的随机密钥" NEXTAUTH_URL="http://localhost:3000"DATABASE_URL 指向 PostgreSQL 实例,本地没有数据库的话可以起一个 Docker 容器,一条命令搞定:
docker run --name t3code-db -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16验证数据库连接是否正常,用npx prisma db push直接把 schema 推到开发库(日常开发建议用 db push 快速迭代,正式环境用 migrate deploy),然后npx prisma studio打开图形化管理界面,能看到刚才定义的 User、Post 表已经创建好。
3.3 编写第一个业务功能
假设需要做一个"创建文章并展示列表"的功能。第一步在 Prisma schema 里确认 Post 模型存在,然后创建 tRPC 路由:
// src/server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: 20, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), 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, }, }); }), });然后把 postRouter 挂到根路由里:
// src/server/api/root.ts import { postRouter } from "./routers/post"; import { userRouter } from "./routers/user"; export const appRouter = createTRPCRouter({ user: userRouter, post: postRouter, }); export type AppRouter = typeof appRouter;前端页面调用时,react-query 的 invalidate 机制让数据更新后自动刷新列表:
const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: async () => { await utils.post.list.invalidate(); }, });这里 onSuccess 里调 invalidate,意思是"创建成功后,让 post.list 这个查询重新拉取数据"。整个链路里没有写一行 fetch、axios 或 URL,这就是 t3code 的日常开发方式——你写的所有代码其实只分两类:定义数据的能力(服务端)和使用数据的能力(客户端)。
3.4 部署到生产环境的要点
代码写完之后,部署阶段有几个坑必须提前避掉。
Prisma Client 需要在构建时生成,Dockerfile 里必须加npx prisma generate这一步,而且要在next build之前执行;否则生产环境的 Prisma Client 是残缺的,运行时直接报 P1001 这类连接错误。数据库迁移在部署流程里的顺序也很讲究:容器启动时先去跑npx prisma migrate deploy,成功后再启动 Next.js 服务,避免新代码已经跑起来但表结构还没更新的情况。
CDN 或反向代理层需要注意 Next.js 的文件路由和静态资源缓存策略,一般把_next/static路径设置为强缓存,页面 HTML 设置为 no-cache,这样既能利用 CDN 缓存又不会让用户拿到过期页面。部署完成之后建议立刻看一眼服务端日志里有没有 tRPC 错误,最常见的一类就是跨域配置不对导致浏览器请求被拦,Next.js 和 tRPC 同源部署的话基本不会遇到,但只要拆了子域就必须在服务端配置 CORS 白名单。
4. 常见问题与排查技巧实录
4.1 tRPC 类型不收敛怎么办
症状是"功能跑得通,但编辑器里 TypeScript 一直报错"或者"改了服务端返回值,前端不生效"。先确认你是否重启了 dev server——tRPC 的类型推导是编译期行为,如果类型文件变了但 dev server 没有重新编译,编辑器是感知不到的。
另一个常见原因是 router 没有挂到根路由。t3code 里存在"孤立 router"的情况,你新建了一个 postRouter,但忘了在 root.ts 里 add,前端 api.post 的对象路径就永远不出现。排查方式很简单,在 root.ts 里export type AppRouter = typeof appRouter这行加一个类型查询,编辑器 hover 就能看到当前 AppRouter 暴露了哪些路由。
4.2 Prisma 迁移报错合集
Prisma 的迁移错误有很强的规律性。最常遇到的是"列已存在"或"表不存在"——原因基本都是本地用 db push 改了 schema,又用 migrate dev 生成迁移,两者在本地产生了分歧。解决思路是:日常开发只用一个命令,要么 db push(适合快速迭代不记录迁移),要么 migrate dev(适合正式记录每次变更),别混着用。
如果是生产环境迁移失败,不要直接在数据库里手工改表结构。正确做法是重新生成一个干净的迁移文件把差异补上,或者用npx prisma migrate resolve --applied标记已经手动执行的迁移。我吃过最大的亏就是"线上报错了直接 psql 改表",后来迁移历史完全对不上,环境重建异常痛苦。
4.3 Next.js 与服务端渲染的边界
很多团队在 T3 项目里把 tRPC 请求全部放在 useEffect 里执行,这会带来两个问题:一是页面首屏出现空白或加载动画,SEO 不友好;二是所有数据请求都在浏览器发起,没有利用服务端的网络带宽优势。
t3code 推荐的做法是善用 Next.js 的 RSC 特性:对于列表页、详情页这些以展示为主的内容,可以在服务端直接调用 tRPC 查询,把数据作为 props 传给客户端组件;对于表单提交、交互操作,才用客户端组件里的 useMutation。一个判断标准是:这个页面是否需要登录用户的前端状态?如果不需要,优先服务端渲染。
4.4 性能与体验优化心得
tRPC 本身只是一个 RPC 层,性能瓶颈往往在数据查询上。一次 list 接口返回 20 条文章,每条都要计算作者信息和评论数,如果没有用 Prisma 的 include 或嵌套 select,会产生 N+1 查询问题——也就是一次接口背后跑了 20 多次 SQL。用 Prisma 的 include 一次联表查出来,再在页面里按需取用,接口耗时能降一个数量级。
另外 react-query 的缓存策略也值得配置。t3code 默认对查询请求做 5 分钟 staleTime,意味着 5 分钟内同一个查询不会重复发请求,这在列表页反复切换筛选条件时收益很明显。数据变化频繁的页面(比如实时仪表盘),可以把 staleTime 调小到 5 秒,并开启 refetchOnWindowFocus,让切回标签页时自动刷新。
5. 扩展方向与个人心得
5.1 插件化与模板市场
t3code 后续最值得做的扩展方向是模板市场。目前脚手架只能生成一套标准结构,但如果能像 create-next-app 那样支持 --template 参数,比如"博客模板""电商模板""管理后台模板",每个模板预置好对应的 Prisma 模型和 tRPC 路由,起新项目的速度会从中级提升到直接开工。我验证过这种做法的可行性:核心脚手架逻辑不变,模板层只是一个包含 pages、routers、schema 的附加包,替换成本不高。
5.2 团队协作中的类型安全收益
t3code 在团队协作里最大的价值不是省了写代码的时间,而是消灭了一整类"理解偏差"问题。两个人合作开发一个功能时,前端同学只要api.post.create.useMutation()一调,参数类型自动弹出,字段名拼错、类型写错在编辑阶段就会被拦下;后端改字段类型时,前端编译红叉立现。我把这套脚手架推进团队半年多之后,联调会议从每周两次减少到基本没有,因为根本不需要讨论"这个接口返回哪些字段"——代码就是契约,编辑器就是文档。
5.3 我踩过的坑和长期维护体会
最后分享几个我自己的真实教训。第一个是全链路升级依赖的时候,不要一口气把 Next.js、React、tRPC、Prisma 全部升到最新版。这几个库的版本耦合很紧,尤其 tRPC 的 v10 和 v11 之间 API 变化非常大,升级时建议逐一升级,每升一个就完整跑一遍类型检查和测试。第二个是环境变量管理,团队多人协作时,建议做一个脚本从统一的面板拉取 .env 文件,别在群里发明文——数据库连接串一旦泄露,不只是隐私问题,还可能被拖库。
还有个容易忽略的细节:prisma schema 是整个类型链路的源头,每一次改动都要走代码评审。它不像普通代码可以频繁回滚,schema 变更意味着数据库结构变更,历史迁移文件无法伪造。所以我的建议是 schema 文件优先由最有经验的同事审,宁可改慢一点,也不要留下错误字段。
如果你准备搭一个全新的全栈 TypeScript 项目,我建议直接花一个小时跑一遍 t3code 的初始化流程,自己动手创建一个带数据库的简单功能,你会直观感受到"类型从数据库长到页面上"是一种什么体验。踩过几次坑之后,大概率就回不去手写 API 层的老路子了。