干我们这行的,每天都被新框架、新名词轮番轰炸,但有些技术组合,你在看到它的第一眼就能感觉到“这次不一样”。t3code这个项目,是我在去年下半年正式立项的一个全栈工程实践,核心思路就八个字:端到端类型安全,全员 TypeScript。整套技术栈选的是现在圈内讨论度很高的 T3 Stack——Next.js 做应用框架,TypeScript 打底,Tailwind CSS 负责样式,tRPC 接替传统 REST API 的角色,再用 Prisma 统一管数据库模型。
如果你正打算从“前后端分离、接口文档对接、联调半天”的老路里跳出来,或者想在中小型项目里把后端和前端的协作成本砍掉一截,这个项目肯定值得你花几分钟看看。它不是学院派理论堆出来的 demo,而是我踩了将近一个月的坑、在真实业务页面上跑通之后沉淀下来的实操经验。下面我会把选型逻辑、初始化过程、核心链路搭建,以及那些文档里不会写的排查技巧,全部拆开讲清楚。
1. 项目整体画像:为什么说T3是一套“端到端类型安全”的解法
1.1 先聊清楚 t3code 到底想解决什么问题
很多业务项目在做到中后期,最大的痛点其实不在功能多难写,而在于前后端之间的“翻译损耗”。后端写了个接口,返回字段命名不规范,前端拿到的数据和联调文档对不上,页面一渲染就报错;改了个参数类型,前端没人知道,运行时才发现请求挂了。这类问题多了以后,团队的时间大量花在“对齐预期”上,而不是花在业务本身。
t3code立项的时候,我给自己定了一个非常硬性的目标:从数据库表结构,到 API 调用,再到页面渲染,中间经过的每一层数据类型都必须由编译器兜底。也就是说,后端模型一旦改了,前端编辑器里立刻就能看到红色波浪线,而不是等浏览器控制台报错。这个目标单靠某一种技术做不到,必须是一套互相咬合的组合拳,这就是 T3 技术栈存在的意义。
T3 这个名字来自社区里非常活跃的开发者 Theo,他在自己的实践中总结出一套“简单、可维护、类型安全”的全栈方案,核心成员是 Next.js、TypeScript、Tailwind CSS、tRPC,另外一般还会带上 Prisma。这套组合不是为了炫技,而是把“类型安全”从单一语言层面提升到了**跨层(跨前后端)**的层面。
1.2 四个核心成员各自的角色定位
先分别说下每个成员在这个体系里干的事,以及我为什么把它们拼在一起。
Next.js 负责“壳”和“路由”。它同时承担页面渲染、API路由、服务端组件等职责,而且对 TypeScript 支持是原生级别的。在 T3 体系里,Next.js 不只是个 React 框架,它还是连接前端页面和后端逻辑的黏合剂。选择 App Router 还是 Pages Router 会直接影响后续代码组织方式,这一点我在 2.3 节里会详细对比。
TypeScript 是贯穿全场的“类型契约”。在前端,它约束组件 props 和状态;在服务端,它约束请求入参和返回出参。真正让它发挥威力的,是它作为“参数”被传递到 tRPC 和 Prisma 的泛型里,让类型检查可以跨越进程边界。
Tailwind CSS 负责“界面表达”。它可能乍一看跟“类型安全”没啥关系,但它的核心价值其实是约束——你很难写出奇奇怪怪的内联样式或难以维护的 CSS 类名。配合 TypeScript 的类名提示,样式这块也能做到一定程度的“自动检查”。我在实际项目里还配了一套设计 token(颜色、间距、字号都从配置文件里取),团队新人写页面时基本不用猜设计稿数值。
**tRPC 是这套体系里最“灵魂”的一层。**它把后端的函数调用直接暴露给前端,前端调用一个异步方法就像调用一个本地函数一样,入参、出参全部自动推断。以前写 REST API 时要手动维护 OpenAPI 规范、生成类型定义、处理各种序列化问题,在 tRPC 里这些全部消失。
Prisma 是数据层的最后一块拼图。它用 schema 文件定义数据模型,然后自动生成 TypeScript 类型,数据库表结构一旦修改,生成的 client 类型同步更新。这样数据库层面的变化能一路传导到最前端的组件里。
1.3 与传统前后端分离方案的对比
很多人会问:我现在的项目是 React 前端 + Spring Boot/Node 后端 + 手写接口文档,为什么非要折腾这套?
我拿一个典型的“用户信息修改”功能来对比。传统方案下,这个功能涉及的文件和环节大概有:前端表单组件、前端请求封装、后端路由、后端控制器、后端服务层、后端数据模型、接口文档、类型定义手工拷贝。一次改动,要同步修改八处,漏一处就是线上事故。
在t3code里,同样一个功能涉及的是:前端表单组件、tRPC 路由里的一个 mutation 方法、Prisma 模型。前端调用updateProfile.mutateAsync(...)时,传入字段、返回结构都已经被编译器盯死。更关键的是,少掉的不仅是文件数量,而是一整类“人肉同步错误”。我把两种方式的差异整理成了一张表:
| 对比维度 | 传统前后端分离 | t3code(T3体系) |
|---|---|---|
| 接口契约 | 人工维护文档/OpenAPI | 由代码类型自动生成 |
| 类型同步 | 手工拷贝、易失效 | 编译期自动推导 |
| 联调成本 | 依赖 Postman/Swagger 反复比对 | 几乎为零,类型即契约 |
| 改动响应链路 | 后端→文档→前端→联调 | 改后端,前端马上报错提醒 |
| 新人上手成本 | 需要理解两套技术栈语义 | 一套 TypeScript 走到底 |
当然,纯 tRPC 方案也有不适合的场景,比如要实现完全开放的第三方公开 API,或者需要严格的接口版本管理,那些场景下 REST/GraphQL 仍然有不可替代的位置。T3 的定位很明确:面向“内部业务系统、全栈应用、快速迭代产品”,在这个区域内,它的效率优势非常突出。
2. 环境安装与项目初始化:从零拉起一个完整的T3工程
2.1 创建项目:create-t3-app 的交互选项逐项拆解
T3 官方提供了一个脚手架命令create-t3-app,我建议所有人都从它开始,不要手动拼装。手动搭会遇到一个很尴尬的问题:Next.js、tRPC、Prisma 三者之间的初始化顺序和版本兼容性,在社区版本迭代频繁的时期,极易出现依赖冲突,脚手架把这些坑替你处理好了。
执行命令很简单:
npm create t3-app@latest my-t3code接下来 CLI 会问你一串交互问题,我把我实际选择的配置列出来,并解释每一项背后的考虑。
项目名称和目录直接按规范来,npm 包名不支持大写,所以项目名里尽量用短横线分隔。
TypeScript or JavaScript:这个不用纠结,选 TypeScript。T3 的整套设计都建立在类型系统之上,选 JavaScript 等于把核心武器扔了。
Next.js 的 App Router 还是 Pages Router:我选的是 App Router。App Router 是 Next.js 当前主推的目录结构,支持 Server Components、流式渲染、更细粒度的缓存控制。虽然 Pages Router 更成熟、相关教程更多,但新项目没有历史包袱,没必要在旧体系里起步。
Tailwind CSS:选上。它的 JIT 编译模式让开发体验很好,配合 Prettier 插件自动排序类名,样式代码非常整齐。
tRPC:选上。没有它,这套方案就不成立了。它会在项目里生成一个完整的 API 层骨架,包括 Router 定义、Context 创建、React Query 集成。
Prisma:选上。T3 官方推荐用 Prisma 作为 ORM,因为它的 schema 声明方式跟 TypeScript 的类型哲学天然契合。如果你有别的数据库层偏好,可以不走 Prisma,但我之后讲的数据链路会围绕它展开。
Next Auth:这个看情况。如果你的项目需要邮件密码、OAuth(GitHub、Google 之类)登录,建议选上;如果是一个纯内部工具,不需要复杂鉴权体系,可以先跳过。t3code里我选上了 NextAuth,因为后续要接入团队账号体系。
ESLint / Prettier:必须选上。这俩不是可选项,是工程质量的底线。T3 脚手架生成的 ESLint 配置里带了不少 Next.js 和 TypeScript 的推荐规则,能提前拦截很多低级错误。
选完这些之后,CLI 会帮你装依赖并生成基础文件。这一步如果网络状况不好,可能会卡在某些依赖包上,常见的处理方式是切到镜像源,或者删掉node_modules和 lock 文件重新安装。我用的是 npm 自带的缓存,第一次跑完整构建大概花了三分钟,属于正常水平。
2.2 初始化后的目录结构:每一层放什么
脚手架完成后,你会看到这样一个核心目录结构:
my-t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── trpc/ │ │ │ └── [trpc]/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ └── trpc.ts │ │ └── auth.ts │ ├── trpc/ │ │ ├── server.ts │ │ └── react.tsx │ └── env.js ├── next.config.js ├── tailwind.config.ts └── tsconfig.json很多第一次接触 T3 的人会被这一堆目录吓到,觉得文件特别多。实际上每个文件职责非常清楚:
src/app是页面层,Next.js App Router 的页面组件都在这里;src/server/api/routers是服务端的 tRPC 路由定义,相当于传统项目里的 Controller + Service;src/server/api/trpc.ts是 tRPC 的初始化文件,负责创建 caller、中间件、上下文;src/trpc/server.ts是服务端调用 tRPC 的统一入口,供 Server Component 和 Server Action 使用;src/trpc/react.tsx是前端 React 侧的 Provider 封装,把 tRPC client 和 React Query 绑在一起;prisma/schema.prisma是数据库模型定义文件。
这个结构的核心原则是**“一切围绕数据流分层”**:页面不直接操作数据库,页面通过 tRPC 调用服务端函数,服务端函数通过 Prisma 访问数据库。每层之间的边界非常清晰,debug 的时候能快速定位问题。
我还额外加了几个目录:src/server/services放业务逻辑(比如计算用户积分、处理订单状态机),src/types放跨层共享的纯类型定义,src/config放业务配置项。这么做是因为如果业务逻辑都堆在 router 里,router 文件会迅速膨胀到几千行,维护性很差。经验之谈:tRPC 的 router 应该保持薄,业务逻辑下沉到 service。
2.3 环境变量与环境校验:T3 的一个隐藏优点
T3 脚手架会生成一个env.js,配合zod做环境变量的运行时校验。这是 T3 体系里很不起眼但极其有价值的设计。
传统 Node 项目里,环境变量用错(比如把DATABASE_URL写成了DATA_BASE_URL)通常要到运行时连数据库才报错,甚至有的错误会在生产环境静默发生。T3 的做法是:在服务启动前用 zod 定义的 schema 校验所有环境变量,缺失或格式不对会直接抛出带有清晰消息的错误。
import { z } from "zod"; export const env = createEnv({ server: { DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(32), }, client: { NEXT_PUBLIC_APP_NAME: z.string().optional(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME, }, });对于NEXTAUTH_SECRET,zod 的.min(32)不是拍脑袋定的,NextAuth 官方要求 secret 至少 32 个字符,否则会有安全隐患。这种“把校验规则写进环境变量声明”的思路,比单纯依赖.env.example文件要可靠得多——至少错误发生在构建期,而不是线上。
3. 核心链路实操:从数据库模型到页面渲染的完整管道
这一节是全文的核心。我会用一个真实的业务场景——“团队项目任务管理”的某个功能片段来串起整条链路,从 Prisma 模型开始,到 tRPC 路由,再到 React 组件消费,完整展示这套类型安全管道怎么运转。
3.1 数据模型层:用 Prisma Schema 定下“数据宪法”
我先在prisma/schema.prisma里定义两张表,一张是用户扩展信息表,一张是任务表。之所以不直接用 NextAuth 默认的 User 模型,是因为业务字段(比如昵称、部门)不该污染认证模型,通过1:1关联扩展更干净。
model UserProfile { id String @id @default(cuid()) userId String @unique displayName String department String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Task { id String @id @default(cuid()) title String description String? status TaskStatus @default(TODO) assigneeId String? dueDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt assignee UserProfile? @relation(fields: [assigneeId], references: [id]) } enum TaskStatus { TODO IN_PROGRESS DONE }定义好模型后执行迁移命令,把 schema 同步到数据库:
npx prisma migrate dev --name init_task_table这条命令做三件事:生成迁移 SQL 文件、执行迁移、重新生成 Prisma Client。重点来了:prisma generate会在node_modules/.prisma/client下生成完整的 TypeScript 类型,之后你写任何查询代码时,IDE 都能提示出所有表字段,并且返回结果类型完全受控。
我踩过的坑:有次我改了 schema 里的字段名,但忘了执行迁移,结果前端页面还在用旧字段,类型检查没报错(因为 Prisma Client 还是旧的),运行时却拿到了undefined。后来我养成了一个习惯:改 schema 之后立刻跑npx prisma migrate dev,或者至少在编辑器里用npx prisma generate刷新类型。这个“类型未刷新”的问题在团队协作时特别容易发生,建议把prisma generate加到项目的postinstall脚本里。
3.2 服务端路由层:定义 tRPC Procedure 作为业务入口
基础数据结构定了,接下来写 tRPC 路由。t3code里的 tRPC 路由分两层:一个根路由appRouter,下面挂各种子路由(如taskRouter、userRouter)。子路由里再定义query(查询)和mutation(变更)。
先看src/server/api/routers/task.ts:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; const createTaskSchema = z.object({ title: z.string().min(1, "任务标题不能为空").max(100), description: z.string().max(2000).optional(), assigneeId: z.string().cuid().optional(), dueDate: z.date().optional(), }); export const taskRouter = createTRPCRouter({ list: protectedProcedure .query(async ({ ctx }) => { return ctx.db.task.findMany({ orderBy: { createdAt: "desc" }, include: { assignee: true }, }); }), create: protectedProcedure .input(createTaskSchema) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: input, }); }), });这里有几个设计细节值得单独说明。
第一,protectedProcedure和publicProcedure的区别。protectedProcedure会先执行会话校验,未登录用户的请求直接拒绝,不会进入业务函数体。这是 T3 官方推荐的做法:涉及用户数据和写操作的路由,一律用保护流程;只有公开信息(比如公告页)才用公开流程。
**第二,输入校验直接用 zod schema,不需要单独的校验层。**zod 的min(1)、max(100)这些约束会在运行时生效,同时它的类型可以反推出input的类型。这样“校验逻辑”和“类型定义”合二为一,世界上不会存在“代码里的校验规则”和“文档里的校验规则”不一致的情况。
**第三,prisma 的类型可以帮助你“写对”查询。**比如include: { assignee: true }这一句,如果你写错了关联名,编译器立刻报错。数据库层面的外键关系,被完整地投影到了 TypeScript 类型里。
我额外提一下list接口的返回结构。因为包含了assignee关联,返回数组的每一项都是Task & { assignee: UserProfile | null }这个类型。前端拿到这个类型后,渲染task.assignee.displayName时不会出现“作家本人都不确定这个字段存在”的尴尬。
3.3 前端消费层:在 React 组件里“直接调用”后端函数
现在来到最让人兴奋的一层。前端页面组件通过src/trpc/react.tsx提供的api对象,直接调用服务端 procedure。
"use client"; import { api } from "~/trpc/react"; export function TaskList() { const { data: tasks, isLoading, error } = api.task.list.useQuery(); if (isLoading) return <div className="p-4 text-gray-500">加载中...</div>; if (error) return <div className="p-4 text-red-500">加载失败:{error.message}</div>; return ( <ul className="divide-y divide-gray-200"> {tasks?.map((task) => ( <li key={task.id} className="py-3 flex items-center justify-between"> <div> <p className="font-medium">{task.title}</p> <p className="text-sm text-gray-500"> 负责人:{task.assignee?.displayName ?? "未分配"} </p> </div> <span className="text-xs text-gray-400">{task.status}</span> </li> ))} </ul> ); }这个组件的写法看起来跟用 React Query 请求 REST 接口一模一样,但背后完全不同。api.task.list.useQuery()的入参类型和出参类型,全部由 tRPC Router 自动推断。后端如果把list的返回结构改了(比如去掉assignee关联),前端这里的task.assignee?.displayName会在编译期直接报错。
这意味着什么?意味着“联调”这个环节基本上被消灭了。前端不需要问后端“这个接口返回什么字段”,因为编辑器已经把答案告诉他了;后端也不需要问前端“你传的字段名是什么”,因为前端调用时传的参数已经被强类型约束。
t3code里我还有一个更复杂的例子:创建任务的表单组件。这里把 zod schema 直接复用到前端做校验,一套规则两端使用,连校验措辞都保持一致:
"use client"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { createTaskSchema } from "~/shared/schemas/task"; import { api } from "~/trpc/react"; export function CreateTaskForm() { const utils = api.useUtils(); const createTask = api.task.create.useMutation({ onSuccess: async () => { await utils.task.list.invalidate(); }, }); const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(createTaskSchema), }); return ( <form onSubmit={handleSubmit((data) => { createTask.mutate({ ...data, dueDate: data.dueDate ? new Date(data.dueDate) : undefined, }); })} className="space-y-4" > <input {...register("title")} placeholder="任务标题" className="..." /> {errors.title && <p className="text-red-500">{errors.title.message}</p>} <input {...register("assigneeId")} placeholder="负责人ID" className="..." /> <button type="submit" disabled={createTask.isPending} className="..."> {createTask.isPending ? "提交中..." : "创建任务"} </button> </form> ); }useMutation里的onSuccess调用utils.task.list.invalidate(),作用是让 React Query 重新拉取任务列表。这就是 T3 体系里“数据变更后刷新页面”的标准动作:不需要手动管理缓存失效,一个 invalidate 调用解决。
这里的~/shared/schemas/task是我在项目里专门抽出来的一个模块。既然 zod schema 能同时服务前后端,就没必要在 server 和 client 各写一份。把它放在一个共享目录里,天然规避“两套校验规则漂移”的问题。这是t3code里我做的一个关键架构决策,强烈建议大家抄作业。
3.4 全链路数据流演示:创建任务到页面刷新
把上面的代码串起来,t3code里“创建一个任务”的完整流程是:
- 用户在前端表单填写标题、选择负责人;
- React Hook Form 通过
zodResolver校验输入,不合法直接在表单下方显示错误; - 合法数据作为
input传给api.task.create.useMutation().mutate(); - tRPC client 把入参序列化后发送到 Next.js API 路由(
/api/trpc/*); - 服务端先执行
protectedProcedure的会话校验; - 通过后,zod 在 server 端再次校验入参(双端校验,防止绕过前端直接请求);
- 校验通过后,调用
ctx.db.task.create({ data: input })写入数据库; - 返回创建后的完整任务对象给前端;
onSuccess回调触发invalidate(),任务列表自动刷新。
整个过程我从“用户点击提交”到“列表出现新任务”,肉眼感知不到任何中间环节,因为它本来就是一次完整的函数调用。而这种“直觉上的简单”,恰恰是背后每一层都做了正确约束的结果。
4. 常见问题与排查技巧实录
4.1 Prisma 类型不同步:改了 schema 但前端没反应
这是 T3 项目里出现频率最高的问题。场景一般是:你改了schema.prisma的字段,保存后去写代码,发现 IDE 还是提示旧字段,或者运行时报“Column not found”。
原因大概率不是代码错了,而是 Prisma Client 没有重新生成。Prisma 的特点是你的代码引用的类型来自自动生成的 Client,而不是 schema 文件本身。生成动作需要手动执行:
npx prisma generate如果改了表结构,还要先迁移:
npx prisma migrate dev --name update_task_assignee排查建议:
- 确认
node_modules/.prisma/client的生成时间是否晚于 schema 修改时间; - 确认你用的是
@prisma/client而不是prisma(后者是 CLI 工具包,不包含运行时 client); - 在 CI/CD 流程里,构建前一定要执行
prisma generate,我甚至建议在package.json的postinstall里加一句,省得每个人本地都忘。
4.2 tRPC 类型推导“卡住”:过程型数据拿不到类型
新手很容易在inferRouterOutputs或者跨路由引用类型时遇到 TS 报错很多的情况。比如你想在前端取task.list的返回类型:
import type { RouterOutputs } from "~/trpc/react"; type Task = RouterOutputs["task"]["list"][number];这个写法理论上没问题,但如果你在src/trpc/react.tsx里没有正确导出RouterOutputs,就会报“类型空间不存在该属性”。
T3 脚手架默认生成的src/trpc/react.tsx里,有一行:
export type RouterOutputs = ReturnType<typeof appRouter.createCaller>;如果你手动改过文件的目录结构或删除了某些导出,这个类型链就会断掉。排查时先看appRouter的导出路径是否正确,再看createTRPCContext的返回类型。
4.3 Next.js 部署时常见的“普适性迷思”:Client/Server 边界
App Router 模式下,最容易踩的坑是:你随手在组件里 import 了一个函数,结果这个函数依赖 Node.js 环境(比如prisma),组件又被标记成了"use client",构建直接失败。
报错信息通常会提示 “PrismaClient is not configured to run in the browser” 之类。出现这种问题,明确一个原则:所有涉及数据库、涉及服务端上下文的代码,绝对不能出现在客户端组件里。解决方式:
- 如果这个组件只是为了展示数据,改成 Server Component(去掉
"use client"),在服务端调用 tRPC caller; - 如果组件必须交互(有
useState、事件处理),把数据获取逻辑留给父级 Server Component,通过 props 传入; - 如果你需要在客户端调用 tRPC,用
src/trpc/react里的 hooks,而不是直接 import server 代码。
t3code里我遇到过一次很隐蔽的问题:一个“用户详情弹窗”组件在弹窗打开时才请求数据,我把它写成了客户端组件,又在里面直接用了prisma查询,结果构建成功但浏览器里疯狂报错。后来改成让弹窗组件只接收userId,数据请求通过 tRPC hook 完成,问题才根治。
4.4 冷门但很实用的几个经验
这里分享几个我在t3code里亲测有用的细节,属于看书看不来的那类。
第一,tRPC 的错误信息要做二次封装。默认的 TRPCError 会把新增字段、校验信息都抛出来,生产环境直接暴露内部表名字段并不合适。我在trpc.ts里加了一层错误映射中间件,把ZodError、PrismaClientKnownRequestError统一转成对外安全的错误消息,同时记录一份完整的服务端日志。上线之后排查错误效率高很多。
第二,用useUtils().myRouter.invalidate()做局部缓存刷新。很多初学者喜欢刷新整个页面来同步数据,在 T3 里没必要。React Query 的缓存是键值库,invalidate可以精准到某个 procedure。这个习惯养成后,页面切换的感知速度会明显提升。
第三,把 zod schema 的“正则校验”用在业务敏感字段上。比如手机号、工号,我都在 schema 里定义好合法格式,前后端共用。这样不仅减少重复代码,还能保证数据在入库存根之前就符合业务规则。配合 TypeScript 的 literal 类型,还能把枚举状态(如TaskStatus)在前后端一起约束住。
第四,本地开发时给 tRPC 开启日志。tRPC 提供了loggerLink,我在 server 端的 tRPC 初始化时默认开启 dev 日志,能看到每一次 procedure 调用的耗时、入参大小、错误堆栈。这个小改动对定位性能问题帮助极大,比事后查监控舒服得多。
5. 工具链选型与团队协作中的几个补充心得
5.1 状态管理:为什么t3code里没有 Redux 和 Zustand
刚上手 T3 的人经常会问:状态管理用什么?Redux 还是 Zustand?
我的回答是:本地 UI 状态用 React 自带的 hooks 就够了,服务端状态交给 React Query,全局共享状态基本用不上第三方库。
在 T3 架构里,服务端状态(用户信息、任务列表、设置项)全部由 React Query 缓存管理,你不需要把它再复制一份进 Redux store。Redux 擅长的是“复杂的前端状态流转”,但大部分业务页面的状态需求其实是“从服务器拿数据、展示、提交变更”,这正好是 React Query + tRPC 的主场。
t3code里只有一处用了 Zustand:一个记录“当前侧边栏是否折叠”的 UI 状态。这种跟服务端无关、跟路由无关的轻量状态,用一个小 store 就够了。这算是我的选型原则:先问这个状态从哪来,再决定它放哪。
5.2 数据库策略:SQLite 起步还是直接上 PostgreSQL
T3 脚手架默认支持 SQLite,这对本地开发极其友好,零配置、跑起来就能用。但生产环境如果业务会增长,我建议直接用 PostgreSQL。
t3code的演进路径是从 SQLite 起步做原型验证,切到 PostgreSQL 时只改了一行DATABASE_URL,因为 Prisma 屏蔽了大部分数据库方言差异。当然,要小心索引、全文检索这类 SQLite 和 PostgreSQL 差异较大的功能,在前期设计时就要有意识避开。
5.3 代码组织上的“团队默契”
T3 给了你很好的骨架,但真正的工程化还得靠约定。我在团队里推行了三条简单规则:
- 所有 zod schema 统一放在
src/shared/schemas,禁止散落在组件文件里; - 所有业务逻辑写在
src/server/services,router 里只做参数校验和调用转发; - 所有跨组件复用的类型,在
src/types里显式声明,不要靠编辑器自动推断链去读。
这些规则跟框架无关,但配合 T3 的类型体系,能让整个项目保持“改了 A 就会立刻提示你改 B”的连锁反应。新人在这种环境里写代码,犯错的成本被压到很低,因为类型系统和大目录约束已经把最容易出错的路径堵死了。
最后再说说我自己这段时间的真实感受。从传统接口联调切到 tRPC + Prisma 这套全栈类型安全方案后,我最大的变化是:以前最害怕的“改接口”,现在反而是最省心的事。因为改一个返回字段时,编译器会告诉我所有受影响的地方,而不是等测试同学来报告。这也是我把这个项目命名为t3code的原因——用 T3 的方式写代码,本质上是用类型系统把一部分测试工作前移,让开发者在“写代码”而不是“猜代码”。
如果你也在维护一个快速迭代、内部业务为主的全栈项目,我建议你拿一个小模块先试点,感受一下“类型即契约”的开发节奏。等你在终端里看到那串红色波浪线替自己挡住了线上 bug,你就明白这套体系为什么值得投入了。