「t3code」是我前段时间亲手搭起来的一个全栈项目代号,核心方案完全围绕 T3 技术栈(T3 Stack)展开。T3 栈这几年在 Next.js 社区里热度一直很高,简单说就是 Next.js、TypeScript、tRPC、Prisma、Tailwind CSS 这五样组合在一起,用同一套类型系统把前端到后端彻底打通。t3code 想解决的是很多开发者都碰到过的现实问题:传统前后端分离下,前端等接口文档写类型,后端等前端确认字段,任何一个字段变动要两边同步改,时间一长接口和类型就对不上。t3code 的做法是让服务端路由直接推导出客户端类型,调用接口时自带完整类型提示和编译检查,基本告别了重复维护类型定义。这篇文章我会从选型逻辑、脚手架搭建、tRPC 核心实现、Prisma 数据建模几个维度完整拆解这个项目,把我在实际开发中踩过的坑和排查思路也一起整理出来,适合对 T3 技术栈感兴趣、或者正打算上手 tRPC 的朋友参考。
1. 为什么是 T3 技术栈:从痛点出发的选型逻辑
很多人第一次看到 T3 栈是冲着 Next.js 去的,但真正让我下定决心采用它的,是 tRPC 带来的端到端类型安全。这项目一开始我想得很单纯:做一个轻量级的团队任务管理工具,有项目、有任务、有状态流转就够了。结果越做越发现,前后端之间的类型衔接才是真正的成本大头。
1.1 传统前后端分离的三大痛点
先聊痛点。如果你写过比较多的业务系统,下面这些场景应该不陌生。
第一,类型定义重复维护。后端定义一套 DTO,前端再复制一份 TypeScript interface,出了新字段就要改两个地方。运气好是用同一个代码仓库还能同步,运气不好是前后端分仓库,接口文档更新不及时,前端就只能对着抓包数据猜类型。
第二,接口文档永远在失真。Swagger 也好、YApi 也好,文档描述的是"上次"的接口状态。一旦后端改了一个字段名或调整了返回结构,文档没来得及更新,前端直接拿到运行时错误。
第三,胶水代码多。传统接口联调几乎都要写一层 API 封装:request 封装、参数拼装、响应拦截、类型断言。这些代码本身没什么技术含量,但量大、无聊、还容易被忽略,真正的业务逻辑反而被淹没在这些胶水层里。
1.2 T3 技术栈如何解决这三座大山
T3 技术栈的核心设计思想,就是让类型从数据库一直延伸到 UI 组件,中间不需要任何胶水代码。
组合起来的效果是这样的:Prisma 负责根据数据库 schema 生成客户端类型,tRPC Router 的输入输出天然继承这些 Prisma 类型,再通过 TypeScript 的类型推导机制传递到 React 组件里。也就是说,你永远不需要为了某个接口"重新声明一次类型",类型定义只有一份源头,其他位置全部靠推导。
对比来说,GraphQL 也能实现类似效果,但通常要用 codegen 生成类型,还要处理 schema 文件和 resolver 之间的同步问题。tRPC 做的是更轻量、更直接的事情:它不定义一套新的 DSL,而是直接利用 TypeScript 本身的类型系统,让服务端和客户端共享同一个类型空间。这个思路对中小型全栈项目来说非常友好,几乎零额外成本,就能拿到比传统 REST 更安全的类型体验。
1.3 t3code 项目的整体定位与设计取舍
t3code 定位于"内部工具类全栈应用",特点是小步快跑、需求变化频繁。这种场景下,如果每个需求都走一遍完整的前后端接口定义流程,迭代速度会被明显拖慢。
所以我做了一个很明确的设计取舍:t3code 采用 Next.js App Router 作为上层框架,tRPC 只负责业务数据读写,Prisma 只作为唯一数据访问层。整个项目是一个"单体全栈应用",短期内不拆分成独立前端和后端。这样做的原因是,内部工具的流量和业务复杂度还不需要微服务那一套,单体应用反而是迭代效率最高的选择。等未来如果某一模块真的需要独立部署,再按 tRPC Router 的边界拆分也不迟。
2. 脚手架搭建与项目结构:从零初始化 t3code
确定选型之后,接下来就是实际把项目跑起来。T3 技术栈官方提供了一个脚手架工具叫 create-t3-app,用它可以一次性生成完整的项目基础,省去了自己逐个集成 Next.js、tRPC、Prisma、Tailwind 的时间。
2.1 create-t3-app 初始化流程
项目初始化非常简单,找一个空目录执行:
npx create-t3-app@latest t3code执行之后,脚手架会弹出一系列交互式选项,询问你需要包含哪些功能模块:
✔ Select which packages you would like to use ✔ Next.js ✔ TypeScript ✔ tRPC ✔ Prisma ✔ Tailwind CSS ✔ ESLint如果只是学习或做内部工具,我建议全部勾选后直接回车,默认配置已经非常合理。里面有个"Include Docker"或者"Use App Router"之类的选项,按需选择即可。App Router 是 Next.js 13 之后的新范式,文件路由、Server Component 这些特性用起来很顺手,t3code 就直接用的 App Router。
初始化完成后,脚手架会自动安装依赖,并且生成一个可以直接跑的通话页面。先跑一遍npm run dev,确认环境没问题,再开始改代码。这里有个小建议:初始化后的第一步,先看一遍生成的目录结构,理解每个文件是干什么的,后面自定义的时候才不容易迷路。
2.2 目录结构与每个角色的作用
t3code 的核心目录结构大概是这样:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc]/ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ ├── routers/ │ │ │ │ ├── project.ts │ │ │ │ └── task.ts │ │ │ └── trpc.ts │ │ └── db.ts │ └── trpc/ │ ├── client.tsx │ ├── react.tsx │ └── server.ts ├── .env └── package.json逐个说下关键文件的作用。
prisma/schema.prisma是数据库模型定义文件,所有表结构、关系、枚举都在这里声明。它是整个类型链路的起点。
src/server/db.ts负责创建 PrismaClient 实例。这里用了一个比较经典的单例模式,避免开发环境热更新时重复创建数据库连接。
src/server/api/trpc.ts是 tRPC 服务端的初始化文件,主要职责是创建 tRPC 实例、定义上下文类型、暴露createTRPCRouter和publicProcedure等基础构造方法。你之后写的所有 Router 都是基于它生成的。
src/server/api/root.ts把 project、task 这些业务 Router 合到一起,形成一个 appRouter 根对象,并导出AppRouter类型。客户端、服务端调用都会引用这个类型。
src/trpc/client.tsx是客户端入口,里面配置了 tRPC 的 Provider 和 API 对象的创建逻辑。你在组件里api.project.list.useQuery()这种调用,就是在用这个入口暴露出来的能力。
src/app/api/trpc/[trpc]/route.ts是 App Router 模式下 tRPC 的 HTTP 路由入口,把前端发来的 HTTP 请求转发给后端 appRouter 处理。
理解了这些文件的角色,整个项目地图也就清晰了:数据流从 Prisma 出发,经过 tRPC Router,再把类型安全地暴露到 React 组件,各个模块各司其职,链路非常干净。
2.3 环境变量与数据库配置
创建完成后,第一件事是配置数据库。create-t3-app 会把一个默认数据库地址放进.env文件里:
DATABASE_URL="file:./dev.db"本地开发可以直接用 SQLite,零配置,跑起来非常舒服。但要注意,如果项目要部署到生产环境,我还是建议把数据库换成 PostgreSQL 或 MySQL,因为 SQLite 在并发写入、数据完整性方面都有明显短板。我之前就犯过"开发用 SQLite、生产直接切 Postgres"的典型错误,schema 没变还好,一旦有类型或者索引差异,迁移的时候就会被各种报错淹没。
我的建议是,从项目一开始就在.env.example里写好 PostgreSQL 的连接串模板:
DATABASE_URL="postgresql://user:password@localhost:5432/t3code"同时在prisma/schema.prisma里把 datasource 的 provider 写成postgresql,本地用 Docker 起一个 Postgres 实例。虽然初期稍微麻烦一点点,但部署时会省掉大量踩坑时间。
3. 端到端类型安全的核心:tRPC 实战实现
tRPC 是整个 t3code 项目里最核心、也最值得好好讲的部分。它做的事情用一句话概括:让前端像调用本地函数一样调用服务端方法,同时类型全程自动推导。
3.1 Router、Procedure 的本质
理解 tRPC,先要看懂两个基本概念:Router 和 Procedure。
Router 是路由容器,相当于 REST API 里的 Controller,或者 GraphQL 里的 Query/Mutation 根对象。一个 Router 里可以注册多个 Procedure。
Procedure 是单个接口,相当于一个 API 端点。tRPC 把 DataLoader 分成query和mutation两种类型,语义上分别对应读操作和写操作。它们都接收一个 handler 函数,函数里有input(输入数据)和ctx(上下文)两个关键参数。
举个例子,t3code 里 Project 的 Router 长这样:
// src/server/api/routers/project.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const projectRouter = createTRPCRouter({ list: publicProcedure .query(async ({ ctx }) => { return ctx.db.project.findMany({ orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input( z.object({ name: z.string().min(1, "项目名称不能为空").max(50), description: z.string().max(500).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.project.create({ data: { name: input.name, description: input.description ?? null, }, }); }), });这里有两个关键点。
第一,input 是过 zod 验证的。zod 不仅是校验工具,也是类型的权威来源。只要在服务端给 Procedure 定义了 zod Schema,客户端调用时传入的参数类型就会自动被推导出来。你不写任何一行额外的类型代码,IDE 就能告诉你该传什么字段。
第二,ctx.db 是 PrismaClient 实例。tRPC 的 context 机制可以在每个请求进来时构造一个上下文对象,通常会把数据库连接、用户信息放进去,Procedure 里直接解构使用即可。
3.2 注册 Router 并导出类型
写好的 Router 要注册到根 Router 上,同时导出 AppRouter 类型,这样客户端才能获得完整的类型信息:
// src/server/api/root.ts import { createTRPCRouter } from "./trpc"; import { projectRouter } from "./routers/project"; import { taskRouter } from "./routers/task"; export const appRouter = createTRPCRouter({ project: projectRouter, task: taskRouter, }); export type AppRouter = typeof appRouter;客户端的 Provider 配置好之后,组件里就可以这样调用:
"use client"; import { api } from "~/trpc/react"; export function ProjectList() { const { data: projects, isLoading } = api.project.list.useQuery(); const createProject = api.project.create.useMutation({ onSuccess: () => { // 失效并重新拉取列表数据 utils.project.list.invalidate(); }, }); if (isLoading) return <div>加载中...</div>; return ( <ul> {projects?.map((project) => ( <li key={project.id}>{project.name}</li> ))} </ul> ); }api.project.list.useQuery()这种写法,是在调用 React Query 的 hooks,但它是由 tRPC 自动生成的。TypeScript 会精确推断出projects的类型就是Project[],这个类型来自 Prisma 的 model 定义,全程没有任何重复声明。一旦后端模型变了,前端的类型提示也会跟着变,编译阶段就能发现问题。
3.3 中间件、鉴权与错误处理
Procedure 不只是能接 zod 和 handler,还可以串联中间件。tRPC 的中间件机制有点类似 Express 的 middleware,可以在执行 handler 之前做统一的逻辑处理,比如鉴权、日志、限流。
t3code 里我加了一个非常简单的日志中间件,记录每次调用的耗时:
// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import { db } from "~/server/db"; export const createTRPCContext = async () => ({ db }); const t = initTRPC.context<typeof createTRPCContext>().create(); const loggerMiddleware = t.middleware(async ({ path, type, next }) => { const start = Date.now(); const result = await next(); const duration = Date.now() - start; console.log(`${type} ${path} 耗时 ${duration}ms`); return result; }); export const createTRPCRouter = t.router; export const publicProcedure = t.procedure.use(loggerMiddleware);另一个很实用的点是错误处理。tRPC 内置了TRPCError,可以用来抛出带语义的错误:
throw new TRPCError({ code: "NOT_FOUND", message: "项目不存在", });客户端拿到错误后,可以通过isTRPCClientError判断错误类型,也可以统一在ErrorBoundary里做展示。相比传统 REST 那种"HTTP 200 但 body 里塞了个 error 字段"的处理方式,tRPC 的语义化错误要清晰得多。
4. 数据层设计:Prisma Schema 与数据库建模
类型安全的源头在 Prisma,所以数据模型设计得好不好,直接决定整个项目后面好不好写。t3code 里我建了 Project、Task 两张核心表,模型关系比较有代表性。
4.1 建模设计
下面这个 schema 设计是精简后的版本,但保留了最核心的关系和约束:
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } enum ProjectStatus { ACTIVE ARCHIVED } model Project { id String @id @default(cuid()) name String description String? status ProjectStatus @default(ACTIVE) tasks Task[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Task { id String @id @default(cuid()) title String completed Boolean @default(false) priority Int @default(0) project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }说几个设计上的关键点。
主键用cuid()而不是自增数字。内部工具经常要做数据导入导出,自增主键容易在生产数据合并时冲突,cuid 是全球唯一的,随便合并也不会撞。
Task 的projectId用的是可空字段加onDelete: Cascade。也就是说,如果删掉一个 Project,它下面的 Task 会自动删除,避免数据库里出现孤儿数据。
DateTime字段配@default(now())和@updatedAt,这样插入和更新的时间都由数据库端负责,不依赖应用层代码。
4.2 迁移与 seed 脚本
写好 schema 之后,执行迁移命令,Prisma 会自动生成 SQL 并同步到数据库:
npx prisma migrate dev --name init这个命令会做三件事:生成一个新的迁移文件、把 SQL 应用到数据库、重新生成 Prisma Client。之后再更新 schema,只需要重复这个流程,Prisma 会智能差异对比,不会让你手动写 SQL。
开发阶段我还建议加一份 seed 脚本,方便本地测试数据。在prisma/seed.ts里用 PrismaClient 插入初始数据:
import { PrismaClient } from "@prisma/client"; const db = new PrismaClient(); async function main() { await db.project.create({ data: { name: "默认项目", tasks: { create: [ { title: "编写技术方案" }, { title: "搭建项目脚手架" }, ], }, }, }); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(async () => { await db.$disconnect(); });seed 脚本跑起来用npx tsx prisma/seed.ts。我习惯把 seed 命令写进 package.json 的 scripts 里,这样新同事拉下代码后,一条命令就能把本地环境初始化好。
4.3 在 tRPC 中优雅使用 Prisma
Prisma 和 tRPC 的组合非常顺手,但有几个细节要注意。
不要在客户端代码里导入 PrismaClient。Prisma 是服务端专用的,把它暴露到客户端不但会打包体积爆炸,还有安全隐患。t3code 的目录结构里,db.ts放在src/server/下,就是有意这样隔离。
查询时用include或select控制返回字段。Prisma 默认会返回所有标量字段,如果你的表字段很多,接口响应会变得臃肿。用select可以精确控制返回结构:
query: publicProcedure .query(async ({ ctx }) => { return ctx.db.project.findMany({ select: { id: true, name: true, _count: { select: { tasks: true } }, }, }); })还有一个值得养成的习惯:创建的 create 输入的 zod schema 里,只声明前端需要传的字段,不要直接拿 Prisma 的ProjectCreateInput当 input 类型。因为 Prisma 生成类型里带着 DeepModify 等一整套复杂工具类型,直接暴露会污染接口层,还会把你不需要开放的字段(比如updatedAt)也漏出去。
5. 常见问题与排坑实录
任何技术方案落地都不是一帆风顺,T3 技术栈也一样。这一部分把我实操中遇到频率最高的几个问题整理出来,作为速查参考。
5.1 类型不同步:改完代码发现前端提示没更新
这是新手最容易困惑的。明明在服务端改了 Router 或 Prisma Schema,前端调用处的类型提示还是旧的。
原因通常是两个。第一,Prisma Client 没有更新。修改 schema 之后必须重新执行npx prisma generate,否则@prisma/client导出的类型还是旧版本。我建议把postinstall脚本里加入prisma generate,这样每次npm install后类型都是最新的。
第二,Next.js 开发服务器的 HMR 对 tRPC 类型推导偶尔不够灵敏。如果改完代码半天没反应,直接重启npm run dev,通常一秒钟就恢复了。这种"重启大法"听起来很土,但实测下来非常管用。
| 症状 | 原因 | 解决办法 |
|---|---|---|
| 客户端调用类型没更新 | Prisma Client 未重新生成 | 执行npx prisma generate |
| 修改 Router 后类型无变化 | HMR 缓存未刷新 | 重启 dev server |
| 接口字段类型报错 | zod schema 与 Prisma 类型不一致 | 统一以 zod 为输入类型源 |
5.2 本地开发时数据库连接错误
用 PostgreSQL 的话,最常遇到Can't reach database server或者ECONNREFUSED。绝大多数情况下不是代码问题,而是本地数据库服务没启动,或者.env里的 DATABASE_URL 配错了。
排查思路很简单:先用npx prisma migrate status检查数据库连接是否正常,再看看 DATABASE_URL 里的用户名、密码、端口是否正确。还可以用psql直接连接数据库验证一下,排除权限问题。我见过不少人把localhost和127.0.0.1混用导致连接失败,如果前几个排查都没问题,可以试试把两者互换。
5.3 部署时的 Prisma 与迁移问题
把 t3code 部署到生产环境,有个很经典的坑:构建时 DATABASE_URL 没有设置成生产库地址,导致 Prisma generate 阶段失败或者拿到错误数据库。
解决方式是在部署平台环境变量里配置正确的 DATABASE_URL,同时保证执行顺序是先prisma migrate deploy再prisma generate再next build。pipeline 里这个顺序很关键,因为 migrate deploy 会按版本顺序执行遗漏的迁移,而 generate 会生成与生产数据库匹配的 Client。
另外一个容易忽略的是权限问题。生产数据库用户最好只给这个应用需要的库权限,不要给超级管理员权限,减少误操作风险。
5.4 实战中的小技巧汇总
除了上面几个问题,再分享几个我实际开发中很受用的小技巧。
tRPC 的 React Query 集成非常方便,利用useQuery的参数可以做很多高级操作。比如关掉冗长请求的自动重试:
const { data } = api.project.list.useQuery(undefined, { retry: 1, staleTime: 1000 * 60, });用utils可以实现跨模块的认证规则、目录清除:utils.project.list.invalidate()会在某个 mutation 成功后把对应的列表查询标记为失效,下次渲染时自动重新拉取。这一套缓存失效机制在写内部工具时几乎每天都要用,强烈建议熟练掌握。
还有,改造某个列表接口时,如果改动比较大,可以先创建一个带前缀的新 Procedure,两端验证没问题后再把旧的删掉。尽量不要直接在原有 Procedure 上大改 schema 和返回结构,内部工具虽然迭代快,但接口的语义稳定性依然重要,贸然修改调用方会在不知不觉中被打脸。
6. 项目命名、扩展方向与个人经验汇总
回到"t3code"这个名字本身,其实没什么高深含义,就是 T3 技术栈加上 code 的组合,既点明了技术路线,也提醒我别把项目带偏成过度设计的重框架。它现在承担的角色是内部任务管理,但更大的价值是验证了端到端类型安全在全栈开发中的可行性。
从扩展方向来说,我接下来的计划是给它加上简单的用户系统和权限控制。tRPC 的中间件机制让权限控制变得非常直接:在 createTRPCContext 里解析当前登录用户,然后定义一个protectedProcedure,在中间件里校验用户,没登录就直接抛 TRPCError。这样只有显式使用 protectedProcedure 的接口才要求登录,publicProcedure 保持原样,权限边界一目了然。
再往后可以接入文件上传、定时任务、甚至把部分 Router 单独抽成一个微服务。但由于 tRPC 和类型推导天然耦合,拆服务时要主动处理好类型共享的问题,可以考虑用 pnpm workspace 把类型定义抽成公共包。
我个人实操下来最大的体会是:T3 技术栈真正厉害的并不是它包含哪几个框架,而是它把"类型安全"从后端一路贯彻到了浏览器端。这种安全带来的体验是实打实的改代码速度提升:前端再也不用猜接口字段,删除一个字段时编译器会告诉你哪个组件受影响,改数据库模型时连锁的类型报错会主动把你拉回来。这种约束刚开始会觉得不适应,等习惯之后反而会依赖上它。
最后再分享一个小技巧:如果你也想用 T3 技术栈练手,建议不要只做一个 To-do 这种抄来抄去的 demo,而是选一个自己有真实需求的小工具来做。只有真实的需求才会逼你处理嵌套查询、缓存失效、权限控制这些真正有挑战的细节。上手路线就是先把 tRPC 官方文档的 Hello World 跑通,然后照着 t3code 的目录结构,把一个简单的 CRUD 模块完整走一遍,你自然会发现 TypeScript、Prisma、tRPC 之间那种奇妙的正反馈。