去年年底我打算把手头散落的代码片段整理成一个能随手检索、能一键分享给自己的小工具,项目名字起得随意,就叫t3code。名字里的 T3 指的是社区里流传度很高的 T3 Stack——TypeScript、Tailwind CSS、tRPC 三件套,外层再套一个 Next.js 负责页面和路由,数据层用 Prisma 管数据库。这套组合最吸引我的地方不是"新",而是每个工具都只操心一件事,整条链路从数据库到页面按钮都是类型安全的,写完 CRUD 基本不需要在前后端之间反复对齐字段名。这篇文章就记录一下我实际搭建这个项目的完整过程,包括脚手架勾选、tRPC 的调用链设计、页面渲染节奏,以及部署之后踩过的几个坑。适合想用 T3 Stack 做点真实项目的人参考,也适合刚接触 Next.js 但被服务端组件、API 路由整懵的朋友。
1. 为什么起名 t3code:一次选型对比之后的决定
很多第一次听到 T3 Stack 的人会以为它是一套"全家桶框架",其实它的官方描述很克制——一个组合了 TypeScript、Tailwind CSS、tRPC、Next.js 和 Prisma 的起步模板。它的核心理念是"最少但完整":Next.js 管路由和渲染,Tailwind 管样式,Prisma 管数据库映射,tRPC 管 API 层,TypeScript 像胶水一样把类型从数据库一路粘到前端组件。我选择它之前,也认真对比过传统方案。
1.1 传统 REST API 和 tRPC 的本质差异
如果按老思路做这个工具,我会先写一套 REST 接口:GET /api/snippets、POST /api/snippets、DELETE /api/snippets/:id,然后在前端手动维护一套 TypeScript 接口定义,再写 axios 封装、错误处理、loading 状态。一个简单项目里,这些重复代码往往比业务代码还多,而且一旦后端改了某个字段,前端很容易忘记同步。
tRPC 的方式则完全不同:在服务端定义一个 router,每个接口就是一个函数,前端却能直接调用这个函数,类型由 TypeScript 自动推导。你不需要"定义接口",只需要"写一个函数",然后前端的调用处会魔法般地出现对应的入参类型和返回值类型。一开始觉得有点反直觉,实际用下来确实省掉了大量接口对齐工作。
1.2 为什么 Next.js 和 Tailwind 没有重叠感
T3 Stack 里没有状态管理库,没有独立的 API 层框架,也没有路由库。这并非遗漏,而是因为 Next.js 本身就覆盖了路由、渲染和部分服务端逻辑。传统 React 项目里 redux 承担的东西,在 T3 里大部分被服务端组件和 URL 参数消化掉了。Tailwind 负责的只是样式,和 Next.js 完全不重叠。每个工具边界清晰,学起来反而快,排查问题时也能一眼定位。
这个项目之所以要叫t3code,就是因为它从里到外都是这一套组合的直接产物。如果你还在纠结要不要上 T3,先问自己一句:项目里是不是大量依赖前后端类型配合?如果答案是肯定的,tRPC 带来的收益会非常直观。
2. 脚手架初始化:create-t3-app 的选项和目录结构解读
初始化 T3 Stack 项目非常无脑,核心命令只有一句:
pnpm create t3-app@latest它会交互式地询问项目名,我输入t3code。接下来是一堆勾选清单,这一步值得仔细想想,不是全部勾选就是最好的。
2.1 哪些选项值得勾选
我最终勾选的是 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma。有一个选项我刻意没勾——NextAuth。原因很实际:我最初只想做一个单人工具,登录功能不是必须的,而 NextAuth 会引入 session、数据库 account 模型等额外复杂度。如果项目后面真要开放给外部用户注册,再补上不迟。脚手架支持选项对比如下:
| 选项 | 我是否勾选 | 理由 |
|---|---|---|
| Next.js | 是 | 页面路由和服务端渲染的基础,T3 的容器 |
| TypeScript | 是 | 类型安全的前提,不选语言层面就毫无意义 |
| Tailwind CSS | 是 | 快速完成 UI,不需要额外设计系统 |
| tRPC | 是 | 全栈类型安全的核心,API 层零重复代码 |
| Prisma | 是 | 数据库建模和迁移好用,类型生成非常顺手 |
| NextAuth | 否 | 单人工具短期内不需要账号体系,减少心智负担 |
用pnpm而不是 npm 也是我的个人偏好:依赖安装速度快,硬链接省磁盘,而且在 monorepo 场景下不会出现依赖提升问题。单项目用起来也完全没问题。
2.2 初始化后的目录结构
初始化完成后,需要先理解几个关键文件的位置,很多人栽在不知道代码该放哪:
src/app/ # 页面路由,App Router 模式 src/app/api/trpc/ # tRPC 的 HTTP 入口 src/server/ # 仅服务端运行的代码 src/server/api/routers/ # 业务接口定义 src/server/api/trpc.ts # tRPC 初始化与上下文 src/server/db.ts # Prisma client 实例 src/trpc/ # 前后端共享的调用封装src/server/api/trpc.ts是最核心的文件。它做两件事:创建 tRPC 实例,并且定义createTRPCContext这个函数。每次请求进来时,上下文会把数据库连接、session 信息塞到一个全局对象里,这样每个 procedure 都能通过ctx.db直接访问数据库。这就是类型安全的第一环——数据库 client 在整个调用链中自始至终存在。
2.3 我做的第一件事:把 SQLite 换成 PostgreSQL
脚手架默认的数据库是 SQLite,本地开发完全够用,但部署到线上就麻烦了——大多数 serverless 平台的文件系统是临时性的,SQLite 文件无法持久化。我一开始就切换到 PostgreSQL,本地用 Docker 跑一个容器:
docker run --name t3code-db -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16然后修改.env里的DATABASE_URL即可。这一步虽然让本地环境稍重一些,但避免了"本地好好的、一上线数据就丢"的惨剧。
3. tRPC + Prisma 的端到端类型安全:核心调用链完全拆解
这一章是整个项目最核心的部分。用一个最简单的功能——"创建代码片段并展示列表"——来完整走一遍 tRPC 和 Prisma 的配合流程。
3.1 先定义 Prisma 数据模型
在prisma/schema.prisma中写了两个模型:
model CodeSnippet { id String @id @default(cuid()) title String description String @default("") code String language String @default("typescript") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt tags Tag[] } model Tag { id String @id @default(cuid()) name String @unique snippets CodeSnippet[] }tags和snippets互为多对多关系,Prisma 会自动在中间层建立一个关系表,不需要手动创建。定义好后执行:
npx prisma migrate dev --name init npx prisma generate关键点来了:npx prisma generate会基于 schema 自动生成完整的 TypeScript 类型。这意味着前端代码里如果写错了一个字段名,TypeScript 会在编译阶段直接报错,而不是等到运行时才发现接口返回了undefined。
3.2 编写第一个 tRPC router
在src/server/api/routers/snippet.ts中写接口逻辑:
import { z } from "zod"; import { createTRPCRouter, publicProcedure, protectedProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ language: z.string().optional() }).optional()) .query(({ ctx }) => { return ctx.db.codeSnippet.findMany({ orderBy: { createdAt: "desc" }, include: { tags: true }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1, "标题不能为空").max(100), description: z.string().max(500).optional(), code: z.string().min(1, "代码内容不能为空"), language: z.string().default("typescript"), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.codeSnippet.create({ data: { title: input.title, description: input.description ?? "", code: input.code, language: input.language, createdBy: ctx.session.user?.name ?? "anonymous", tags: { connectOrCreate: input.tags?.map((name) => ({ where: { name }, create: { name }, })), }, }, }); }), });这里有两个 procedure 类型:publicProcedure任何人都能调用,protectedProcedure会先判断ctx.session是否存在,不存在就直接抛错。入参校验用的是 zod,它的类型定义同时也能被 TypeScript 推断出来,校验规则就是类型规则,不用维护两套。
connectOrCreate是 Prisma 的一个小技巧:如果 tag 已经存在就连接,不存在就创建。这样用户随便输入啥 tag 都不会报唯一约束错误。
3.3 前端调用:服务端直接调函数,客户端走 hook
T3 脚手架会把 tRPC 的调用封装成两个引用路径:~/trpc/server和~/trpc/react。在服务端组件里,调用方式类似于直接调用函数:
import { api } from "~/trpc/server"; export default async function SnippetListPage() { const snippets = await api.snippet.list(); return ( <ul> {snippets.map((s) => ( <li key={s.id}>{s.title}</li> ))} </ul> ); }而在需要交互的客户端组件里,调用方式略有不同:
"use client"; import { api } from "~/trpc/react"; export function CreateSnippetForm() { const utils = api.useUtils(); const createSnippet = api.snippet.create.useMutation({ onSuccess: () => { utils.snippet.list.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); const data = new FormData(e.currentTarget); createSnippet.mutate({ title: String(data.get("title")), code: String(data.get("code")), }); }} > <input name="title" placeholder="标题" /> <textarea name="code" placeholder="代码内容" /> <button disabled={createSnippet.isPending}> {createSnippet.isPending ? "提交中..." : "创建"} </button> </form> ); }utils.snippet.list.invalidate()是 tRPC 的缓存刷新机制。它做的事情是标记snippet.list的缓存已失效,下一次读取时重新请求。不用手动去管理全局 store,也不用自己写事件通知,这是我最喜欢的一点——数据更新后的 UI 同步就像呼吸一样自然。
3.4 改模型的连锁收益
这个链路的最大价值在于:当我给CodeSnippet增加一个新字段,比如isPublic,我只需要改 schema 和 router,前端组件里立刻就能补上这个新字段的提示。整个过程中没有多余的文件需要同步修改,没有 DTO 定义,没有接口文档。对我来说,这种体验就像是"后端代码本身就是前端代码的类型说明书"。
4. 页面开发节奏:服务端组件优先,客户端组件兜底
T3 Stack 搭配 Next.js App Router 后,一个必须想清楚的问题是:什么数据在服务端渲染,什么逻辑放客户端。这个选择直接影响页面性能和代码复杂程度。
4.1 列表页这种只读场景,直接用服务端组件
我的代码片段列表页完全不需要浏览器端 JavaScript 参与。数据请求、HTML 渲染都在服务端完成,用户拿到的直接是完整的 HTML,首屏速度快,对 SEO 也友好。服务端组件里连接数据库、调用 tRPC 都很自然,不会暴露数据库连接信息给浏览器。
4.2 表单和搜索才需要 "use client"
创建表单需要交互,搜索框需要防抖,这些场景必须标"use client"。我的习惯是:默认所有组件都是服务端组件,只有当组件内出现useState、useEffect、事件监听或者需要 tRPC 的 mutation hook 时,才在最顶部加"use client"。一个页面可以有多个客户端组件,它们会被单独打包、隔离,不会把整个页面拖下水。
4.3 代码高亮:shiki 的集成细节
代码片段工具肯定要展示代码高亮。我最初用的是react-syntax-highlighter,后来换成了 shiki——它用的是 VS Code 系的语法解析,高亮效果更精细。但接入时踩了个坑:shiki 默认打包会把所有语言都带进客户端,构建产物大得离谱。解决办法是只注册实际用到的语言:
import { createHighlighter } from "shiki"; const highlighter = await createHighlighter({ themes: ["github-dark", "github-light"], langs: ["typescript", "javascript", "css", "html", "sql", "bash", "python"], });这样构建时会明显缩小体积。如果用户上传了一个未注册语言的文件,我会在页面里降级成纯文本展示,并提示"该语言暂不支持高亮"。
4.4 分页和骨架屏的小优化
列表多了以后,一次性加载全部数据肯定不行。我的处理是:先用take: 20做分页,前端服务端组件里接收page参数,通过 URL 传参。这样翻页时可以直接用<Link>跳转,浏览器自带前进后退。骨架屏其实不需要额外库,Tailwind 的animate-pulse就够用。
5. 部署上线后的三个真实故障:从现象到根因的完整排查
项目部署到 Vercel 之后,我连着遇到三个问题,每个都比较有代表性,这里记录一下完整的排查链路。
5.1 数据库连接超时
第一次部署后,页面请求时快时慢,偶尔直接 504。查 Vercel 日志,发现大量connection refused和timeout。排查过程如下:
- 先确认数据库地址没问题,本地能够正常连接。
- 再看 Vercel 环境变量,确信
DATABASE_URL已设置。 - 最后定位到原因:Vercel 函数是 serverless 架构,每个请求可能启动一个新的运行实例,每个实例都会尝试创建数据库连接;而云数据库(我用的是 Neon PostgreSQL)连接数有上限,当函数实例多了,连接数被打满,后面来的请求只能排队等超时。
解决办法有两步。第一步,连接串加上sslmode=require和数据池参数:
DATABASE_URL="postgres://user:pass@host/db?sslmode=require&connection_limit=5"第二步,在src/server/db.ts里调整 Prisma 客户端的连接池配置:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["error"] : [], }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;这个globalForPrisma模式非常重要:在开发环境热更新时,避免每次都重新创建 PrismaClient 导致连接数暴涨。生产环境下每个 serverless 实例仍然需要控制连接数,所以我额外设置了connection_limit,同时可以加一个pool_timeout避免无谓等待。
5.2 列表页数据更新后不刷新
问题特征:在后台新增一条 snippet,刷新页面,列表里还是旧数据。一开始我以为是缓存问题,清空浏览器缓存也没用。后来发现是 Next.js 的渲染层做了缓存。
服务端组件里调用api.snippet.list()时,框架层面有数据缓存和路由缓存。由于列表页没有任何动态操作标记,Next.js 认为页面是静态的,把渲染结果缓存住了。解决办法是在列表页顶部加:
export const dynamic = "force-dynamic";这会让页面每次请求都重新渲染,不再读取缓存。代价是首屏性能稍微下降,但对一个以内容实时性为主的工具来说,这种取舍完全可以接受。如果后续需要更好的性能,还可以按需用revalidate = 60做 ISR,不过那要配合更细粒度的缓存失效策略。
5.3 表单重复提交导致重复数据
还有一次,我在提交按钮的 loading 状态加好之后,本地测没问题,但同事连点了几下按钮,数据库里出现了三条一模一样的数据。根因是useMutation的isPending只控制了一个按钮的禁用,但用户快速双击时第一次点击的 mutation 还没进入 pending 状态。
解决方式是在onSubmit里做一层防抖,同时给 service 层加幂等判断——提交时带上一个客户端生成的临时 id,数据库里用这个 id 做唯一约束。这样即使网络重试,也不会二次写入:
const createSnippet = api.snippet.create.useMutation({ onSuccess: () => { utils.snippet.list.invalidate(); }, }); function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (createSnippet.isPending) return; // 幂等 key const tempId = crypto.randomUUID(); createSnippet.mutate({ ...input, tempId }); }Prisma schema 里给tempId加@unique,即可从数据库层面兜底。
5.4 边缘函数的兼容性提醒
如果你和我一样把整个 Next.js 应用放在 edge runtime 上跑,需要注意 Prisma 官方对 edge runtime 的支持比较有限,尤其是 PostgreSQL 驱动的某些原生依赖可能无法正常运作。实测下来,最稳妥的方式是用 Node.js runtime 跑业务接口,没必要为了追求边缘计算把数据库操作架在 edge 上。
6. 最后聊几句个人心得
t3code这个项目从零到上线,花的时间比我预估的少很多。真正花时间的不是写代码,而是反复纠结"这个功能应该放服务端还是客户端"以及"缓存机制到底怎么和 tRPC 配合"。一旦把这些底层逻辑摸清,T3 Stack 的开发节奏其实很舒服——你只需要关注业务本身,剩下的类型、接口、缓存联动都交给框架。
有一点我强烈建议新手注意:不要一上来就给自己造复杂的轮子。tRPC 的类型推导和 Prisma 的自动类型生成,只有在项目结构清晰、命名一致时才能发挥最大威力。如果把前后端逻辑揉成一团,再强的类型系统也救不了你。我的习惯是每个业务域一个 router,每个 router 里只放和这个领域相关的 query 和 mutation,宁可多写几个文件,也不要在一个文件里堆几千行。
如果你打算拿 T3 Stack 做自己的下一个工具项目,照着上面的链路走一遍,先跑通"建表-写 router-页面调用-上线",再逐步加入认证、分页、富文本之类的进阶功能。这套组合不会让你失望。