1. 项目拆解:t3code 到底在解决什么问题?
很多入行两三年的朋友经常问我一个问题:手头同时管着好几个项目,前端要用 React 还是 Vue,后端要不要单独拆服务,API 层用 REST 还是 GraphQL,样式方案选 Tailwind 还是 CSS Modules……每次选型都要纠结好几天,最后往往还是一套老组合打天下。说白了,代码本身不是瓶颈,技术栈的混乱和项目之间的重复劳动才是。
t3code 这个名字第一次出现时,我下意识把它拆成“t3”和“code”两段来看。t3 在开发者圈子里最有名的指代是 T3 Stack——一套以TypeScript 为核心、强调端到端类型安全的全栈技术栈组合,典型的成员是 Next.js、Tailwind CSS、tRPC。如果你在 GitHub 上搜过 create-t3-app,对这个组合应该不陌生。而“code”自然是代码本身。两个字合在一起,我的第一反应是:这是一个围绕 T3 技术栈做代码规范化、模板化和效率工具化的项目,目标就是让一套好用的技术栈反复复用,减少选型和配置的重复成本。
那它到底能做什么?我理解有四个层面的价值:
- 统一项目脚手架:把 Next.js、TypeScript、Tailwind、tRPC 这些常用模块提前配好,新项目直接从模板起
- 规范代码风格与提交规范:ESLint、Prettier、Husky、lint-staged 一把梭,避免团队里一百个 PR 有一百种风格
- 提供常用业务模块的可复制代码片段:登录鉴权、CRUD 接口、数据校验这些不用每次从头写
- 打通前后端类型安全:tRPC 让 API 的输入输出类型直接从后端推断到前端,省掉手写 interface 的重复劳动
说句实在话,如果你是个独立开发者,或者在一支十人以内的小团队里干活,t3code 这类东西能帮你省掉 30% 以上的“搬砖时间”。因为选型纠结没了,脚手架配置没了,重复的业务代码也有底稿可抄。我从零到一搭建过一个中型全栈项目,光是把环境配到能舒服地写业务代码,就花了两三天。如果能有一套成熟模板直接用,这两三天完全可以用来把核心功能打磨得更好。
当然,t3code 也不只是给新手用的“开箱即用全家桶”。对老手来说,它真正的价值在于沉淀。每个团队都有自己的一套最佳实践,通过模板和代码片段的方式固定下来,新同事来了不用从头摸索,老同事写代码也有统一的底层规范。这种“可复用的工程资产”,比任何文档都来得实在。
2. 技术选型解析:为什么是这几个成员组合
2.1 核心栈:TypeScript 与前端的类型安全感
先聊聊为什么 t3code 这类方案会把 TypeScript 放在底座位置。我从 2017 年前后开始用 TS,那时候还只是在 Angular 项目里被“强制”使用,后来转到 React 生态后,才真正意识到类型系统带来的收益有多么大。
举一个最直观的场景:你写了一个getUserById(id: string): Promise<User>的函数,如果前后端共用同一套类型定义,前端调用时写错字段名,编译期就直接报红,而不是等接口返回后才发现user.name是 undefined。这种“错误前置”的能力,对工程质量的提升非常明显。TS 的另一个隐性好处是重构安全感。你改一个类型的字段名,编辑器会自动把所有引用点标出来,不用靠 Ctrl+F 全局搜索。
在 t3code 里,TypeScript 不仅是语言选择,更是一种“连接件”。后面要说的 tRPC,就是靠 TS 的类型推导能力才能实现端到端类型安全。如果底层用的是纯 JavaScript,这套方案的优势会直接折掉一半。
2.2 另一端:Next.js 与 Tailwind 的组合效应
Next.js 作为 React 全栈框架,这几年几乎成了脚手架标配。它把路由、SSR、API 路由、打包配置全部集成好,你不需要自己拼 Webpack 或 Vite 配置。对 t3code 这类模板项目而言,Next.js 意味着**“一个项目同时搞定前端界面和后端 API 路由”**,让 tRPC 的接入变得更顺畅。
Tailwind CSS 则是另一个被低估的省时利器。很多人觉得 Tailwind 就是把样式写在类名里,没什么大不了。但真正用过之后,你会发现它对开发节奏的影响是深层次的:不用再绞尽脑汁给一个临时布局命名 class,不用反复横跳 HTML 和 CSS 文件,视觉调整几乎实时可见。在 t3code 的场景里,Tailwind 配合 shadcn/ui 这类基于 Tailwind 的组件库,可以大大加速页面搭建。
不过这里我要做个诚实的技术判断:Tailwind 不是银弹。如果你做的是复杂的数据可视化大屏,或者需要高度定制化的设计系统,纯 Tailwind 写起来会非常痛苦。但在普通的后台管理系统、B 端产品页面上,它的效率优势非常明显。t3code 选它作为默认样式方案,是符合大多数项目实际需求的。
2.3 连接前后端的 tRPC:为什么比 REST 更适合全栈项目
这是 t3code 里我认为最有技术含量的一环。tRPC 是一个轻量级 RPC 框架,核心卖点就是不需要写 API 文档、不需要手写接口类型,函数即接口。
传统 REST 模式下的开发流程是这样的:后端写接口 -> 用 Swagger/Postman 调通 -> 前端根据文档手写类型定义 -> 联调 -> 类型对不上 -> 改文档改代码。这套流程里最耗时的其实是“信息同步”。后端改了字段名,前端不知道,等联调才发现;前端需要的嵌套结构,后端给了扁平结构,最后还是后端迁就前端改。
而 tRPC 的工作模式是:你在后端定义一个函数:
// server/routers/user.ts import { z } from "zod"; import { publicProcedure, router } from "../trpc"; export const userRouter = router({ getById: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ input }) => { const user = await db.user.findUnique({ where: { id: input.id }, }); return user; }), });前端的调用代码长这样:
// client/components/UserProfile.tsx import { trpc } from "../utils/trpc"; export default function UserProfile({ userId }: { userId: string }) { const { data, isLoading } = trpc.user.getById.useQuery({ id: userId }); if (isLoading) return <div>加载中...</div>; return <div>{data?.name}</div>; }注意看,input的类型校验用的是 zod 的 schema,返回值的类型直接从prisma.user.findUnique的函数签名推导出来。前端data.name会不会报错,编译期就能知道,根本没有“联调”这个环节。这种开发体验带来的效率提升是几何级的,前提是你做的是前后端一体的项目。
当然,如果项目需要开放 API 给第三方,或者未来一定会横向拆分服务,tRPC 就不太合适了。tRPC 的服务端和客户端是强耦合的,它不是为公网开放 API 设计的。这里的取舍很重要:t3code 适合的是“内部全栈应用”,不是“开放平台”。
3. 实操指南:五个步骤从零搭建一个 t3code 风格的项目
3.1 第一步:初始化项目与目录结构规划
我试过用 create-t3-app 的脚手架直接起步,也试过手动搭建,结论是:如果你第一次接触这套技术栈,建议直接用脚手架,省时间;如果你已经有一定经验,手动搭能让每一步都踩得明白。
这里给出一套我常用的目录结构:
src/ app/ # Next.js App Router 页面 api/ trpc/ # tRPC 的 Next.js 路由入口 (pages)/ dashboard/ server/ db/ # Prisma schema 与客户端实例 routers/ # 按业务域划分的 tRPC router user.ts post.ts _app.ts # 汇总所有 router 的根文件 trpc.ts # tRPC 的 router/procedure 工厂 utils/ trpc.ts # 客户端 tRPC 封装 auth.ts styles/ public/ prisma/ schema.prisma package.json tsconfig.json next.config.mjs tailwind.config.ts这个结构的核心逻辑是:页面与 API 路由分离、业务代码按域聚合。routers 文件夹里的每个文件对应一个业务域,比如 user、post、order,跟数据库表结构一一对应。这样当项目规模上来后,改动某个业务模块,只需要看对应的 router 文件,不用在一堆全局代码里翻找。
trpc.ts文件是 tRPC 的“工厂”,它导出两个关键对象:
// src/server/trpc.ts import { initTRPC } from "@trpc/server"; import { z } from "zod"; const t = initTRPC.create({ isServer: true }); export const router = t.router; export const publicProcedure = t.procedure;如果你要做登录鉴权,还需要在这里定义一个protectedProcedure,它会在执行 query/mutation 前校验 session,通过才放行。这一步我会在后面的鉴权模块里展开讲。
3.2 第二步:数据库接入与 Prisma 数据建模
数据库层面,t3code 的标准选择是 Prisma。Prisma 的强项在于schema 即文档,你写的schema.prisma文件既是数据库表结构的定义,也是 TypeScript 类型生成的源。
一个简单的数据模型示例:
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(uuid()) @db.Uuid email String @unique name String? posts Post[] createdAt DateTime @default(now()) } model Post { id String @id @default(uuid()) @db.Uuid title String content String authorId String @db.Uuid author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) }写完 schema 后执行两条关键命令:
# 同步数据库结构 npx prisma db push # 根据 schema 生成 TypeScript 类型 npx prisma generatedb push适合开发阶段快速迭代,生产环境应该用migrate dev生成迁移文件,再通过 CI 流程应用迁移。很多新人在这一步踩坑:开发阶段图方便用db push,上线后发现数据库结构已经和历史迁移文件不一致,导致无法回滚。规范做法是:从项目第一天就使用prisma migrate dev --name init初始化迁移记录。
这里的核心价值在于:数据库字段和 TS 类型是“同源”的,前端拿到的对象就一定符合后端的模型定义。配合 tRPC 返回值类型自动推导,整套链路里的类型断层被彻底消灭。
3.3 第三步:编写 tRPC router 并接入鉴权逻辑
现在我们来写真正的业务代码。以一个带登录鉴权的“修改用户昵称”功能为例:
// src/server/routers/user.ts import { z } from "zod"; import { router, protectedProcedure, publicProcedure } from "../trpc"; import { prisma } from "../db"; export const userRouter = router({ // 公开接口:查询用户信息 getById: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ input }) => { return prisma.user.findUnique({ where: { id: input.id } }); }), // 受保护接口:修改当前用户的昵称 updateNickname: protectedProcedure .input(z.object({ nickname: z.string().min(2).max(20) })) .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; return prisma.user.update({ where: { id: userId }, data: { name: input.nickname }, }); }), });protectedProcedure的定义里,我们在执行前会调用一个getServerSession函数,把 session 内容挂到ctx上:
import { initTRPC, TRPCError } from "@trpc/server"; import { getServerSession } from "next-auth"; const t = initTRPC.create({ isServer: true }); export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { const session = await getServerSession(); if (!session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session } }); });注意z.string().min(2).max(20)这一段,它是运行时校验,不只靠 TS 类型。TS 类型只在编译期有效,而 zod 会在请求到达时真正校验数据。如果你相信“前端已经校验过了”,后端也顺手做个二次校验,这类问题基本不会出。
接着把 user router 挂到根文件:
// src/server/routers/_app.ts import { router } from "../trpc"; import { userRouter } from "./user"; import { postRouter } from "./post"; export const appRouter = router({ user: userRouter, post: postRouter, }); export type AppRouter = typeof appRouter;到这里,后端的能力层已经完成。注意看,你完全没有写任何路由路径,没有POST /api/user/updateNickname这种东西。所有接口的路径就是user.updateNickname,类型安全天然成立。
3.4 第四步:前端页面接入并享受类型全链路推导
前端侧,在utils/trpc.ts里初始化 tRPC 客户端:
// src/utils/trpc.ts import { createTRPCNext } from "@trpc/next"; import { httpBatchLink } from "@trpc/client"; import type { AppRouter } from "../server/routers/_app"; export const trpc = createTRPCNext<AppRouter>({ config() { return { links: [ httpBatchLink({ url: "/api/trpc", }), ], }; }, ssr: true, });这里的灵魂之处在于createTRPCNext<AppRouter>的泛型引用。前端代码直接引用后端的类型定义,当你敲下trpc.user.updateNickname.useMutation()时,编辑器会提示input必须包含nickname: string,返回值是User | null。如果后端把nickname改成nickName,前端这个调用点会立刻报红,不再需要人工去对字段名。
页面组件里的完整调用长这样:
// src/app/settings/page.tsx "use client"; import { trpc } from "../../utils/trpc"; export default function SettingsPage() { const utils = trpc.useUtils(); const { data: me } = trpc.user.getMe.useQuery(); const updateNickname = trpc.user.updateNickname.useMutation({ onSuccess: () => { utils.user.getMe.invalidate(); // 自动刷新缓存 }, }); return ( <main className="p-8 max-w-md mx-auto"> <h1 className="text-2xl font-bold">账号设置</h1> <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); updateNickname.mutate({ nickname: formData.get("nickname") as string }); }} className="mt-6 space-y-4" > <input name="nickname" defaultValue={me?.name ?? ""} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="请输入新的昵称" /> <button type="submit" disabled={updateNickname.isLoading} className="w-full bg-blue-600 text-white rounded-lg py-2 text-sm font-medium" > {updateNickname.isLoading ? "保存中..." : "保存"} </button> </form> </main> ); }useMutation返回的isLoading、error、isSuccess这些状态由 tRPC 自动管理,你不需要自己手写 loading / error 状态机。这是 tRPC 又一个省事点:请求状态标准化,该有的状态都有了,直接用就行。
3.5 第五步:配置 ESLint、Prettier 与 Git 提交规范
到了这一步,项目已经能跑起来了,但还缺工程规范。t3code 的价值很大一部分在这里。
首先是 ESLint + Prettier。ESLint 负责代码质量规则,Prettier 负责格式化。这套组合的关键在于避免规则冲突:ESLint 里要关掉格式相关的规则,把格式化任务完全交给 Prettier,不然两者会打架。
一个比较稳妥的配置思路是:ESLint 使用团队公开推荐的规则集合,辅以几个针对性插件,比如@typescript-eslint/recommended基础规则、eslint-plugin-react-hooks的 hooks 规则。Prettier 则可以完全放心使用默认配置,最多调整一下单行长度和分号。
在工程规范方面,最值得花时间的是Git 提交信息的规范化。t3code 的思路是利用husky工具监听pre-commit钩子,在每次提交前自动执行代码检查;再用commitlint规范提交消息格式,让提交历史清晰可读。我建议团队从第一天就开始执行:把提交规范当成硬性门槛,而不是建议性约定。一旦团队规模超过三五个人,没有规范的历史记录会变得非常难回溯。
下面的配置是社区里通用的约定:
{ "scripts": { "lint": "eslint . --ext ts,tsx", "format": "prettier --write \"src/**/*.{ts,tsx,css,md}\"" } }.eslintrc.json的核心片段:
{ "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"], "plugins": ["@typescript-eslint"], "ignorePatterns": ["node_modules/", ".next/", "dist/"] }注意prettier这个 extend 一定要放在最后,它的作用不是增加规则,而是把 ESLint 中与 prettier 冲突的规则全部关掉。
4. 调试与排错:那些绕不开的坑和排查路径
4.1 类型推导不生效:检查 tRPC 客户端泛型
tRPC 这套方案,很多坑都出在“类型没传导到位”上。最常见的情形是:后端加了新 query,前端调用时却提示“Property does not exist”。排查路径主要有两条。
先检查AppRouter类型有没有正确导出并传给createTRPCNext。前端的utils/trpc.ts里如果是createTRPCNext<AppRouter>(),就一定要确认AppRouter是从后端_app.ts导入的。这时常是因为 IDE 还停留在老的类型缓存上,重启 TS Server 即可。
再检查 router 是不是真的挂到了根文件里。写了userRouter忘了挂到_app.ts,前端自然找不到对应接口。这里的常量是:router 聚合文件的导出类型,才是前端的“接口清单”。
4.2 环境变量缺失导致的 Prisma 报错
新手最容易踩的坑是 Prisma 连不上数据库。报错信息往往是Environment variable not found: DATABASE_URL。这时先检查.env文件是否被.gitignore忽略了——如果模板项目里忘了提供.env.example,换一台机器 clone 下来就跑不起来。
我建议 t3code 项目从一开始就维护一个.env.example文件,里面声明所有需要的环境变量键名,但值留空或填占位符。这样不管谁来接手,都能快速知道需要配置哪些内容:
DATABASE_URL="postgresql://user:password@localhost:5432/t3code" NEXTAUTH_SECRET="" NEXTAUTH_URL="http://localhost:3000"还有一个非常隐蔽的坑:Prisma Client 在构建时生成,如果你的 Docker 构建流程里忘了执行prisma generate,运行时就会出现 “PrismaClient is not defined” 或 “Cannot find module @prisma/client”。这时候在模版里提前把生成步骤写进postinstall脚本,能避免大量排查时间。
4.3 鉴权失效的真相:服务端 session 与客户端 cookie
使用 NextAuth 做鉴权时,一个高频问题是在 tRPC 后端获取 session 返回 null,但前端明明能拿到登录状态。排查思路是:getServerSession依赖请求头里的 cookie,而 tRPC 的 server 端调用实际上是运行在服务端环境里,它有完整的请求上下文,所以一般不会出问题。
真正常见的错误发生在自封装请求转发时。如果你在写自定义的 tRPC 链接,比如出于日志目的包了一层loggerLink,别忘了把 cookie 转发到后端。否则服务端getServerSession会一直拿不到 session,表现为“明明登录了,但所有 protectedProcedure 都报 UNAUTHORIZED”。
4.4 Zod 校验失败的报错信息优化
tRPC 默认的 zod 错误信息比较啰嗦,直接返给前端体验不太好。一个有效的办法是在 procedure 的外层统一处理TRPCError的 message:把 zod 的 error 展开成简洁的中文提示。比如input.nickname长度不足时,zod 默认报的是Expected string, received number之类的技术英文,你可以捕获后转成“昵称长度需在 2-20 个字符之间”。
当然这层包装不复杂,但非常影响实际体验。我在项目里会维护一个formatZodError的工具函数,把所有 zod 校验失败的场景都调它来处理。
4.5 性能优化:减少重复数据请求
tRPC 自带httpBatchLink,它能自动把多个请求合并成一个 HTTP 调用,这一点对于页面初始加载特别管用。需要注意,batch 不是免费的,如果接口时延相差很大,比如一个接口要 2 秒一个 50 毫秒,合并后反而会拖慢短任务。这种场景建议拆成单独调用,用httpLink替代httpBatchLink。
另外一个很容易被忽略的性能优化点是 tRPC query 的staleTime配置。后台管理页面里很多数据其实变化不频繁,比如用户列表、配置项,设一个合理的 staleTime 能显著减少后端压力:
config() { return { links: [httpBatchLink({ url: "/api/trpc" })], queryClientConfig: { defaultOptions: { queries: { staleTime: 10_000 }, // 10 秒内不重复请求 }, }, }; }这里的思路不是一味追求“实时”,而是结合业务实际,为不同查询设置不同的缓存策略。实时性要求高的用 0,要求不高的用几十秒,甚至几分钟。
5. 常见问题速查表与避坑清单
把前面踩坑总结成一张速查表,方便你直接保存参考:
| 问题现象 | 排查步骤 | 解决方案 |
|---|---|---|
| 前端调用 tRPC 接口报 404 | 检查/api/trpc/[trpc].ts路由文件是否存在 | 确认 tRPC 的 Next.js 路由入口已创建 |
| 类型无法推导到底 | 检查AppRouter泛型是否正确传递 | 重启 TS Server,确认_app.ts导出根 router |
| PrismaClient 找不到 | 检查node_modules/.prisma是否存在 | 执行npx prisma generate,或在postinstall中内置 |
| session 获取不到 | 检查请求头中 cookie 是否被丢失 | 自定义 tRPC link 时确保转发 cookie |
| 部署后页面样式丢失 | 检查 CSS 是否被正确引入 | 确认 Tailwind 在全局 CSS 中已用@tailwind base/components/utilities引入三层指令 |
| tRPC 批量请求超时 | 检查 batch 请求中的慢接口 | 拆分 batch,或提高maxBatchSize和超时时间 |
| 编译通过但运行时校验失败 | 检查 zod schema 是否与实际数据相符 | 统一前后端校验规则,避免“前端有校验后端无校验” |
| 数据库字段变更后类型未更新 | 未执行 prisma generate | 每次 schema 改动后均执行npx prisma generate |
这里有几个我特别想叮嘱的避坑经验:
第一个是永远不要在hooks/useXxx.ts里直接调用 trpc 的 query 并自行缓存。tRPC 的 query 已经内置了 react-query 的能力,你再手写一个缓存层,一方面存在双份缓存,另一方面类型推导链路也会被破坏。直接用useQuery就好,任何显式数据形态转换都放在独立的 mapper 层。
第二个是 zod schema 的复用。前端在表单里做校验用的 schema,应该和后端 tRPC 里的 schema 保持同一来源。如果它们放在不同文件中,前后端相对照总是迟早会错位。在 t3code 项目里,我习惯把 schema 放到一个shared/validations/目录,前端表单和后端 router 都从那里 import,确保同一份定义。
第三个是项目规模增速后的横向思考。t3code 这套玩法在小团队内的优势毋庸置疑,但一旦你的项目需要被第三方访问,需要按 SLA 对外提供 API,tRPC 的强耦合会成为负担。我个人的判断是:内部系统、创业公司 MVP、中后台工具,无脑选 tRPC;开放平台、对外 API,老老实实把接口层换成 OpenAPI 规范的 REST 或 GraphQL。
6. 进阶延伸:多人协作与私有仓库里的模板沉淀
单独一个人用 t3code,其实只是工具效率的提升。但如果你的场景是小团队协作,这套体系的价值会翻倍,关键就看你怎么把这些配置沉淀下来。
我见过不少团队用 create-t3-app 初始化项目后,各成员的本地配置五花八门,最后合代码时冲突不断。一个务实的做法是:把整套模板推进一个私有 npm 包或私有仓库,通过版本号管理。
模板沉淀的具体做法不复杂。先在一个基础项目里把 Webpack/Next.js/tRPC/Prisma/ESLint/Prettier/commitlint 全部配好,然后把所有“项目特定内容”——比如品牌名、默认数据库地址——都抽成环境变量。接着把这个项目作为 template 仓库,之后需要新开项目时直接git clone --depth 1这个仓库作为基底,再重新初始化 git 历史,修改环境变量后即可开工。
进阶一点,你可以把高频的业务模块抽成可复用的函数库,比如:
createPaginatedRouter:自动生成分页查询 routercreateCrudRouter:基于一个 Prisma model 自动生成增删改查接口createUploadHandler:统一处理文件上传,包含大小校验、格式白名单
这里有个我不建议的做法:用代码生成器把所有 CRUD 代码自动生成出来。生成的代码往往冗余、难以维护,而且把逻辑复杂度藏起来了。更适合的模式是写一个createCrudRouter函数,用函数抽象替代代码生成:
// src/server/factories/createCrudRouter.ts import { z } from "zod"; import { router, protectedProcedure } from "../trpc"; interface CrudConfig<T> { model: any; createSchema: z.ZodSchema<T>; updateSchema?: z.ZodSchema<Partial<T>>; } export function createCrudRouter<T>({ model, createSchema }: CrudConfig<T>) { return router({ create: protectedProcedure.input(createSchema).mutation(({ input }) => { return model.create({ data: input }); }), list: protectedProcedure .input(z.object({ page: z.number().default(1), pageSize: z.number().default(20) })) .query(({ input }) => { return model.paginate({ page: input.page, pageSize: input.pageSize }); }), }); }这样每新增一个业务模型,可能只需要几行配置代码:
export const tagRouter = createCrudRouter({ model: prisma.tag, createSchema: z.object({ name: z.string().min(1).max(30) }), });这种思路的最大价值在于:让团队里最懂业务的人去定义模板函数,其他人在使用层面受益,而不是每个人都在自己的 router 文件里重复踩坑。它解放了团队里大部分重复劳动,同时把业务边界收敛到了函数参数配置层面。
还有一类团队在这个体系里很受益:UI 组件库与业务组件并存。t3code 的模板里除了管理 API 层,还应该把 UI 层的常用组件沉淀下来。比如确认弹窗、数据表格、表单布局这些做后台系统时天天要用的东西,统一封装成规范化组件。配合 Tailwind 的类名规则写起来灵活性高,复用的时候也几乎不需要二次定制。
其实走到这一步,所谓的 “t3code 项目” 已经从“一个技术方案”升维成了“团队的工程资产”。它不再只是代码,而是一套被验证过的、可以让组织内所有人都遵循的开发方法论。它的复用价值、新人上手成本、跨项目一致性,才是真正的核心回报。
7. 一个真实项目的全流程复盘
理论说再多,不如看一眼实战。我这里复盘一个今年上半年做的中型项目管理后台,正好就是用 t3code 这套思路搭起来的。
项目需求不复杂:多个团队共用一套系统管理各自的客户、合同和回款数据。如果走传统 REST 路线,我估计前后端联调加上设计接口文档要一周左右。但用 t3code 的思路,从初始化仓库到核心链路跑通,用了不到两天。
第一天的进度是:创建 monorepo 目录、初始化 Next.js 加 TypeScript、接入 Tailwind、安装 tRPC、Prisma、NextAuth,建了 User、Customer、Contract 和 Payment 四张表,写好对应的 router。这些工作如果从零手动配,至少要两天。
第二天开始写真正业务页面。因为类型推导是自动的,我在写前端的客户列表、合同详情、回款记录时,根本不用回头翻接口文档——编辑器里直接能看到后端的字段和类型。当天下午,三个核心页面已经从“无数据状态”到“真实数据渲染”跑通。到了第三周,新增的报表模块里要展示“最近六个月回款趋势”,前端顺手把 tRPC router 加一个统计查询,调用处直接有类型提示,整个开发体验非常顺滑。
过程中遇到两个实际问题的印象很深。第一个是报表模块的批量查询特别慢,几个接口合在一个 batch 里导致页面首屏卡顿,后来把慢接口独立出来就解决了。第二个是某张表加了字段后前端立刻报错,因为后端 schema 变更后没有重新执行prisma generate。这个坑后来靠团队统一在 CI 里加了 generate 步骤才彻底杜绝。
如果非要总结一句,那这句话应该是:技术选型决定上限,工程规范守护下限。t3code 把整套好用的东西组合得足够舒适,剩下的,就是你自己的业务判断力和产品感能不能跟上了。没有银弹,但可以减少不必要的折腾。
对我来说,这套方案最大的收益不是“快”,而是一致性。不管团队来了新人还是老手,打开一个新项目看到的目录结构、代码风格、校验方式都是同一套,进入状态的时间成本被压缩到极低。这种工程上的确定性,比单个技术点上的快,要值钱得多。