t3code 这个名字其实来得非常随意。当时我手头一堆代码片段散落在浏览器收藏夹、本地 markdown 笔记和微信文件传输助手里,每次想翻一个以前写过的正则表达式,都得经历一轮"我去哪了"的考古。于是我决定做一个代码片段管理工具,并把这个项目命名为 t3code。所谓 t3,正好对应我准备采用的三项技术:TypeScript、Tailwind CSS、tRPC,再由 Next.js 和 Prisma 把这套全栈方案串起来,组成完整的 T3 Stack。这个项目从一开始就不是为了炫技,而是为了解决一个非常具体的痛点:让开发者用最少的维护成本,把常用代码片段收进一个带标签、可搜索、还能随时预览的私人仓库里。
下面这几章,就是我完整做下来之后觉得最值得记录的部分。我会把每个选型背后的理由、实际操作的完整步骤、以及真正踩过的坑都铺开来讲,希望能给想用 T3 Stack 做点东西的人一个参考。无论你是第一次接触这套技术栈,还是已经用过但想看看别人怎么落地,应该都能在里面找到有用的东西。
1. 为什么 t3code 选择了 T3 Stack 而不是传统前后端分离
1.1 传统方案最大的问题不是慢,而是类型割裂
我以前做过不少 React SPA 加 Express 后端的小项目,功能开发本身并不慢,真正折磨人的是"改接口"。前端拿到后端返回的数据,通常靠手写 interface 来描述结构;后端改了某个字段名,前端不报错,页面渲染时才 undefined,然后你打开控制台、对比 JSON、再翻代码,兜一大圈终于定位到原因。这种问题出现十次之后,你就会开始怀疑:我们花那么多时间维护"前后端类型定义一致",是不是在做无用功?
GraphQL 的情况稍好一些,因为它可以通过 schema 和 codegen 自动生成客户端类型。但 GraphQL 也有自己的隐藏成本:你需要定义 schema、编写 resolver、处理 N+1 查询、维护 codegen 脚本。对于 t3code 这种个人工具项目,这套复杂度完全没有必要。
1.2 T3 Stack 从根本上绕开了"接口对接"这个环节
T3 Stack 的核心理念是:整个项目就是一个 TypeScript 应用,前端调用后端时不需要通过 HTTP 层的手写类型定义,因为 tRPC 直接从你的 Router 函数推导出客户端能用的类型。也就是说,后端定义了一个 query,前端立刻能在编辑器里拿到精确的入参和返回类型;后端把title改成name,前端代码里用了title的地方马上飘红。
tRPC 在这套方案里的角色,可以理解成一条"类型安全电话线"。传统模式里你写接口文档、维护请求层、处理响应解析,就像给两台机器配信使,信使跑得再快也有传达偏差的可能;tRPC 则是直接把两台机器的电路接到一起,前后端共享一份类型系统,任何一端的改动另一端立刻感知。对我这种一个人维护全栈项目的场景来说,省掉的可不只是几份类型定义,而是整个"心智切换"的成本——我不需要考虑某个数据在传输过程中会不会变形,我只关心它写出来是什么样子。
1.3 Next.js 和 Prisma 解决剩下的事
选 Next.js 是因为它天然支持服务端组件和 API 路由,t3code 的首屏列表和代码详情可以走服务端直接渲染,不需要客户端先加载 JS 再请求数据。Prisma 则负责把业务模型映射成数据库表,同时它的客户端也能保持类型同步。这样整套项目里几乎没有"未类型化"的缝隙:数据库表结构由 Prisma Schema 定义,API 由 tRPC Router 定义,前端页面和服务端组件都在同一个 TS 项目内,类型自然流动。
2. create-t3-app 初始化时我勾了什么,没勾什么
2.1 初始化命令与选项
创建项目的命令非常简单:
pnpm create t3-app@latest t3code执行之后会有一堆交互式选项,我按实际需求做了取舍:
- TypeScript:默认勾选,这是整套方案的基础
- Next.js:勾选,负责页面和路由
- Tailwind CSS:勾选,负责样式
- Prisma:勾选,负责数据层
- tRPC:勾选,负责前后端类型安全通信
- NextAuth.js:不勾选,因为 t3code 是个人工具,我暂时不需要复杂的账号体系
- ESLint / Prettier:按默认勾选,方便保持代码整洁
这里解释一下为什么没勾 NextAuth.js。T3 Stack 官方脚手架里集成的认证方案,更多是给需要完整登录体系的团队项目准备的。我的场景是自用工具,认证方案后续想加随时可以加,但前期加上来反而会让初始化流程多出一堆表和环境变量配置,分散注意力。
2.2 生成后的目录结构与职责划分
脚手架生成的项目结构并不复杂,我结合自己的理解稍作调整之后,主要关注这几个文件:
prisma/ schema.prisma src/ server/ db.ts api/ root.ts trpc.ts app/ page.tsx api/trpc/[trpc]/route.tsserver/db.ts是 PrismaClient 的单例导出,全项目共用同一个数据库连接池。server/api/trpc.ts负责创建 tRPC 实例和公用 Middleware,比如后续要加认证逻辑就在这里塞。server/api/root.ts是所有业务 Router 的聚合入口,tRPC 的类型推导就是从这里向客户端展开的。
2.3 数据库连接和运行起来
初始化时我选择先用 SQLite 起步,这样本机不需要装数据库服务,跑起来完全没有负担。.env文件里配置:
DATABASE_URL="file:./db.sqlite"然后pnpm db:push或者pnpm prisma migrate dev生成数据库表,最后pnpm dev就能看到一个带 Tailwind 样式的默认页面跑起来。这一步没什么难度,但很多人会在依赖安装上卡住,我的建议是直接用pnpm而不是npm,T3 Stack 的依赖树比较大,pnpm 在安装速度和磁盘占用上明显有优势。
3. Prisma 建模:从表结构到第一次 migrate 的完整链路
3.1 表结构设计:标签用多对多,语言用字符串
t3code 的核心业务模型有两个:代码片段 Snippet 和标签 Tag。一个片段可以有多个标签,一个标签也可以挂在多个片段上,所以这里需要做多对多关系。
我的 schema 是这样设计的:
model Snippet { id String @id @default(cuid()) title String description String? code String language String @default("plaintext") tags SnippetTag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique snippets SnippetTag[] } model SnippetTag { snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id]) snippetId String tagId String createdAt DateTime @default(now()) @@id([snippetId, tagId]) }有几个设计决策我想单独说一下:
第一,language字段我没有做成枚举类型。因为代码语言的数量太多,而且经常出现新语言或方言,用字符串存最简单,前端高亮时解析成对应的语言标识即可。
第二,标签关系我用了显式的中间表,而不是 Prisma 的隐式多对多。原因是显式表以后可以扩展字段,比如给某个片段上的标签记录额外权重或备注,迁移成本极低。如果一开始就用隐式多对多,后面改过来会比较麻烦。
第三,片段删除时SnippetTag会自动级联删除,避免数据库里残留一堆孤儿关联。
3.2 第一次 migrate 的完整过程
定义好 schema 后执行:
pnpm prisma migrate dev --name init这个命令会做三件事:生成 SQL 迁移文件、执行到数据库、重新生成 Prisma Client。如果 schema 有语法错误,会在这一步暴露出来,方便尽早修正。
我建议在正式执行之前,先用一段简短的 SQL 迁移文件确认一下改动范围。有时候 Prisma 生成的迁移会包含你没预料到的操作,比如列顺序变化、索引新增等。如果项目已经上了生产,数据库迁移的风险会比开发阶段高很多,这时候更推荐用prisma migrate dev --create-only先生成迁移文件、人工审阅 SQL 之后再用prisma migrate deploy在生产上执行。
3.3 seed 脚本:让前端联调不再靠手工造数据
没有数据的列表页是非常无聊的,所以我在prisma/seed.ts里写了一个初始化脚本,往数据库里插入几十个常用的代码片段,比如正则表达式合集、Shell 命令速查、常见算法实现等。内容生成之后跑一次pnpm prisma db seed就可以正式开始做前端页面了。
这个 seed 文件放在任何项目里都值得做,尤其是前端开发阶段,我不想每改一次样式就手动在数据库里敲一条记录。有了 seed,我可以随时把数据库重置到有数据的初始状态,这对联调和测试都有很大帮助。
4. tRPC 是这套代码的粘合剂
4.1 先理解 tRPC 的"路由器即类型源"
tRPC 的核心概念是 Router 和 Procedure。Router 就是一组具有层级关系的 API 集合,Procedure 是具体接口,分 query 和 mutation 两种:query 负责读数据,mutation 负责写数据。所有 procedure 集中定义在 Router 里,客户端通过trpc.snippet.getAll.useQuery()之类的钩子直接调用,不需要额外生成 API SDK。
我把它理解成"邮编即地址":地址本身是唯一的,邮编只是地址的编码形式,你不需要再去翻译一遍。Router 里的定义是唯一的真相源,种类安全由 TypeScript 编译器保证。
4.2 实际业务 Router 的写法
我写了一个snippetRouter,里面包含列表查询、创建、更新、删除四个基本操作:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; import { db } from "../../db"; export const snippetRouter = createTRPCRouter({ getAll: publicProcedure .input( z.object({ search: z.string().optional(), tagId: z.string().optional(), }) ) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { title: { contains: input.search ?? "" }, tags: input.tagId ? { some: { tagId: input.tagId } } : undefined, }, include: { tags: true }, orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1), description: z.string().optional(), code: z.string(), language: z.string(), tagIds: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { title: input.title, description: input.description, code: input.code, language: input.language, tags: { create: input.tagIds.map((tagId) => ({ tag: { connect: { id: tagId } } })), }, }, }); }), });注意入参的校验完全交给了 zod,z.object(...)定义出来的 schema 既是运行时校验器,也自动成为类型推导的来源。前端调用时如果传错字段结构,编辑器会直接报错,而不是等请求发出去之后才看到 400 响应。
4.3 客户端调用:零手写请求代码
在 React 组件里调用 tRPC 的体验非常顺滑:
const { data, isLoading } = trpc.snippet.getAll.useQuery({ search }); const createMutation = trpc.snippet.create.useMutation({ onSuccess: () => { utils.snippet.getAll.invalidate(); }, });创建成功之后立刻让列表查询失效并重新拉取,这是 React Query 里最常见的更新模式。不需要手动刷新页面,也不需要自己封装请求钩子。所有返回类型都能精确到字段级别,我看接口返回数据的结构直接看前面的data类型就知道了。
t3code 开发到后期,我发现自己已经很少再去看网络请求面板了,因为类型系统在编译期就把绝大多数问题拦截掉了。作为一个长期写前后端分离代码的人,这种体验确实会上瘾。
4.4 服务端组件里怎么用 tRPC
Next.js 的 App Router 默认页面是服务端组件。首屏数据如果全部从客户端拉,会出现一段空列表期,所以我在列表页服务端组件里直接调用 tRPC 的 caller:
import { createCaller } from "~/server/api/root"; const ctx = await createTRPCContext(); // 自己组装 context const caller = createCaller(ctx); const snippets = await caller.snippet.getAll({});这样首屏 HTML 里就直接包含渲染好的列表内容,SEO 和首屏体验都好很多。等服务端数据传给客户端组件之后,后续的交互操作再通过trpc.snippet.*钩子走客户端调用,两者各司其职。
5. 前端页面:服务端组件、Tailwind 与代码高亮的搭配
5.1 列表页和编辑器页的组件边界
我把页面拆成服务端架子加客户端交互件的结构。列表页的容器是一个服务端组件,负责从数据库取数据并渲染标题列表;搜索框、标签筛选器、删除按钮这类带交互的部分,全部单独拆成客户端组件,每个组件都接收 props 传入当前状态和回调函数。这样划分边界的好处是:服务端组件提前拿到数据,客户端组件只负责交互,不负责数据来源,逻辑非常清晰。
有一点要提醒:客户端组件里不能直接调用服务端的db或 PrismaClient,否则轻则抛错,重则把敏感数据暴露出去。服务端数据必须通过 props 或 tRPC 查询传递。
5.2 Tailwind 的实战配置
Tailwind 上手很快,但在 t3code 里我遇到过最典型的一个问题:动态拼类名。一开始我试图根据变量拼接颜色类,比如bg-${color},结果样式完全不生效。原因在于 Tailwind 的 JIT 编译只会扫描源码里完整的类名字符串,动态拼接的类名无法被识别。解决办法也很简单:把可能用到的颜色写成完整的静态类名,或者用映射表把color映射到完整的 Tailwind 类名。
我在样式组织上还做了点设计:公共的卡片、按钮样式提取到@layer components里,减少 JSX 中的重复类名。常写 Tailwind 的人都知道,原子类一时爽,改样式火葬场,所以该抽象还是得抽象,只是别变成 CSS-in-JS 的复杂度膨胀。
5.3 代码高亮库的选型和 SSR 陷阱
t3code 里最核心的交互就是代码预览,所以我需要一个可靠的高亮方案。我对比过两个库:
react-syntax-highlighter:接入简单,默认组件化,但打包体积偏大,因为里面带了很多语言定义和主题样式。shiki:基于 TextMate 语法的高亮方案,生成的 HTML 带主题配色,首屏比较好看,SSR 支持也还行,但用法要稍微熟一点。
最终我选了 shiki,因为它的高亮效果更好看,而且支持同步和异步两种调用方式。这里有个坑:如果在服务端组件里直接调用 shiki 的高亮方法,没问题;但如果把 shiki 当作客户端组件里的模块,又刚好在组件初始化时访问了document,就会在 SSR 阶段报document is not defined。所以我的方案是:预览区在服务端做好高亮,把生成的 HTML 直接用dangerouslySetInnerHTML渲染到底层,客户端只负责切换主题和语言。这样既绕开了 SSR 问题,又保留了高亮效果。
5.4 表单操作:创建和编辑片段
创建和编辑片段我用的是一个两栏布局:左栏是标题、描述、语言选择、标签输入,右栏实时预览代码。这种布局在开发时非常顺手,因为我看得到最终效果,比纯表单体验好很多。表单提交走trpc.snippet.create或trpc.snippet.update,提交成功后立刻触发列表刷新。整个过程没有任何手动 fetch,代码量也比传统方式少不少。
6. Vercel 部署前必须处理的三类问题
6.1 环境变量:SQLite 到 PostgreSQL 的切换
本机开发用 SQLite,一旦要部署到 Vercel,SQLite 就没法用了,因为 Vercel 的无服务器函数环境提供不了持久的本地文件存储。所以部署之前需要把DATABASE_URL换成 PostgreSQL。我在部署前就把 schema 从头到尾看了一遍,确保没有 SQLite 专用的特性被写进业务代码里。
实际操作是先在 PostgreSQL 实例上执行迁移:
pnpm prisma migrate deploy然后配置环境变量DATABASE_URL指向生产库。注意.env里如果带有file:./db.sqlite这种相对路径,部署时一定要替换掉,否则构建阶段就会报错。
6.2 构建阶段常见的三类坑
第一类是 Node 版本问题。T3 Stack 需要 Node 18 以上,Vercel 默认的环境一般没问题,但如果你本地用了 nvm 切到旧版本,构建时可能报某些 API 不存在。推荐在项目根目录添加.nvmrc并写20,锁定 Node 大版本。
第二类是依赖安装问题。Vercel 默认会根据 lockfile 识别包管理器,第一次部署时我因为本地npm install生成了新的package-lock.json,和原有 pnpm lockfile 冲突,导致构建时依赖解析失败。解决办法是删除多余的 lockfile,统一用 pnpm 的pnpm-lock.yaml。
第三类是 tRPC API 路由路径问题。在 App Router 模式下,tRPC 的服务端路由必须放在src/app/api/trpc/[trpc]/route.ts,如果你初始化时选了 Pages Router,那就要放在src/pages/api/trpc/[trpc].ts。如果觉得部署后调用链路不对,第一反应应该是看这个文件在不在正确的位置。
6.3 上线后日志排查
部署完成后如果页面白屏,先看 Vercel 的 Function Logs。t3code 第一次上线就遇到一个和 tRPC 的 context 相关的问题:我在 context 里尝试读取请求头里的自定义字段,本地一切正常,部署后发现某些边缘网络环境不带这个头,导致 context 抛错。这类问题的排查思路很简单:先在本地用vercel dev启动模拟环境,然后打开浏览器控制台看网络请求,再结合 Function Logs 里的堆栈信息定位。不要凭感觉改代码,先复现,再修复。
7. 不到十个小问题,却折腾了两天的节点记录
这部分我整理了一个问题清单,都是项目过程中真实遇到过、并且花了时间解决的。
| 问题 | 原因 | 解决方式 |
|---|---|---|
| 动态拼接 Tailwind 类名不生效 | JIT 编译扫描不到完整类名 | 改用完整类名映射表 |
| tRPC 客户端 query 在某个组件里报 "no query client" | 忘记把组件包在 TRPCProvider 里 | 在根布局统一挂 provider |
| shiki 在 SSR 阶段报 document is not defined | 客户端模块被服务端组件引用 | 高亮逻辑移到服务端完成 |
| Prisma 查询返回的时间是 UTC,页面显示少了 8 小时 | DateTime 序列化为 ISO 字符串 | 前端格式化时指定时区 |
| 部署后首页数据一直加载不出来 | DATABASE_URL 指向了本地 SQLite | 生产库换 PostgreSQL 并执行 migrate deploy |
| 删除片段时中间表数据残留 | 缺少 onDelete: Cascade | schema 中补上级联删除 |
| 列表页搜索框输入即报 500 | query 的 input schema 未定义 search 字段 | 给 getAll 增加可选 search 入参 |
| 表单提交后列表不刷新 | 没有调用 invalidate | 在 mutation 的 onSuccess 中刷新缓存 |
| 使用 Vercel 部署时构建依赖解析失败 | lockfile 混用 npm/pnpm | 删除多余的 lockfile 统一 pnpm |
其中有两个坑我觉得值得展开,因为它们很典型,排查链路也很有代表性。
第一个是"部署后首页数据加载不出来"。第一次部署后,我打开首页,看到标题都渲染出来了,但列表永远是空的,而且没有任何报错。检查了三次日志,都没有明确的异常堆栈。后来我发现问题出在环境变量上:本地.env里的DATABASE_URL被我写成了file:./db.sqlite,部署后这个文件根本不存在,Prisma 查询虽然没抛错,但查询结果为空。解决方式就是前面说的,换 PostgreSQL 后重新执行迁移,并且把环境变量在 Vercel 后台设置成生产库地址。这个问题给了我一个经验:部署到无服务器环境前,先检查所有依赖"本地文件"的配置,这类静态文件在云端几乎都是不可用的。
第二个是 shiki 的 SSR 报错。这个问题表象很直接:构建成功,但是渲染页面时抛document is not defined。我一开始以为是页面里有动态导入没有处理好,排查方向完全错了。后来把shiki的调用从组件里挪到服务端工具函数中,才彻底解决。原因在于客户端组件会被 SSR 阶段执行,代码里如果直接访问document,Node 环境里自然没有这个对象。这类问题在引入任何"客户端专属库"时都可能出现,排查思路应该优先检查:这个库是在组件初始化时执行,还是在事件回调里执行。如果是前者,就要考虑放到服务端,或者用dynamic(..., { ssr: false })延迟到客户端执行。
其实这些小问题都不复杂,但它们组合在一起,会消耗掉很多额外时间。我的体会是:先把技术栈的核心链路跑通、明确每类问题该去哪里查,后面的效率会成倍提升。
8. 写在最后:这次实践留给我的一些直觉
做完 t3code 这个项目,我对 T3 Stack 的感受是:它并不是银弹,但非常适合我这种"一个人要从数据库写到 CSS 的全栈开发者"。类型安全在团队协作里是沟通成本的前置投入,在个人项目里则是省心省力的保障——因为我不需要记住每个接口返回什么结构,编译器会替我盯着。
如果要给后来者一个建议,我会说:不要被"全栈框架"这个词吓到,也不要一上来就把所有特性都铺满。t3code 的路线是先用 SQLite 和最小化的页面把核心功能跑通,再去处理部署和高亮这些外围问题。技术栈是工具,能把你的想法变成可用的产品,它才是好工具。
最后分享一个我实际操作中的小技巧:每次改完 Prisma schema,都顺手跑一次prisma generate;每次改完 tRPC router,都顺手看一眼前端有没有报类型错误。这两步加起来不到十秒钟,但能帮你避开无数个深夜排查的坑。T3 Stack 带来的类型安全感,需要你在日常操作里主动维护,它才会真正成为你的开发日常。