如果你在开发者社区里刷到过t3code这个词,大概率会把它和 T3 Stack 联系起来。没错,t3code 就是我维护的一个全栈项目仓库,核心是用 T3 Stack 做一套代码片段管理工具。它能干什么?一句话:把散落在本地和聊天记录里的代码片段集中收藏、打标签、按语言筛选,还能一键复制甚至分享给朋友。适合什么人看?正在学 Next.js 全栈、想搞清楚 tRPC 和 Prisma 怎么配合的人,或者纯粹想找一个能跑起来的 T3 Stack 参考项目的人。这篇文章我会从项目设计、技术选型、初始化、数据模型、前后端实现到部署排错,完整复盘一遍。
1. 项目整体设计:t3code 想做成什么样
1.1 从痛点出发的功能清单
我最初想做 t3code,是因为自己经常在开发中遇到一个尴尬场景:一段调试了很久才跑通的配置代码,今天调完,下周再用就忘了放哪儿了。微信收藏夹里一大堆代码截图,本地 notes 里混着各种临时记录,真要找的时候只能硬翻。
所以 t3code 的第一个版本,功能列表非常克制。它只做四件事:
- 展示所有已保存的代码片段,按创建时间倒序排。
- 新建片段时填写标题、正文内容、编程语言,并支持挂多个标签。
- 首页提供语言筛选框,可以快速过滤出 JavaScript、Python、Go 等特定语言的片段。
- 每条片段右侧有一个复制按钮,点一下就把完整代码复制到剪贴板。
这个功能范围看起来简单,但已经足够覆盖 T3 Stack 里大部分核心能力:Prisma 负责数据写入和查询,tRPC 负责前后端接口,Next.js 负责页面渲染,Tailwind 负责外观。如果一开始就把权限、评论、点赞都塞进来,项目复杂度会被迫拉高,反而不利于搞清楚技术栈本身的协作方式。
另外我在设计时坚持了一个原则:所有操作都不需要手动刷新页面。创建完片段后自动回到列表页,列表页数据自动更新。这个体验优化在传统开发里需要写一堆状态同步逻辑,但在 T3 Stack 里几乎是由 React Query 的缓存失效机制白送的,后面会详细说。
1.2 技术选型的取舍逻辑
先解释一下 T3 Stack 到底是什么。它不是一个单一框架,而是一套组合方案,常见组成是 TypeScript、Tailwind CSS、Next.js、tRPC、Prisma。也有人把这套东西叫全栈开发全家桶,因为它从数据库到样式到前端交互全都有明确分工。
我选择这套组合,核心原因是“类型安全可以贯穿一整个请求链路”。你在 Prisma 里定义的数据模型,生成出的 TypeScript 类型可以直接被 tRPC 的 Router 使用;tRPC 的 query 返回类型,又可以自动映射到前端 React Hooks 的返回值。也就是说,从数据库字段到前端渲染,类型是咬死的,字段名写错、类型不对,在编译期就会报错,不用等运行时。
如果把项目改成传统前后端分离,用 Next.js 只做前端,后端单独用 Express 或者 NestJS,那至少要多维护一份 OpenAPI 文档或者手写类型声明。对于 t3code 这种轻量工具,这属于过度设计。REST 接口虽然通用,但每次加字段都要手动同步类型,频率一高就容易出现前后端不一致的隐患。
tRPC 在这套架构里替代的是 REST 层。它不需要你用 URL 去描述动作,直接调用 TypeScript 函数就行。我刚开始用的时候也觉得不够“工程化”,后来发现团队协作反而更简单,因为接口定义就是函数签名,后端改了签名,前端立刻报红,根本不会等到联调才发现参数对不上。
状态管理方面,t3code 没有引入 Redux 或者 Zustand。原因很简单:服务端数据状态全部由 React Query 管,本地表单状态只需要 useState。两个状态域分得清清楚楚,没必要再塞一个全局状态库把水搅浑。这个取舍在多人协作时尤其重要,代码评审一看就知道哪些是服务端数据、哪些是本地临时态。
2. 从零初始化 t3code:环境准备与目录解读
2.1 开发环境的最低配置
在跑 create-t3-app 之前,先把环境准备好。我实际用的配置是 Node 20.11,包管理器用 pnpm,数据库先用 SQLite,后面部署到生产环境时再切 PostgreSQL。
| 工具 | 推荐版本 | 备注 |
|---|---|---|
| Node.js | 18.0 以上,推荐 20 | create-t3-app 要求较新的 Node,20 是稳定选择 |
| 包管理器 | pnpm 8+ | npm 和 yarn 也能用,但 pnpm 安装速度更快 |
| 数据库 | SQLite 本地,PostgreSQL 生产 | 本地零配置,生产避免 SQLite 的 serverless 限制 |
| Git | 2.30+ | 初始化仓库后方便回滚每一步实验 |
这里有个经验分享:不要在 Node 16 上折腾。T3 Stack 里的 Next.js 14 和 tRPC 10 已经明确要求 Node 18+,我在初期踩过坑,安装依赖能成功,但 dev server 经常内存溢出,后来换了 Node 20 才稳定。
2.2 create-t3-app 初始化命令与交互选项
初始化 t3code 我直接用了官方脚手架,命令是:
pnpm create t3-app@latest t3code交互过程中会有很多选择项,我当时选了这些:
- Next.js:选 App Router。
- TypeScript:必选,这是 T3 Stack 的根基。
- Tailwind CSS:必选,样式方案。
- tRPC:必选,后面所有接口都走它。
- Prisma:必选,数据模型和数据访问都靠它。
- NextAuth.js:暂时不选,t3code 第一版不需要登录系统。
如果你像我一样暂时不需要鉴权,可以在初始化时去掉 NextAuth。后面要加也不是很难,但脚手架会多生成一堆示例代码,反而容易干扰理解核心流程。
初始化完成后,项目里会出现一堆文件和目录。初学者容易被吓到,我整理一下最关键的几个,其余可以先不用管:
src/app:Next.js App Router 的页面目录,所有页面都在这里。src/server/api:tRPC 的 Router 和上下文配置,后端逻辑都在这层。src/server/db.ts:Prisma Client 的单例实例,供 tRPC Router 调用。prisma/schema.prisma:数据模型定义,改数据库表就是改这个文件。src/env.mjs:环境变量校验,启动时用 Zod 检查缺失变量。
2.3 环境变量与数据库连接配置
create-t3-app 会生成一个.env.example,第一次启动前要把它复制成.env,否则框架会因为找不到环境变量直接退出。
cp .env.example .env默认的.env里其实只有两个重点变量。t3code 本地用 SQLite,所以DATABASE_URL就是本地文件名:
DATABASE_URL="file:./db.sqlite"如果你用的是 Postgres,需要改成连接串:
DATABASE_URL="postgresql://user:password@localhost:5432/t3code"这里想多说一句:Next.js 项目的.env文件千万不要提交到 Git。你可以在.gitignore里直接写死.env,提交到远程仓库的只保留.env.example。这不仅是为了保护密钥,更重要的是,如果团队其他人拉下来代码,能靠.env.example知道需要配置哪些变量,而不是对着空仓库发呆。
3. 数据模型与后端逻辑搭建
3.1 Prisma Schema 设计思路
t3code 的第一个版本只有两个核心模型:代码片段(Snippet)和标签(Tag)。关系很简单,一个片段可以有多个标签,一个标签可以挂在多个片段上,标准的多对多关系。
我在prisma/schema.prisma里定义的数据模型是这样的:
model Snippet { id String @id @default(cuid()) title String content String @db.Text language String @default("plaintext") tags Tag[] @relation("SnippetTags") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] @relation("SnippetTags") }有几个设计细节想说明一下。主键我用了cuid()而不是autoincrement(),因为在实际生产中,数据库可能从单实例迁移到分布式环境,自增 ID 容易冲突,而 cuid 可以在任何节点独立生成。本地开发可能看不出区别,但养成这个习惯以后少踩坑。
content字段加上了@db.Text,这是告诉 Prisma 在 SQLite 里用 TEXT 类型而不是 VARCHAR。如果不加,默认会生成有限长度的字符串类型,一段完整的代码随便就超了。这个细节不注意,等到保存长一点的脚本时会直接报数据超长错误。
language字段没有做成单独的模型。有人可能有疑问,为什么不设计成 Language 表?因为语言这是一个有限的枚举集合,直接存字符串最省事。等真正需要维护语言的元数据时,再抽表不迟。
3.2 tRPC Router 与业务流程实现
数据模型有了,接下来就是写后端逻辑。T3 Stack 里后端逻辑都放在 tRPC Router 中。我在src/server/api/routers/snippet.ts里实现了两个接口:查出所有片段和新建片段。
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const snippetRouter = createTRPCRouter({ getAll: publicProcedure.query(({ ctx }) => { return ctx.prisma.snippet.findMany({ include: { tags: true }, orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().default("plaintext"), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.snippet.create({ data: { title: input.title, content: input.content, language: input.language, tags: input.tags?.length ? { connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })), } : undefined, }, }); }), });这里要说清楚 tRPC 的一个核心概念:publicProcedure表示公开可访问的过程,query对应 GET 类型的读取操作,mutation对应写操作。如果以后要加登录,只需要把publicProcedure换成protectedProcedure,然后在中间件里校验 session,不用改 Controller 层的业务代码。
getAll里的include: { tags: true }是 Prisma 的关联查询,它会一并返回这个片段关联的标签数组。我建议在列表页默认就把标签带出来,否则前端还要为了标签单独发请求,体验会差很多。
create里的connectOrCreate是 Prisma 处理多对多关联的利器。它的逻辑是:如果标签已经存在,就连接已有记录;如果不存在,就创建这个标签。每一行代码都很直观。
输入参数校验我用了 Zod。因为 tRPC 官方默认就集成了 Zod,不用单独引入其他验证库。在前后端类型共享的背景下,Zod 的 schema 既能在服务端做校验,又能在前端自动推导出入参类型。你不需要写两份同名的 interface,这就是类型安全的红利。
3.3 挂载 Router 到根路由
写好的 Router 要挂载到根路由里,才能在客户端被调用。在src/server/api/root.ts中,把snippetRouter放进去:
import { snippetRouter } from "~/server/api/routers/snippet"; export const appRouter = createTRPCRouter({ snippet: snippetRouter, }); export type AppRouter = typeof appRouter;注意最后一行导出的AppRouter类型。这个类型会作为 tRPC 前后端的“契约”,前端所有的 Hooks 都基于它推断返回类型和入参类型。如果这里漏了export type,客户端就没法获得类型提示。
挂载完之后,我习惯先执行一次迁移,把表结构同步到本地数据库:
npx prisma db pushdb push适合开发阶段快速同步 schema。如果你用的是 Postgres 并且已经上了生产,那最好用prisma migrate dev生成迁移文件,而不是直接 push。这是两种场景的管理方式,本地怎么快怎么来,生产可不行。
4. 前端页面与交互实现
4.1 App Router 下的页面结构
t3code 的页面结构简单直接,全部放在src/app目录下:
src/app/page.tsx:首页,展示全部代码片段。src/app/new/page.tsx:新建片段页面。
App Router 目录结构就是 URL 结构。new文件夹对应的就是/new路径,不需要额外配置路由表。这种约定优于配置的设计对于小项目非常友好,少一个路由配置文件,就少一处出错的可能。
首页布局用 Tailwind 写起来很快。我这儿给一个最直接的骨架思路:
<div className="mx-auto max-w-4xl px-4 py-8"> <header className="mb-8 flex items-center justify-between"> <h1 className="text-2xl font-bold">t3code 片段库</h1> <Link href="/new" className="rounded-md bg-blue-600 px-4 py-2 text-white"> 新建片段 </Link> </header> <div className="grid gap-4"> {snippets.map((snippet) => ( <div key={snippet.id} className="rounded-lg border p-4 shadow-sm"> {/* 片段内容 */} </div> ))} </div> </div>Tailwind 的优势在这个时候体现得很明显:你不用切到 CSS 文件去给类名命名,所有的间距、圆角、阴影、字号全部在模板里用 class 完成。对于一个人维护的项目,这套工作流省下来的时间很可观。
4.2 用 tRPC Hooks 连接前后端
前端调用 tRPC 接口的方式非常简单。在page.tsx里加上"use client",然后用api这个自动生成的 Hooks 对象:
"use client"; import { api } from "~/trpc/react"; export default function HomePage() { const { data: snippets, isLoading } = api.snippet.getAll.useQuery(); if (isLoading) { return <div className="p-8">加载中...</div>; } return ( // 渲染 snippets ); }这里有个关键边界问题:tRPC 的 Hooks 依赖 React Query,而 React Query 的缓存存在于客户端,所以使用useQuery的组件必须标记为 Client Component。如果你放在 Server Component 里,会直接报错useQuery is not a function或者类似的水合错误。
新建页面的提交逻辑是:
"use client"; import { useRouter } from "next/navigation"; import { api } from "~/trpc/react"; export default function NewSnippetPage() { const router = useRouter(); const utils = api.useUtils(); const createSnippet = api.snippet.create.useMutation({ onSuccess: async () => { await utils.snippet.getAll.invalidate(); router.push("/"); }, }); function handleSubmit(formData: FormData) { createSnippet.mutate({ title: String(formData.get("title")), content: String(formData.get("content")), language: String(formData.get("language")), tags: [String(formData.get("tags"))], }); } return ( <form action={handleSubmit} className="mx-auto max-w-2xl space-y-4 p-8"> {/* 表单字段 */} </form> ); }这段代码里最重要的就是invalidate()方法。它是 React Query 的缓存失效机制,我会在创建成功后调用它,让之前缓存的片段列表强制重新请求。这一步保证了返回首页后,新创建的片段立刻出现在列表顶部,不需要手动刷新浏览器。我在没有了解这个机制之前,曾经用别的方式绕过缓存,结果导致数据不同步,改起来比直接使用正确机制更复杂。
4.3 细节体验:语言筛选与复制按钮
首版 t3code 的体验有两个细节,我想展开讲讲。
第一个是语言筛选框。我在首页放了一个select下拉框,选择语言后只展示对应语言的片段。实现方式是在前端根据language字段过滤。虽然数据量大了之后这样做不够高效,但第一版完全够用。后续如果片段数量上万,再改成把语言参数传给后端 query 也不迟。
第二个是复制按钮。每个片段卡片右上角放了一个小按钮,点击后调用浏览器的剪贴板 API:
async function handleCopy(content: string) { await navigator.clipboard.writeText(content); alert("已复制到剪贴板"); }这个 API 在 HTTPS 环境下才能正常工作,本地开发时 localhost 不受影响。如果你部署到 HTTP 的测试环境,复制功能可能静默失败,这一点建议提前考虑到。
代码高亮我暂时没有上重型库。第一版直接用<pre><code>加 Tailwind 的灰底样式就够用。如果你想让 t3code 更完整一点,可以研究一下 Shiki 或 Prism,它们是更常规的选择,但不要一开始引入,否则后续难度会超出主线学习目标。
5. 上线部署与常见问题排查记录
5.1 构建与部署流程
t3code 整个项目构建起来非常简单,因为 T3 Stack 默认就是围绕 Next.js 设计的,最省心的部署方式是 Vercel。
本地先验证构建是否通过:
pnpm build如果一切正常,项目会输出.next目录。然后把代码推送到 Git 仓库,在 Vercel 里导入项目,填写环境变量即可。这里有个极其容易踩的坑:如果你还沿用本地 SQLite 的DATABASE_URL,在 Vercel 的 serverless 环境里会频繁报错,因为函数实例的文件系统是临时的,连数据库文件都写不进去。
所以上生产前,我把 t3code 的数据库切到了 PostgreSQL。操作步骤也很简单:
- 在 Vercel 的 Storage 模块创建一个 Postgres 数据库。
- 把生成的连接串填到项目环境变量里。
- 本地执行
npx prisma migrate deploy,让生产库把表结构同步上。 - 重新部署,验证读写正常。
如果不想用 Vercel 的 Storage,也可以用任何云数据库服务的 Postgres 实例,只要连接串正确就没问题。
5.2 典型问题速查表
我在开发 t3code 的过程中,遇到过一些非常磨人的问题,这里整理成表格,方便你直接对照。
| 问题现象 | 主要原因 | 解决方法 |
|---|---|---|
npx prisma db push失败,报数据库连接错误 | SQLite 文件路径不对,或数据库文件被占用 | 检查.env里的DATABASE_URL,确认为file:./db.sqlite,关闭其他数据库管理工具 |
前端调用api.snippet.create时无类型提示 | 类型没更新,dev server 缓存了旧类型 | 重启 dev server,或重新执行pnpm dev,让 tRPC 重新生成类型 |
| Tailwind 样式没生效,页面看着像无样式 HTML | Tailwind 的内容路径没配置 | 在tailwind.config.ts里确认content包含src/app/**/*.{tsx,ts} |
| 创建片段成功后列表不刷新 | 没有调用utils.snippet.getAll.invalidate() | 在onSuccess回调里执行缓存失效操作 |
| 部署到 Vercel 后,写入数据成功但读取时数据丢失 | SQLite 无法用于持久化存储 | 切换数据库为 PostgreSQL |
Next.js 报错SerializableState或者水合不匹配 | Server Component 和 Client Component 混用,渲染结果不一致 | 检查页面是否用了"use client",确认不会把 Date 对象直接传给组件 |
tRPC 报No "create" exists in the union of ... | 类型不匹配,Router 路径不对或 ctx.prisma 访问失败 | 核对root.ts挂载名,确保与前端调用名一致 |
这些问题中,最让我记忆深刻的是第二个。tRPC 强类型虽然爽,但它依赖类型生成器在 dev 运行时动态更新。如果你像我一样经常改 Router 签名,建议改完之后等 dev server 自动 reload 几秒,再去写前端代码,不然编辑器会给你错误的红色波浪线,容易造成误判。
5.3 避开 T3 Stack 的三个隐藏坑
第一个坑是 React Server Component 和 Client Component 的边界。tRPC Hooks 只能在 Client Component 里反复强调,但仍然有人会下意识在默认的 Server Component 页面里写useQuery。我建议从一开始就把页面根部标记为"use client",这样才能避免水合错误。
第二个坑是 Prisma 在 serverless 平台的连接数问题。Vercel 的 serverless 函数按需创建,如果用 Postgres,每个函数实例都会尝试建立数据库连接,连接池很快被打满。解决方式是在server/db.ts里使用 Prisma 官方推荐的加速连接方案,或者给数据库加一个连接池代理。这个问题在个人项目里不明显,但流量稍高就会爆发。
第三个坑是升级依赖时要谨慎。T3 Stack 是多个框架的叠加,任何一方升级大版本都可能牵一发动全身。比如 Next.js 升级大版本后,tRPC 适配器可能不兼容;Prisma 升级大版本后,生成的 Client 类型缓存要重新构建。我后期吃了一次升级的亏之后,形成了自己的习惯:小版本升级直接上,大版本升级先查 Upstream 的兼容性说明,绝不盲目执行pnpm update。
最后再分享一个小技巧
我在实际维护 t3code 的过程中,最受用的是把整个项目拆成“可复现的最小步骤”来做:先让数据库读写通,再让 tRPC 接口通,最后才碰页面样式。看起来多花时间,但每一步出了问题都能立刻定位。
还有一个更小但很实用的细节,就是给每个 Router 都加一个getLatest之类的简单 query,作为“探针接口”。前端联调时如果什么数据都拿不到,先用这个探针确认基础链路是否正常,能省掉很多无意义排查。
如果你自己也在搞类似的 T3 Stack 项目,建议先不要急着堆功能,把一个最简单的 CRUD 流程跑通,比任何教程都能让你更快理解这套组合的打法。t3code 会持续迭代,后续我计划加上全文搜索和用户区分,但这都是把根基打牢之后的事。