最近折腾了一阵子,总算把一个代号叫t3code的全栈项目从零跑通了。
t3code 这个名字,拆开看其实有两层意思:“T3”对应的是 T3 Stack,也就是目前 TypeScript 社区里非常流行的 Next.js + TypeScript + tRPC + Prisma + Tailwind 技术组合;“code”则代表我这个项目本身要解决的场景——一个代码片段的管理与分享工具。简单说,t3code 就是一个完全基于 T3 Stack 构建的代码收藏夹,支持注册登录、保存代码、自动高亮、按标签筛选、一键复制。它解决的核心问题很朴素:日常开发中那些“到处乱丢的代码片段”,需要一个专门的地方来归类、检索和随时取用。
如果你刚接触 T3 Stack,或者正准备做一个中型全栈 TypeScript 项目,那这篇内容会比较值得参考。下面我会从技术选型、数据模型、tRPC 路由、认证接入、部署排查这几个方面,把 t3code 从想法到落地的过程完整拆开来讲,并附上不少直接能用的代码片段。
1. 整体设计与技术选型
1.1 为什么叫 T3,它到底解决了什么问题
T3 Stack 的核心并不是某个新框架,而是一种“怎么搭全栈项目”的约定。它把目前 TypeScript 生态里几个配合度很高的库组合在一起:Next.js负责页面和服务端渲染,tRPC负责前后端接口调用,Prisma负责数据库访问。这套组合最吸引我的地方是端到端类型安全,也就是说你在后端定义好一个业务接口后,前端调用时能得到完整的类型提示,字段名、参数类型、返回结构统统自动带过去,不需要另写一份接口文档,也不容易因为前后端定义不一致而踩坑。
用生活化一点的类比来讲,这就像你在公司内部下工单,不需要每次都用标准表格重新填写一遍需求,只要在同一个协作系统里选一下编号,后台就知道你要什么,字段也对得上。tRPC 就是这个“协作系统”,它把类型信息从前端一直延伸到数据库,中间几乎没有断层。
t3code 我选用这套技术栈,还有一个现实原因:它很适合中小型全栈项目。你不需要单独维护一个 NestJS 或 Express 后端,也不需要为几个页面单独搭一套 GraphQL 网关。Next.js 的 API 层配合 tRPC,已经完全够用。等到将来真需要对外开放 API,再单独抽象出一层也不迟。
1.2 tRPC 与 REST / GraphQL 的取舍
很多人在选型时会问:为什么不直接用 REST?或者干脆上 GraphQL?我最初也纠结过,后来实际对比之后发现,项目规模不同,选择真的差很多。
REST 的问题不在于不好用,而是在于“类型安全”这件事需要额外投入。每个接口都要定义请求参数、响应结构,前端还要维护对应的 interface,一旦字段变动,改起来很痛苦。GraphQL 的问题则在于“重”,你需要引入 schema、resolver、codegen,还得处理缓存和性能问题,这对一个代码片段管理工具来说明显杀鸡用了牛刀。
tRPC 的定位刚好卡在中间。它没有自己的协议,本质上还是走 HTTP JSON 请求,但通过 TypeScript 的类型推导把RPC 函数签名直接暴露给前端。在你的 React 组件里调用trpc.snippet.create.useMutation(),返回结果、入参类型全部自动推导,连网络请求的细节都被封装好了。写代码的时候会觉得特别顺手,很多错误在编译期就能发现,而不是等部署到线上才发现字段写错了。
1.3 数据模型与服务边界:让 Prisma 一开始就“够用”
t3code 的核心数据模型我设计得非常简单,因为代码片段工具最怕过度设计。业界有句话叫“先让业务跑起来,再谈抽象”,我完全同意。当时我建了三个模型:User、Snippet、Tag。
Snippet表保存代码内容、语言类型、标题、描述,并通过外键关联到创建者User;Tag通过一张隐式的关联表与Snippet建立多对多关系。没有引入额外的工作流、收藏、评论这些复杂概念,因为第一版的目标是验证整套技术链是否顺畅。
从服务边界上看,t3code 的前后端并不是完全分离的。因为用了 Next.js,页面路由、服务端渲染和 API 都在同一个应用里,服务边界主要体现在目录结构上。src/server放 Prisma client、tRPC router、上下文逻辑;src/pages放 Next.js 页面;src/components放可复用组件。这样的分层对小型项目来说足够清晰,也不会让代码堆成一团。
2. 核心细节解析与实操要点
2.1 项目初始化:create-t3-app 到底该怎么选
如果你也想复刻 t3code,推荐直接用官方脚手架:
pnpm create t3-app@latest t3code交互式提问里会让你选择需要启用的功能模块。我当时的勾选情况是这样的:
- Next.js:默认启用,使用 App Router 或 Pages Router 看你习惯。我选了 Pages Router,因为 create-t3-app 的模板对 Pages Router 支持得非常稳定。
- TypeScript:默认启用,没什么好说。
- Prisma:必选。没有 Prisma,T3 Stack 只剩空壳。
- tRPC:必选。这是整个链路的核心。
- Tailwind CSS:必选。做代码管理工具的编辑器界面时,Tailwind 的效率确实高。
- NextAuth.js:选了。认证直接交给标准库处理,不用自己造轮子。
还有 ESLint 和 Prettier,我建议都勾上。团队项目里代码风格统一这件事,越早做越好。
初始化完成后,模板会生成一套参考实现:一个包含示例 post 的 tRPC router、Prisma schema、NextAuth 配置。第一件事是把示例内容跑通,再开始改成自己的业务。很多人喜欢先删掉模板再推倒重来,其实没必要,先顺着模板理解一遍数据流动,反而能更快弄懂 T3 Stack 的设计思路。
2.2 Prisma 数据模型编写与迁移
Prisma schema 是 t3code 的“数据地基”。为了让代码片段能按标签筛选,我设计了多对多关系。
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String? @unique image String? snippets Snippet[] accounts Account[] sessions Session[] } model Snippet { id String @id @default(cuid()) title String description String? code String @db.Text language String @default("plaintext") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String tags Tag[] } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] }设计上有几点值得特别注意:
- 主键用
cuid()而不是自增数字。在分布式部署和 serverless 环境下,字符串主键不需要查数据库拿序列号,生成更自然。 code字段用@db.Text。代码片段动辄几百行,如果默认用String映射成varchar,很容易在长度边界上踩坑。language字段给一个默认值plaintext,这样即使新增片段时忘记选语言,前端也不会渲染失败。
模型写好后,先做一次迁移:
pnpm prisma migrate dev --name init pnpm prisma generatemigrate dev会生成迁移文件并同步数据库结构,generate会重新生成类型安全的 Prisma Client。如果改了 schema 之后发现 IDE 里的类型没更新,多半是忘了跑prisma generate。
2.3 tRPC Router 与类型导出:后端接口的正确打开方式
在 T3 Stack 中,你的“接口”就是 tRPC router。规范写法是把每个业务域拆成独立文件,再汇总到根 router。t3code 里我建了一个snippetRouter,用来处理代码片段的新增、列表、删除和按标签过滤。
// src/server/api/routers/snippet.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ 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().max(30).default("plaintext"), tagNames: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { const authorId = ctx.session.user.id; const snippet = await ctx.prisma.snippet.create({ data: { title: input.title, description: input.description, code: input.code, language: input.language, authorId, tags: input.tagNames ? { connectOrCreate: input.tagNames.map((name) => ({ where: { name }, create: { name }, })), } : undefined, }, include: { tags: true }, }); return snippet; }), getAll: publicProcedure .input( z.object({ tag: z.string().optional(), }) ) .query(async ({ ctx, input }) => { return ctx.prisma.snippet.findMany({ where: input.tag ? { tags: { some: { name: input.tag } } } : undefined, include: { tags: true, author: { select: { name: true } } }, orderBy: { createdAt: "desc" }, }); }), });有两个点容易出错:
protectedProcedure跟publicProcedure的区别。写操作必须要求登录,所以用protectedProcedure,未登录会直接抛错;列表页允许公开访问,所以用publicProcedure。不要搞反,否则要么匿名用户能改数据,要么未登录用户连看都看不了。- 输入校验用 zod。tRPC 和 zod 天然配合,前端提交的数据会在后端入口处做一次校验,不满足条件就直接返回错误。这样即使有人在浏览器里手动构造请求,也不会绕过数据合法性验证。
这个 router 写完之后,还需要挂载到根 router 上:
// src/server/api/root.ts export const appRouter = createTRPCRouter({ snippet: snippetRouter, // ...其他router }); export type AppRouter = typeof appRouter;AppRouter这个类型就是前后端共享的“契约”。前端引它去创建 hooks,后端用它来启动服务器。
2.4 前端调用与 React Query 的联动
在 T3 Stack 中,前端并不直接发 fetch 请求,而是通过 tRPC 自动生成的 hooks。拿 t3code 新增片段的场景举例:
import { api } from "~/utils/api"; const createSnippet = api.snippet.create.useMutation(); async function handleSubmit(data) { await createSnippet.mutateAsync({ title: data.title, code: data.code, language: data.language, tagNames: data.tags, }); }你会发现这里根本不需要手写 API 地址,也不需要声明返回类型。createSnippet.mutateAsync的入参会自动和snippet.create的 zod input 对齐,返回值也和后端 router 的返回类型完全一致。
接着,列表页查询可以这样接上 React Query:
const { data: snippets, isLoading } = api.snippet.getAll.useQuery({ tag: activeTag, });React Query 负责缓存、重试和重新验证。t3code 里我做了“切换标签后自动重新拉取数据”的需求,只需要传不同的参数给useQuery,React Query 会基于参数自动生成不同的缓存 key。这个体验比手动在useEffect里请求数据要舒服太多了。
2.5 Tailwind 样式与代码高亮方案
样式方面没什么好说的,Tailwind 作为 T3 Stack 的一部分,开箱即用。比较值得讨论的是代码高亮方案。t3code 里我需要把用户保存的code字段渲染成带颜色高亮的代码块,试过两种方案:
- Prism.js 的客户端高亮:简单,但容易在首屏加载时出现样式闪烁,而且大文件的 tokenize 过程会卡主线程。
- Shiki 的服务端高亮:在服务端渲染过程中直接把代码变成带高亮 span 的 HTML,体验好很多。
最终我选的是 Shiki,结合 Next.js 服务端渲染的效果确实更顺滑。用法也不复杂:
import { getHighlighter } from "shiki"; const highlighter = await getHighlighter({ themes: ["github-dark"], langs: ["typescript", "javascript", "python", "sql"], }); function highlight(code: string, lang: string) { return highlighter.codeToHtml(code, { lang, theme: "github-dark" }); }这里稍微注意一下:不要在客户端 bundle 里引入 Shiki,否则体积会很难看。把它放在服务端工具函数里,通过dangerouslySetInnerHTML输出到前端即可。
3. 实操过程与核心环节实现
3.1 环境准备:Node、数据库、包管理器一个都不能少
动手之前,先确认本地环境。t3code 我用的开发环境是:
- Node.js 18 LTS 以上。太老版本的 Node 对 Next.js 14 的支持不够好。
- 包管理器用的pnpm。create-t3-app 对 pnpm 的支持最顺,install 速度也明显比 npm 快。
- 数据库用的PostgreSQL。本地开发可以直接用 Docker 跑一个实例,也可以使用 PostgreSQL 云服务。
Docker 起库最简单的方式:
docker run --name t3code-postgres -e POSTGRES_PASSWORD=localpass -p 5432:5432 -d postgres:16然后创建一个.env文件,填入数据库连接字符串:
DATABASE_URL="postgresql://postgres:localpass@localhost:5432/t3code?schema=public" NEXTAUTH_SECRET="随便生成一串足够长的随机字符串" NEXTAUTH_URL="http://localhost:3000"NEXTAUTH_SECRET可以用openssl rand -base64 32生成。这个环境变量在本地和线上都会用到,务必保管好。
3.2 t3code 的核心业务闭环:保存片段到渲染列表
我从模板的示例业务改成自己的业务时,做了一套最小闭环:保存一段代码 -> 存储到数据库 -> 首页展示 -> 点击标签筛滤。
保存页面是一个受控组件,逻辑很简单:
const [code, setCode] = useState(""); const [language, setLanguage] = useState("typescript"); const createSnippet = api.snippet.create.useMutation({ onSuccess: () => { router.push("/"); }, onError: (err) => { alert(err.message); }, });首页则负责拉取数据:
const { data: snippets } = api.snippet.getAll.useQuery(); return ( <div className="space-y-4"> {snippets?.map((snippet) => ( <div key={snippet.id}> <h2>{snippet.title}</h2> <div dangerouslySetInnerHTML={{ __html: highlight(snippet.code, snippet.language) }} /> {snippet.tags.map((tag) => ( <span key={tag.id}>{tag.name}</span> ))} </div> ))} </div> );别看这段代码简单,整个链路已经完整走通了:后端拿到数据库里的 snippet 数据,通过 tRPC 类型化返回到前端组件,前端调用 Shiki 高亮后渲染出来。类型错误在编译阶段就被 TypeScript 拦住了,不需要靠 Postman 去手动验证接口长什么样。
3.3 认证接入与授权控制
t3code 里用户需要登录后才能保存代码,所以我接入了 NextAuth.js。create-t3-app 模板默认会创建一个auth.ts文件,配置 providers。为了演示方便,我使用 Credentials Provider(邮箱密码登录),也可以加 GitHub Provider。
// src/server/auth.ts import { getServerSession, type NextAuthOptions } from "next-auth"; import Credentials from "next-auth/providers/credentials"; export const authOptions: NextAuthOptions = { session: { strategy: "jwt", }, providers: [ Credentials({ credentials: { email: { label: "邮箱", type: "email" }, password: { label: "密码", type: "password" }, }, async authorize(credentials) { const user = await prisma.user.findUnique({ where: { email: credentials?.email }, }); if (!user) return null; // 生产环境建议使用 bcrypt 校验密码 return { id: user.id, email: user.email, name: user.name }; }, }), ], };然后在 tRPC 的 context 里注入 session,就能在protectedProcedure中直接取到当前用户:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });这里最容易犯的错是:在context里没有把 session 传入,导致后端所有需要判断当前用户身份的接口都拿不到用户信息。记得在src/server/api/trpc.ts里通过getServerSession或getToken初始化 session。
3.4 部署到生产环境的完整步骤
部署我选的是 Vercel,数据库用的 Postgres 云服务。整个部署过程分几步:
- 把代码推到 GitHub,然后导入 Vercel。
- 在 Vercel 项目的环境变量里配置
DATABASE_URL、NEXTAUTH_URL和NEXTAUTH_SECRET。 - 在构建命令里加一步:
prisma db push && next build。如果数据库变更需要迁移,就用prisma migrate deploy。 - 等待构建完成,访问生成域名测试。
有一个很容易漏的点:serverless 环境下 Prisma 连接数不能无限创建。建议给 Prisma Client 加一层全局单例:
// src/server/db.ts import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: ["warn", "error"], }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;如果不加这一层,开发模式下 HMR 每次刷新都会新建一个 PrismaClient 连接池,很容易报“too many connections”。线上环境 Vercel 函数冷启动时也容易因为连接数超限而失败。
4. 常见问题与排查技巧实录
4.1 前后端类型没有同步
T3 Stack 虽然类型安全,但也有模糊地带:当你修改了后端 tRPC router 后,前端有时会提示 TS 类型错误,或者旧缓存一直不更新。排查思路很简单:
- 确认是否重新运行了
prisma generate。修改 Prisma schema 后,这个步骤不能省。 - 确认是否重启了
pnpm dev。Next.js 对 tRPC router 的类型缓存偶尔不会热更新那么快。 - 确认前端引用的是否是
AppRouter导出的最新类型。如果在根 router 里忘记挂载新 router,前端永远调不到新接口。
这些问题不是玄学,基本都能通过“重启 dev + 重新生成类型”解决。
4.2 Prisma 连接超时或连接数满
服务端报错常见的有两种:
P1001: Can't reach database server P2024: Timed out fetching a new connection from the poolP1001大概率是 DATABASE_URL 写错了,或者数据库防火墙没放开访问。P2024则是连接池问题,处理办法上面提过:全局单例 + 适当调大连接池上限:
DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=10"还有一个额外技巧:Prisma 冷启动加载时间在 Serverless 环境里会比较明显。如果觉得慢,可以把 Prisma Client 放到边缘函数之外,或者预热函数。
4.3 NextAuth 登录跳转与回调 URL 异常
开发环境一切正常,部署到 Vercel 后点登录却跳到一个陌生域名或者报callback URL mismatch,通常有三个原因:
NEXTAUTH_URL没有设置为线上正式域名。- 本地
.env和线上环境变量不一致,登录用的 provider 回调解码失败。 - 使用 GitHub Provider 时,授权回调地址没加
https://你的域名/api/auth/callback/github。
确认这三点之后,绝大多数回调问题都能解决。另一点是:Vercel 预览环境可能会有动态域名,建议在authOptions里通过NEXTAUTH_URL覆盖默认域名逻辑,避免预览环境跳到错误地址。
4.4 部署后 API 路由返回 404 或 500
如果页面能正常打开,但 tRPC 请求全部 404,第一反应检查是不是appRouter的路径写错了。在 Next.js 中,tRPC 的 API handler 需要这样创建:
// src/pages/api/trpc/[trpc].ts import { createNextApiHandler } from "@trpc/server/adapters/next"; import { appRouter } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; export default createNextApiHandler({ router: appRouter, createContext: createTRPCContext, });文件路径少了[trpc]动态参数,或者文件名拼错,就会让所有请求都落到 404。返回 500 则多半是 context 里 session 获取失败,检查createTRPCContext是否在服务端正确读取了req和res。
4.5 常见问题速查表
| 问题 | 可能原因 | 解决办法 |
|---|---|---|
| 前端调不到新增 router | 根 router 未挂载 | 在appRouter里加一行xxx: xxxRouter |
| Prisma 类型报错 | schema 修改后未生成 client | pnpm prisma generate |
| localStorage 没有 session,接口仍可修改 | protectedProcedure没生效 | 检查 tRPC context session 注入 |
| 部署后样式错乱 | Tailwind 内容配置不完整 | 在tailwind.config.ts中加入所有页面路径 |
| 代码类型非常宽泛 | 输入未用 zod 约束 | 在 procedure 的input中补充 zod schema |
| 数据库连接出现大量空闲连接 | Serverless 环境连接池泄漏 | 全局单例化 PrismaClient |
4.6 一个容易被忽视的细节:删除或更新片段时的关联数据
t3code 第一版里没有做删除功能,后来补的时候踩了一个坑:删除 Snippet 时,它的关联标签并不会自动清理。如果用户删了所有使用某标签的片段,Tag表里会留下很多孤立的标签数据。
我的处理方式是在删除接口里手动检查:
deleteSnippet: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const snippet = await ctx.prisma.snippet.findUnique({ where: { id: input.id }, }); if (!snippet || snippet.authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } await ctx.prisma.$transaction([ ctx.prisma.snippet.delete({ where: { id: input.id } }), ctx.prisma.$executeRaw`DELETE FROM "Tag" WHERE "id" IN ( SELECT t."id" FROM "Tag" t LEFT JOIN "_SnippetToTag" st ON st."B" = t."id" WHERE st."A" IS NULL )`, ]); return { success: true }; }),这里还顺带做了权限校验:只有片段作者才能删除。业务上这个点很多人会漏掉,但安全上非常关键。
5. 一点扩展想法与个人体会
项目跑通之后,我最大的体会是:T3 Stack 的“类型安全”不仅是一个营销概念,它真的能改变日常编码的感受。以前做全栈项目时,前后端接口对不齐是家常便饭;但在 t3code 里,只要后端把类型写好,前端就永远不用猜字段名,也基本不会出现“线上才发现字段拼错”的低级失误。
如果这个项目要继续扩展,我接下来可能会做三个方向:一是给代码片段增加分享链接,通过只读页面公开某一段代码;二是添加团队协作空间,把片段按项目分组,然后共享给同事;三是做一个浏览器收藏夹的小插件,在浏览技术文档时一键把优质代码片段收入 t3code。这些方向都还在 T3 Stack 的能力覆盖范围内,不需要推倒重来。
最后分享一个很实用的小技巧:在本地写 t3code 时,我习惯把pnpm dev和pnpm prisma studio两个终端同时开着。改 schema、看数据、调接口都能即时反馈,比盲写一遍再回头看数据库舒服得多。代码片段管理工具本身虽然不是特别复杂,但用对技术栈,整个过程会顺畅到让人上瘾。