最近把一个全栈试验项目收了尾,项目代号就叫 t3code。名字不是我随便敲的,t3 指的是 TypeScript、Tailwind、tRPC 这三件套,code 则是核心使用场景——把平时散落在各种仓库、聊天记录、临时文件里的代码片段统一收进来,既能按标签检索,也能在不同设备上继续编辑。很多第一次看到这个代号的朋友会问,t3code 是不是某个开源框架?其实它就是一个基于 T3 技术栈搭建的全栈示例应用,规模不大,但该有的东西都有:数据库建模、类型安全接口、登录鉴权、响应式界面、线上部署。这篇文章就把我在这个项目里的技术选型、关键实现、踩坑记录全部盘一遍,适合想完整跑通一个全栈 TypeScript 项目的人参考。
1. 项目定位与技术选型解析
1.1 t3code 到底想解决什么问题
代码片段管理听起来是个小需求,真做起来会发现它的复杂度很考验技术选型。最原始的痛点是这样:我经常要在一个旧项目里翻之前写过的一段正则,在某台机器的临时目录里找一个 debounce 封装,甚至在手机备忘录里翻技术方案链接。散落位置太多,导致重复造轮子的概率特别高。
所以我对 t3code 的功能预期很明确:列表加载要快,检索要灵活,标签体系要足够方便,最好还能做基础的登录隔离,不让所有内容裸奔。更进一步,我不想为这种中小型需求单独维护一个 Node 后端加一个管理后台,那样太重了。这时候全栈应用模型就是最合适的:一个 Next.js 应用同时承担页面渲染和接口服务,数据层用 Prisma 做建模和迁移,接口层用 tRPC 直接暴露类型安全的调用函数。
这个定位决定了我不需要微服务,不需要独立的前后端仓库,更不需要复杂的消息队列。先把最小闭环跑起来:用户登录,查看 snippet 列表,创建新 snippet,按标签过滤,删除自己的 snippet。后续如果需要全文搜索、导出、协作,再在同一个工程里往上叠。
1.2 为什么选 T3 组合而不是自己拼技术栈
如果你去翻 create-t3-app 的文档,会发现 T3 从来不是某个框架,而是一组工具的约定组合:Next.js 负责应用框架,TypeScript 保证类型,Tailwind 解决样式,tRPC 做端到端类型安全接口,Prisma 操作数据库,NextAuth 处理登录。看起来东西很多,但它解决一个很实际的问题:类型安全在前后端之间怎么穿透。
以前写 REST 接口,前端要用 axios 调GET /api/snippet,后端的响应类型、错误类型都得自己维护,稍有不同步前端就要靠猜。手写fetch加as断言能跑,但跑久了接口一多就是事故高发区。tRPC 的做法是把服务端路由函数直接暴露给客户端调用,前端 import 一个api.snippet.list.useQuery(),函数签名、入参校验、返回类型完全和服务端一致。简单说,只要服务端编译通过,前端拿到的数据结构就不会在类型层面骗你。
再有就是 T3 对“约定优于配置”的坚持。create-t3-app 会把目录、依赖、基础配置一次生成完毕,你不需要在一堆空文件夹里自己纠结 src 下怎么组织。它对新手非常宽容:即使你没用过 tRPC,光看生成出来的代码也能大概理解查询和变更的写法。作为个人项目,我优先保证一段时间的可维护性,而不是花大量时间搭一套自认为完美的架构。
代价也客观存在。T3 要求你必须同时理解 Next.js 的 App Router、Prisma 的 client 实例、tRPC 的 Context 注入,少一环都容易绕晕。这些概念在文档里各写各的,第一次串起来还是要花点功夫。但一旦把整条链路跑通,后面加功能就很舒服了。
2. 数据模型与核心落点:Prisma 建模与种子数据
2.1 三张表的关系为什么这样设计
t3code 的 v1 版本数据库里只放了三个模型:User、Snippet、Tag。有人会问,标签为什么不能是一个逗号分隔字符串?简单数据能这么干,但一旦你需要在侧边栏按标签聚合统计,或者点了某个标签要反查所有相关 snippet,用字符串就会被迫写一堆模糊查询。用关系表做多对多,语义清楚,索引也方便。
下面是prisma/schema.prisma的核心片段:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? snippets Snippet[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Snippet { id String @id @default(cuid()) title String description String? language String @default("text") content String @db.Text tags Tag[] author User? @relation(fields: [authorId], references: [id]) authorId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }几个关键点说明一下:
content用@db.Text,因为代码片段往往几百行,默认 VARCHAR 长度不够,容易出现奇怪的中途截断。Tag.name加@unique约束,配合后面的connectOrCreate操作,可以避免并发创建重复标签。authorId是可选的,这是为了让公开示例能展示,如果要做严格私有化,把User?改成User并加@default需要另外处理。- 没有给
title加唯一约束,因为实际上存在同一个代码片段被改造成不同版本的情况,唯一约束反而碍事。
为什么我不直接塞一个 JSON 字段来存 tags?因为 Prisma 对 Postgres 的Json支持不错,但涉及“按标签过滤”时你仍然要写原生查询或者把数据拉出来再过滤,既绕又不优雅。关系模型虽然会多一张表,但对 t3code 这种低频写入的实时场景完全没压力。
2.2 种子数据与本地开发准备
模型定好之后,第一步是跑迁移和生成客户端:
npx prisma migrate dev --name init npx prisma generate接着在package.json里配好prisma的 seed 命令,我用 npm:
"prisma": { "db": { "seed": "tsx prisma/seed.ts" } }然后写prisma/seed.ts,给数据库放一点初始数据:
import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const user = await prisma.user.upsert({ where: { email: "dev@example.com" }, update: {}, create: { email: "dev@example.com", name: "Dev User", }, }); const baseTags = ["react", "nextjs", "trpc", "prisma", "typescript"]; for (const name of baseTags) { await prisma.tag.upsert({ where: { name }, update: {}, create: { name }, }); } const snippet = await prisma.snippet.create({ data: { title: "tRPC query 最小示例", description: "在客户端调用 tRPC query 的示例", language: "typescript", content: `const data = api.snippet.list.useQuery({ q: "trpc" });`, authorId: user.id, tags: { connect: [{ name: "trpc" }, { name: "nextjs" }], }, }, }); console.log("seed done:", snippet.id); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => { void prisma.$disconnect(); });执行npm run db:seed之后,库里就有了一个用户、五个标签和一条示例片段。开发时我习惯保持种子数据可以重复执行,所以能 upsert 的都 upsert,不能 upsert 的至少保证不报错中断。
3. 前后端衔接:tRPC 路由与鉴权细节
3.1 路由拆解:增删查应该怎么设计
tRPC 的写法非常像普通 TypeScript 函数,但背后的类型推导只要写错一个地方,前端立刻报错。我把 snippet 相关路由放在src/server/api/routers/snippet.ts:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure, } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ q: z.string().optional(), tag: z.string().optional(), }), ) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), ...(input.q ? { OR: [ { title: { contains: input.q } }, { content: { contains: input.q } }, ], } : {}), }, include: { tags: true, author: true }, orderBy: { createdAt: "desc" }, take: 50, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), description: z.string().max(500).optional(), language: z.string().max(30).default("text"), content: z.string().min(1), tags: z.array(z.string()).default([]), }), ) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { title: input.title, description: input.description, language: input.language, content: input.content, authorId: ctx.session.user.id, tags: { connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })), }, }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const snippet = await ctx.db.snippet.findUnique({ where: { id: input.id }, }); if (!snippet) { throw new Error("Snippet not found"); } if (snippet.authorId !== ctx.session.user.id) { throw new Error("Not allowed"); } await ctx.db.snippet.delete({ where: { id: input.id } }); return { ok: true }; }), });设计思路很简单:
list用publicProcedure,是为了让演示页面的列表逻辑不强制登录,省得每次看效果都要走一遍登录流程。真正上线时一般改成protectedProcedure或者根据 session 过滤 snippet 的 owner。create用protectedProcedure,因为只有登录用户才能新增内容。delete里我特意做了作者校验,从原理上说这种“资源归属”判断应该在服务端做,不能只在前端隐藏删除按钮。真实业务中还会有很复杂的角色判断,这里先落一个最小判断。
3.2 Context、鉴权与安全边界
tRPC 的 Context 是连接 NextAuth 和数据库的关键。默认生成的src/server/api/trpc.ts里要做一个统一入口:
import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { ZodError } from "zod"; import { getServerAuthSession } from "~/server/auth"; import { db } from "~/server/db"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { db, session, ...opts }; }; const t = initTRPC.context<typeof createTRPCContext>().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const createCallerFactory = t.createCallerFactory; export const createTRPCRouter = t.router; export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user }, }, }); });这段代码有几个容易忽略的细节。superjson是必需的,它负责序列化 Date、BigInt 这些 JS 原生不直接被 JSON 支持的类型。没有它,Prisma 返回的日期字段到客户端就变成字符串,类型系统也会懵。errorFormatter里把 ZodError 的flatten结果暴露给前端,这样表单提交时字段错误能在页面上逐项提示,而不是只看到一个冷冰冰的 “invalid input”。
鉴权层面我要多说一句。NextAuth 的getServerAuthSession()依赖服务端 cookie,所以在 tRPC 的 Context 里调用是安全的,它不会被浏览器执行环境调用。如果你把createTRPCContext当普通函数在客户端调用,很容易出现 session 永远为 null 的坑。
NextAuth 的配置我用了 GitHub OAuth,方便省事:
import { getServerSession, type NextAuthOptions } from "next-auth"; import GitHub from "next-auth/providers/github"; import { db } from "~/server/db"; export const authOptions: NextAuthOptions = { session: { strategy: "jwt" }, providers: [ GitHub({ clientId: process.env.AUTH_GITHUB_ID ?? "", clientSecret: process.env.AUTH_GITHUB_SECRET ?? "", }), ], callbacks: { jwt({ token, user }) { if (user) token.id = user.id; return token; }, session({ session, token }) { if (token.id) { session.user.id = token.id as string; } return session; }, }, }; export const getServerAuthSession = () => getServerSession(authOptions);本地开发时在.env里填好AUTH_GITHUB_ID和AUTH_GITHUB_SECRET,点一次 OAuth 授权就能登录。生产环境千万别把这些值提交到代码仓库,Vercel 后台的环境变量配置比硬编码靠谱得多。很多朋友在部署后遇到登录失效,一大半是AUTH_SECRET没设置。
4. 界面与交互的快速成型:Tailwind 与客户端状态
4.1 布局结构与 Tailwind 的快速组合
页面布局我采用的是编辑器式结构:左侧是标签导航和 snippet 列表,右侧是编辑区域和详情预览。这种布局非常适合代码片段管理,因为用户不会一直点击跳转,而是希望在列表和正文之间快速切换。
用 Tailwind 写这种布局非常顺手。我没有引入任何重型 UI 组件库,因为 T3 默认 Tailwind 已经能满足 95% 的界面需求,组件库引入后反而要处理主题定制和样式覆盖问题。整个布局的核心就是一个 flex 两栏结构:
export function SnippetLayout({ children }: { children: React.ReactNode }) { return ( <div className="flex h-screen overflow-hidden bg-zinc-50"> <aside className="w-72 shrink-0 border-r border-zinc-200 bg-white p-4"> {/* 标签导航和搜索框 */} </aside> <main className="min-w-0 flex-1 overflow-y-auto p-6"> {children} </main> </div> ); }这里有个实务细节:父容器用了overflow-hidden,子容器用overflow-y-auto,才能保证页面在长列表情况下不会滚动整个视口,而是只在右侧内容区滚动。min-w-0是 flex 子项防止内容溢出的经典解法,没有它,内部超长代码文本会把布局撑破。
配色方面我只用了 zinc 色阶加一个主题色的思路,Tailwind 默认色板就够了。深色模式如果需要的话,在tailwind.config.ts里配置darkMode: "class",然后在顶层组件做一个 class 切换即可。
4.2 表单校验与列表实时更新
新增 snippet 的页面,我用客户端组件加 tRPC mutation 处理。这里的一个核心体验问题是:提交成功之后,列表必须立刻刷新。很多第一次写 tRPC 的人会忘记这一步,导致新增内容一直不出现。
正确做法是用api.useUtils()拿到上下文工具,再在onSuccess里 invalidate 对应查询:
"use client"; import { useState } from "react"; import { api } from "~/trpc/react"; const emptyForm = { title: "", description: "", language: "typescript", content: "", tags: [] as string[], }; export function SnippetForm() { const utils = api.useUtils(); const [form, setForm] = useState(emptyForm); const createSnippet = api.snippet.create.useMutation({ onSuccess: async () => { await utils.snippet.list.invalidate(); setForm(emptyForm); }, onError: (error) => { alert(error.message); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); createSnippet.mutate(form); }; return ( <form onSubmit={handleSubmit} className="space-y-4"> <input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="标题" className="w-full rounded-lg border border-zinc-300 p-2" /> <textarea value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} rows={10} className="w-full rounded-lg border border-zinc-300 p-2 font-mono" /> <button type="submit" disabled={createSnippet.isLoading} className="rounded-lg bg-zinc-900 px-4 py-2 text-white disabled:opacity-50" > {createSnippet.isLoading ? "保存中..." : "保存"} </button> </form> ); }需要注意,这里我没用 React Hook Form,因为表单字段就四个,用useState管理足够。等项目字段数量上来了再考虑引入react-hook-form加zodResolver的组合,能自动把服务端的 ZodError 映射到表单错误。
更复杂的业务场景里推荐把输入标签做成一个 TagInput 组件,输入后回车生成标签 pill,再存进form.tags。我这里偷了个懒,把标签统一做成默认几个高频项供用户勾选,逻辑更简单,也能覆盖大部分使用场景。
5. 部署上线与常见问题排查
5.1 Vercel 部署前必须确认的配置
本地开发时我用 SQLite 文件库,跑起来零依赖。一旦要部署到线上,SQLite 在 serverless 环境里会遇到磁盘临时写入限制,所以我换成了 Postgres。这里有一个容易忽略的坑:本地生成的dev.db和线上 Postgres 的 schema 并不冲突,但你必须重新执行一次数据库迁移。
在 Vercel 上部署前,按这个顺序走:
- 创建 Postgres 数据库,拿到
DATABASE_URL。 DATABASE_URL=配置到 Vercel 项目环境变量中。- 在本地执行
prisma migrate deploy或直接在远端数据库执行迁移脚本。 - 使用
openssl rand -base64 32生成AUTH_SECRET,配置到 Vercel。 - GitHub OAuth 在 GitHub 后台填线上域名地址,比如
https://你的域名,回调地址写https://你的域名/api/auth/callback/github。 - 重新部署一次,确保构建时能拿到新的环境变量。
这里我还遇到过一个非常隐蔽的问题:Vercel 的构建环境默认不会读取.env文件,如果你在代码里用process.env.DATABASE_URL,本地能跑,线上构建却会报PrismaClientInitializationError。一定是环境变量没配齐,或者配完后没有触发新的部署。
5.2 高频报错和排查速查表
t3code 从开发到上线,我整理了一份问题清单,很多问题我在其他项目里也反复见到,直接列成表格方便检索:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
PrismaClientInitializationError或Environment variable not found: DATABASE_URL | 环境变量没设置,或构建时未读取 | 在 Vercel / 本地.env配置DATABASE_URL后重新构建 |
| 登录后一直跳回首页,无法保持登录态 | AUTH_SECRET缺失 | 生成随机字符串并设置到环境变量 |
| GitHub 登录报 redirect_uri 错误 | OAuth 回调地址不匹配 | 在 GitHub OAuth 后台填写完整回调地址 |
| 新增 snippet 后列表不更新 | tRPC mutation 成功但未 invalidate 查询 | 调用utils.snippet.list.invalidate() |
ZodError: invalid input但页面没有具体提示 | 默认 errorFormatter 未暴露 zodError | 配置errorFormatter返回zodError.flatten() |
| 构建时 Tailwind 样式大量丢失 | tailwind.config.ts的content数组漏了组件目录 | 检查 content glob 是否包含./src/**/*.{ts,tsx} |
| 长内容显示不全,页面整个滚动 | 缺少min-w-0或错误的 overflow 层级 | 父容器overflow-hidden,子容器overflow-y-auto |
| 本地能用,线上访问数据库超时 | 数据库连接池或白名单问题 | 确认 Postgres 服务商允许 Vercel 的 IP 访问或使用连接池 |
排查技巧上我想强调一个习惯:遇到报错先看服务端日志,而不是只盯前端错误提示。tRPC 的报错往往在前面是TRPCClientError,但真正的堆栈在服务端函数里。Vercel 的 Function Logs 页面能看到完整错误路径,省去很多瞎猜的时间。
另外在本地开发时,npm run dev启动后如果改了schema.prisma,一定要先npx prisma migrate dev再重启 dev server。Prisma Client 的类型是在 generate 时生成的,你不做这一步,编辑器里可能还在用旧类型,看起来没报错,运行起来就会各种诡异。
最后分享一个我在 t3code 里实践的体会:全栈项目最忌一开始就把架构铺得太大,T3 这套组合真正的价值不是某个框架多牛逼,而是它把一条清晰的数据流从数据库直通到界面。以后想在这个项目上加全文搜索,可以在 Prisma 里加@fulltext索引并换掉contains查询;想加分享功能,只需要把 snippet 的isPublic字段加上,再在list的 where 条件里判断。沿着这条已经跑通的链路往后扩展,比从零搭一个“完美架构”的实际体验要好得多。如果你也在做一个中小型工具类项目,t3code 这个节奏值得参考:小步快跑,先让数据流闭环,再持续加功能。