1. t3code 到底是什么一个从杂物间成长起来的全栈代码库t3code 这个名字最早是我在本地新建仓库时随手敲的当时只想给手头一堆练习 Demo 找一个统一的归宿没想到后来它从“杂物间”慢慢长成了我主力维护的一个全栈代码库用 TypeScript 贯穿前后端、用 tRPC 写接口、用 Tailwind 统一样式再把常见的增删改查、鉴权、上传、表格这些场景沉淀成可以反复使用的模块。如果你也是个前端开发者正在从“只会调接口”往“能独立搞定一个完整产品”的方向走或者说你听说过 T3 StackNext.js TypeScript Tailwind tRPC Prisma 这一套但不知道从哪下手那这篇文章就是写给你看的。先说清楚 t3code 的本质它不是一个开源框架也不是什么新概念库它是我个人的实战代码仓库业务场景是“每日链接收藏”——用户可以注册登录、收藏链接、打标签、搜索、公开分享自己的收藏列表。这个场景足够小小到一两个晚上就能跑通又足够全全到能覆盖一个真实 Web 应用会遇到的绝大多数问题认证、数据库设计、接口层抽象、样式体系、SEO、部署、测试。我选择这个场景来填充 t3code不是因为它多酷而是因为它每一个模块都能单独拆出来讲也方便你把它替换成自己的业务。在过去很长一段时间里大多数前端工程师的学习路径是“看一个教程建一个项目”于是本地散落着无数个 next-demo、vue-test、react-playground 这样的文件夹。问题在于这些项目永远停留在“能跑”的阶段不会有人去维护更不会积累出可复用的套路。t3code 的出发点就是对抗这种“一次性代码”凡是写进这个仓库的模块都要求它有清晰的目录边界、拿得出手的类型定义、能跑通的测试以及一份让三个月后的自己还能看懂的文档。换句话说t3code 不追求功能数量追求每个功能的完成度。如果你目前的情况是学过 React 和 TypeScript 基础但没独立做过带数据库和登录功能的完整项目或者你已经在用 Next.js 写页面但接口层还是 fetch 到处飞、类型全靠手写又或者你单纯想找一个“结构像正经项目”的代码库来参考——那这篇文章和 t3code 仓库本身应该能给你不少可以直接抄作业的东西。2. 技术选型背后的取舍为什么我锁死在这套 T 字头组合上很多人听到 T3 Stack 这个名字第一反应是“又是某个网红推的技术栈”。但真正动手把 Next.js、TypeScript、Tailwind、tRPC、Prisma 拼在一起之后我的感受是这套组合最厉害的地方不是任何一个单独的库而是它们之间几乎没有类型缝隙。TypeScript 从数据库层一路贯穿到前端组件整个过程不像是在拼接工具更像是在写一个完整的程序。2.1 为什么选 Next.js 而不是 Vite SPA如果只是做一个内部工具我可能会选 Vite React 这种更轻的方案启动速度快、配置少。但 t3code 的目标是做一个对外可见、需要分享链接的产品SEO 和首屏体验就没法回避。Next.js 的 App Router 天然支持服务端组件、路由懒加载和 Metadata 配置三件套解决了我最关心的两个问题页面能被搜索引擎正确抓取首屏不会白屏半天。更重要的是Next.js 的 API Routes 和 Server Actions 让我可以在一个项目里同时写后端逻辑和前端页面不需要为了一个小项目单独维护两个服务。这里有一个很实际的判断标准如果你的项目需要登录、需要分享、需要被别人搜索到那服务端渲染的价值远大于打包速度的体验。反过来如果你只是在公司内网做一个数据看板Vite 反而更合适。t3code 选 Next.js 不是因为“大家都用”而是这个业务场景确实需要。2.2 为什么选 tRPC 而不是 REST 或 GraphQL这是 t3code 里争议最大、但用久了最回不去的一个选择。传统前后端联调是写 REST 接口后端定义 URL、方法、参数前端拿着接口文档自己写类型两边一旦不同步就出线上 Bug。GraphQL 能解决类型问题但引入的 Schema 语言、Resolver 分层和缓存策略对一个小项目来说太重了。tRPC 的思路完全不同后端直接 export 一个 router前端 import 这个 router 的类型调用起来就像调用本地函数。写 tRPC 接口时不需要定义 URL不需要手写类型不需要文档因为类型就是文档。第一次用的时候会有点不真实感——写完一个createBookmark函数前端立刻就有了带自动补全的trpc.bookmark.create.useMutation()参数错了编译期就报红。这种感觉就像把数据库表结构直接接到了键盘上。当然 tRPC 不是没有代价它要求前后端必须在同一个 TypeScript 项目里共享类型这在微服务架构下不适用但 t3code 本来就是一个单体应用这个前提完全成立。我的建议是如果你在做一个前后端一起维护的产品而不是开放给第三方调用的平台tRPC 的收益远大于成本。2.3 为什么选 Prisma 和 TailwindPrisma 解决的核心问题是“数据库 Schema 和代码类型不一致”。我见过太多项目里 SQL 表字段改了后端类型忘了更新结果前端拿到undefined查半天。Prisma 的做法是让你先定义schema.prisma然后运行prisma generate自动产出完整的 TypeScript 类型。改表结构变成改一个文件再生成一次客户端查询代码立刻提示新字段这几乎是零成本的同步方案。Tailwind 给我的价值不是“写样式更快”而是“不用再想类名”。在 t3code 里我几乎没写过自定义 CSS 文件所有页面都是原子类堆出来的。很多人担心 Tailwind 会让 HTML 变得很长实际用下来我认为这个代价是值得的修改样式不用在 CSS 文件里来回跳删一个组件也不会留下无用样式。而且 Tailwind 在服务端渲染下天然支持样式提取不会像某些 CSS-in-JS 方案那样出现首屏闪烁。2.4 稳定版本的组合参考技术选型不能只看名字还要看版本搭配。t3code 当前跑稳的组合是 Next.js 14App Router TypeScript 5 Tailwind 3 Prisma 5 tRPC 10这个组合在官方脚手架 create-t3-app 里被反复验证过社区踩坑记录也最全。如果你想复刻我建议直接跑npx create-t3-applatest my-app然后在交互提示里勾选 Next.js、Tailwind、tRPC、Prisma 这几个选项就够了。不要一上来就追最新的大版本尤其是 Prisma 和 tRPC它们的大版本升级往往伴随 API 变化等生态跟上了再升不迟。3. t3code 的核心模块长什么样目录、数据模型与 API 组织方式代码库和 Demo 项目最大的区别在于目录结构是否经得起追问。别人打开你的仓库先看目录就能判断出你清不清楚自己在干什么。t3code 的目录经历了三次重构才变成现在的样子每一层都有明确的目的。3.1 目录结构把业务、接口、组件分开当前的结构是这样t3code/ ├── prisma/ │ ├── schema.prisma │ └── seed.ts ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── bookmark.ts │ │ │ │ ├── tag.ts │ │ │ │ └── user.ts │ │ │ └── root.ts │ │ ├── db.ts │ │ └── auth.ts │ ├── app/ │ │ ├── (auth)/ │ │ │ ├── login/ │ │ │ └── register/ │ │ ├── (main)/ │ │ │ ├── bookmarks/ │ │ │ ├── tags/ │ │ │ └── profile/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── components/ │ │ ├── ui/ │ │ ├── forms/ │ │ └── bookmarks/ │ └── trpc/ │ ├── client.ts │ └── server.ts ├── vitest.config.ts └── package.json你可能注意到我把server/api/routers和app分开了这是故意的。App Router 目录承载的是页面和路由而server/api承载的是业务逻辑。这样改一个接口时不需要去页面目录里翻改一个页面时也不怕碰坏接口层。components/ui放纯展示组件components/forms放跟表单状态打交道的组件避免把所有组件堆在一个文件夹里变成大杂烩。3.2 数据模型一个书签应用背后的 Prisma Schemat3code 的数据库只有三张表但它们之间的关系已经能体现 Prisma 的核心用法用户、链接、标签链接和标签是多对多用户和链接是一对多。schema 的核心部分长这样model User { id String id default(cuid()) email String unique name String? bookmarks Bookmark[] createdAt DateTime default(now()) } model Bookmark { id String id default(cuid()) url String db.VarChar(2048) title String description String? isPublic Boolean default(false) userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) tags Tag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt index([userId, createdAt]) } model Tag { id String id default(cuid()) name String unique bookmarks Bookmark[] }这里有三处细节值得注意。第一ID 用cuid()而不是自增整数因为书签链接将来可能要做公开分享页用增量 ID 等于把数据量暴露给别人。第二index([userId, createdAt])是给“查询某用户最近收藏”这条最常用的 SQL 加的索引数据量大了之后没有这个索引会非常慢。第三onDelete: Cascade意味着删除用户时自动删掉他的书签避免产生孤儿数据。3.3 tRPC Router接口层是怎么组织的接口层最关键的设计是“按业务领域拆 Router”而不是按页面拆。用户页需要的接口放在 user router 里书签页的接口放在 bookmark router 里这样当一个人问“书签相关的接口有哪些”时答案在一个文件里就找得到。t3code 里 bookmark router 的简化写法import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; export const bookmarkRouter createTRPCRouter({ listMyBookmarks: protectedProcedure .input( z.object({ tagId: z.string().optional(), cursor: z.string().optional(), limit: z.number().min(1).max(50).default(20), }) ) .query(async ({ ctx, input }) { return ctx.db.bookmark.findMany({ where: { userId: ctx.session.user.id, ...(input.tagId ? { tags: { some: { id: input.tagId } } } : {}), }, take: input.limit 1, ...(input.cursor ? { cursor: { id: input.cursor } } : {}), orderBy: { createdAt: desc }, include: { tags: true }, }); }), createBookmark: protectedProcedure .input( z.object({ url: z.string().url(), title: z.string().min(1), tagIds: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.bookmark.create({ data: { url: input.url, title: input.title, userId: ctx.session.user.id, tags: input.tagIds?.length ? { connect: input.tagIds.map((id) ({ id })) } : undefined, }, }); }), });注意这里protectedProcedure是 t3code 自己封装的一个鉴权流程没有登录的用户直接收到错误根本进不了数据库查询逻辑。这种“在入口处卡住”的思路比在每个 handler 里手写if (!session)要可靠得多也避免了你以后忘记加权限判断。3.4 前端组件页面与数据交互的分层方式页面层我坚持一个铁律app目录下每个页面文件里不出现useState所有需要状态交互的逻辑都下沉到components里。这样做的直接好处是页面文件只负责数据获取和布局一眼能看清这个路由渲染什么表格、弹窗、筛选这些复杂交互在组件内部自己管理状态互不干扰。举一个具体例子app/(main)/bookmarks/page.tsx里只做两件事调用trpc.bookmark.listMyBookmarks.useQuery()拿到数据然后把BookmarkList组件渲染出来。至于编辑弹窗开不开、筛选标签选了什么、当前页是第几页全部封装在BookmarkList内部。这看起来像是小题大做但当页面复杂度上来之后这种边界会帮你省下大量排查时间——出 Bug 时你能立刻定位到“是数据问题还是交互问题”。4. 让代码库可以被人复用测试、文档与环境变量的处理细节代码库和一个能跑的项目之间隔着一道隐形门槛别人 clone 下来能不能顺利跑起来改代码时敢不敢重构。t3code 在这方面踩过不少跟头我把处理方案整理在下面。4.1 测试策略先保住最容易被改坏的逻辑t3code 的测试不追求覆盖率数字只盯三类容易被改坏的东西鉴权流程、表单校验、工具函数。页面 UI 的渲染测试我只写了关键的几条因为 UI 测试对样式和文案变动过于敏感维护成本极高。具体做法是给listMyBookmarks的过滤逻辑单独抽成一个纯函数比如filterBookmarksByTag(bookmarks, tagId)然后针对这个函数写单测。这样做的好处是当我把筛选逻辑从组件里抽出来时行为就变得可以被精确验证不需要先渲染整个列表再模拟点击。类似地书签表单的 URL 校验和标签去重逻辑我都会先抽成纯函数再测。测试框架选的是 Vitest它跟 Vite 共享配置跑起来比 Jest 快不少而且在 Next.js 项目里集成很顺。配置里最重要的部分是environment: node用于测试工具函数environment: jsdom用于测试组件我用注释的方式在测试文件顶部显式声明// vitest-environment jsdom import { render, screen } from testing-library/react;4.2 本地一键跑通环境变量清单与初始化脚本很多项目写好了却跑不起来问题往往出在环境变量上。我把 t3code 需要的环境变量整理成一张表直接写在.env.example里变量名必填说明DATABASE_URL是PostgreSQL 连接串本地可以不装数据库直接用 Docker 起NEXTAUTH_SECRET是NextAuth 的加密密钥生成方式openssl rand -base64 32NEXTAUTH_URL是本地填http://localhost:3000部署填线上地址SKIP_MIGRATION否某些部署平台不想自动跑迁移时设为true我在package.json里放了一个db:setup脚本把建库、建表、填充种子数据串起来{ scripts: { db:setup: prisma migrate dev prisma db seed } }这段脚本的价值在于新加入项目的人不需要读文档了解“先干什么后干什么”一句命令就能把数据库准备到可开发状态。4.3 README 写作的几个硬要求t3code 的 README 改过四版最后留下的结构只有四块项目简介三句话内说明白业务场景、本地运行步骤从 clone 到打开页面的完整命令、目录导航说明哪些目录千万别乱动、常见问题专门记录环境变量问题和数据库连接问题。我刻意删掉了“技术架构图”和“贡献指南”因为对一个个人代码库来说这两块内容不是没人看是写了也没人维护只会越放越过期。另外一个细节README 里的命令必须是复制就能跑通的不要出现“或者你也可以……”这种含糊表达。每一条命令我都在干净环境里实测过因为我太多次照着别人 README 跑跑到一半发现他漏了一步npx prisma generate那个滋味相信你也懂。4.4 Prisma 在测试环境中的数据隔离办法测试里最怕的一件事情是跑测试把开发数据库给污染了。t3code 里我单独建了一个test数据库测试跑之前用prisma migrate reset --force --skip-seed重置它。虽然这会慢一点但换来的是每次测试都在干净的数据集上运行不会出现“上次跑完留了一条脏数据导致这次断言失败”的问题。这个稳定性的价值远大于那几秒钟的等待。5. 跑过三套部署环境才总结出来的坑t3code 从本地跑通到真正稳定部署在线上中间换了三次环境每次都踩到不同的坑。我原以为部署是最无聊的环节实际上它是最能逼你把项目结构搞清楚的环节。5.1 第一个坑构建时 Prisma Client 没有生成在第一次部署到 Node 服务器时构建日志里报了这样一个错PrismaClient is not defined或Cannot find module prisma/client。原因很简单部署平台执行npm run build之前没有跑prisma generate而prisma/client的代码是在 generate 之后才生成的。很多人会在package.json里加一个postinstall脚本但我实测下来更稳妥的是把它写进构建命令本身{ scripts: { build: prisma generate next build } }这样无论谁来构建顺序一定是先生成 Prisma Client 再编译 Next.js。如果你用 create-t3-app 脚手架它默认生成的build脚本可能没包含这一步记得自己加上。5.2 第二个坑NEXTAUTH_URL 在客户端崩溃NextAuth 在本地跑得好好的一部署就报[next-auth][error][SIGNIN_ERROR]。排查了很久才发现NEXTAUTH_URL在生产环境必须写成用户访问的完整地址不能写成http://localhost:3000也不能写成内网地址。这个变量同时被服务端和客户端读取一旦配置错误OAuth 跳转会拿不到正确的回调地址。更隐蔽的是如果你把NEXTAUTH_URL设置成结尾带斜杠的地址也会导致回调 URL 匹配失败。我的经验是部署后第一时间打开登录页看跳转地址是不是你期望的域名这个比看日志更直观。5.3 第三个坑客户端环境变量的前缀陷阱Next.js 里只有以NEXT_PUBLIC_开头的变量会暴露给浏览器端而 tRPC 的调用地址和图片上传地址如果写成普通变量页面爬起来就是各种undefined。我一开始把所有变量都放在.env里结果浏览器端一访问全是空。改成.env.local并显式把客户端需要的变量命名成NEXT_PUBLIC_前缀之后才正常。这里有个容易忽略的点部署平台的“环境变量”面板通常不会告诉你哪些变量被注入到客户端代码。你需要在本地先跑一次next build然后在浏览器控制台里检查window上是否能读取到这些变量。别嫌这一步麻烦它能帮你提前拦下一大批“线上白屏”问题。5.4 部署前必做的检查清单我把最后稳定下来的部署检查项整理成了清单每次上线前照着过一遍数据库迁移是否已执行prisma migrate deploy而不是prisma migrate devDATABASE_URL是否指向生产库生产库的密码是否带特殊字符导致 URL 解析失败NEXTAUTH_URL是否等于线上域名且不带尾斜杠构建命令里是否包含prisma generate部署平台是否支持 Next.js 的 Standalone 模式如果不支持是否切换为默认模式其中“数据库密码带特殊字符”这个坑最冤比如密码里含或#URL 解析会直接截断连接串看着没问题实际上已经错了。我的处理办法是把密码 URL 编码或者干脆用不带特殊字符的强密码。6. 把 t3code 做成“活”的代码库脚本、模板与后续方向一个代码库如果只是写完了放那儿过三个月自己都不会想打开它。我为了让 t3code 保持“活”的状态给它加了两类东西一类是降低日常操作成本的脚本另一类是让新功能有套路可循的模板。6.1 新增模块的一键脚本t3code 里我写了一个简单的 Node 脚本scripts/new-module.mjs你只需要执行npm run new-module bookmark它就会自动在src/server/api/routers/下生成一个 router 文件在src/app/下生成一个对应的页面目录并把基础路由挂载到根 Router 里。这个脚本表面上省的是几次手动新建文件的时间实际上是强制新功能按照既定结构来生长不会出现某天有人为了省事把接口写在页面文件里这种情况。脚本的核心逻辑不复杂关键是读模板、替换名称、写入三个动作。我把模板文件放在scripts/templates/下Router 模板长这样import { createTRPCRouter, protectedProcedure } from ../trpc; export const {{name}}Router createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) { return []; }), });6.2 页面模板把 CRUD 变成填表书签列表页和标签管理页的交互高度相似都是“表格 弹窗 搜索”。我把这套交互抽成了components/ui/DataTable和components/ui/ModalForm新页面第一次开发时不再从零开始拼组件而是组合这两个通用组件专注定义字段和校验规则。这个结果就是 t3code 里新增一个类似管理页面核心代码量能控制在 100 行以内。为什么我要强调这一点因为大多数前端项目死在“每个页面互相复制然后各自改出一点差异”的泥潭里。当你有 5 个长得差不多但细节不一样的列表页时改一个公共交互行为就得挨个页面去排查。抽成模板虽然前期多花一点时间但后面每次改动都只需要动一处。6.3 下一步可以扩展的方向t3code 现在的状态离我理想中的“个人脚手架”还差两块内容。一是把收藏数据的导入导出做成可复用模块支持从浏览器书签 HTML 文件里批量解析这涉及到前端文件解析和批量写库是可以单独写一篇的实战题材。二是把公开分享页做成一个独立的站点用 RSS 输出用户公开收藏之后可以接入自动化更新通知。这些扩展方向我都刻意没有现在就做理由是一个代码库的价值不在于堆了多少功能而在于每个功能是否都达到“可以给别人参考”的质量线。先把现有的模块打磨好让后来者能看懂、能跑通、能借鉴比急着加功能重要得多。最后分享一个我在维护 t3code 过程中的真实体会给代码库起一个像样的名字真的会改变你对它的态度。“杂物间”里的东西坏了你不想修但一个叫 t3code 的、目录结构清楚的仓库你会愿意为它写测试、写文档、记录踩坑过程。哪怕你手头的项目规模不大我也建议你从现在开始挑一个自己最常用的技术组合把散落的练习代码认真地归拢到一个正式的仓库里三个月后你回头看会感谢当时的这个决定。