
如果你是个天天写 TypeScript 的开发者大概率已经听说过 T3 Stack 这个名字也一定被 tRPC 那种“前端直接调用后端函数还带类型提示”的体验惊艳过。t3code 就是我基于这套技术栈从零到一做完的一个真实项目——一个面向开发者的代码片段管理与分享工具支持创建、打标签、全文搜索、语法高亮和一键生成分享链接。这篇文章不是我拿官方文档给你翻译一遍而是把我一个人在本地从脚手架初始化、数据库建模、API 设计、前端页面到部署上线的完整过程连同踩过的坑和排查思路全部倒出来。不管你是刚接触 T3 栈的新手还是已经在 Next.js 里摸爬滚打一阵子、想看看 tRPC 和 Prisma 在真实项目里怎么协作的老手这份实操记录应该都能让你少走几条弯路。1. 项目定位与整体设计思路1.1 t3code 到底解决了什么问题先说个直白的问题你电脑里那些散落在本地文件、备忘录、微信收藏和公司 Wiki 里的代码片段隔三个月还能找到吗我自己的情况是明明写过一段很漂亮的正则或者调了很久才跑通的数据库查询真到要用的时候死活想不起来在哪个目录里。t3code 的出发点就是给这种“开发者的数字碎片”一个统一的家你往里面扔一段代码给它贴上语言标签和自定义标签写一行备注它就能被永久地组织起来而且随时能通过关键词搜到。这听起来有点像 GitHub Gist但我的侧重点不一样。Gist 是给别人看的t3code 更偏“个人知识库 轻量分享”的结合体。所以我在设计功能时定了三条主线第一是录入要快打开页面创建一个 snippet粘贴代码、选语言、打标签整个过程十秒内完成第二是检索要准支持按语言过滤、按标签过滤和全文搜索搜索结果里高亮命中的代码行第三是分享要方便每个 snippet 可以生成一个只读的公开链接发给同事不用再复制一长串代码塞进聊天框。这个定位直接决定了技术选型的方向不需要复杂的权限矩阵不需要多人协作不需要庞大的后台管理系统。它就是一个人用起来顺手、偶尔发个链接给别人看的小而美的工具。所以用 T3 Stack 这种“少而精”的全栈方案而不是拆成前端后端两个项目是最贴合需求的选择。1.2 为什么选 T3 全家桶而不是传统 REST我在做这个项目之前也犹豫过要不要老老实实用 Next.js 写页面再用 Express 或者 NestJS 搭一个 REST API前后端分离这条路我太熟了但仔细想想t3code 这种规模的项目如果走前后端分离会凭空多出来一堆负担得定义 OpenAPI 文档、生成类型定义、写接口调用封装、处理跨域、维护两套部署流程。这些工作对业务没有任何增量纯粹是技术债。T3 Stack 的主张恰好相反它的核心理念是“类型安全优先”——从数据库到前端页面整个数据链路全程由 TypeScript 编译器盯着。tRPC 让我在前端组件里直接调用后端路由不需要手写 fetch、不需要手工维护 API 类型后端函数签名变了前端立刻报错。Prisma 让数据库表结构和 TypeScript 类型形成映射查询结果返回什么类型一清二楚。这种“全链路类型安全”带来的好处不是炫技而是实打实的效率我重构字段名的时候编译器会告诉我所有受影响的地方而不是等上线后用户报告报错。另外还有个很实际的原因T3 Stack 的项目结构非常统一。create-t3-app初始化出来的目录约定、API 路由组织方式、环境变量管理方式都是社区验证过的成熟模式。我以后想加新功能不用纠结“这个文件放哪”直接按既有模式往里填代码就行。1.3 核心功能与技术栈的对应关系把功能需求映射到技术选择上整个项目的骨架就清晰了代码片段模型语言类型、标签、内容、备注、可见性这些交给 Prisma 建模SQLite 起步后续可以平滑迁移到 PostgreSQL。增删改查接口tRPC 路由负责procedure 里用 Zod 做输入校验保证脏数据进不了数据库。前端交互Next.js App Router 做页面和路由Server Component 负责首屏渲染需要交互的搜索框用 Client Component。样式Tailwind CSS配套着做了一套基于语法的代码高亮方案避免引入又重又丑的组件库。认证NextAuth.js 接 GitHub OAuth因为用户管理不是核心卖点能登录、能识别用户身份就够了。这套组合的好处是每一层都有明确边界。数据库不知道前端存在tRPC 不关心页面怎么渲染Next.js 只负责 UI。我可以随意替换任何一层而不影响其他层——比如从 SQLite 换到 PostgreSQL只要 Prisma schema 不变tRPC 和前端代码一行都不用动。2. 技术选型与架构细节2.1 Next.js App Router 的页面分层设计真正开始写代码之前先把页面结构在脑子里过一遍。t3code 主要有四个视图首页展示 snippet 列表、新建/编辑表单页、snippet 详情页、以及公开分享页。App Router 下面我是这样组织的app/ layout.tsx # 全局布局导航栏 主容器 page.tsx # 首页snippet 列表 筛选 new/page.tsx # 新建 snippet snippet/[id]/page.tsx # 详情页 / 编辑页 share/[slug]/page.tsx # 公开分享页 api/trpc/[trpc]/route.ts # tRPC HTTP 入口这个分层有一个关键决策哪些组件用 Server Component哪些用 Client Component。我一开始图省事把整个首页都标成 Client Component结果数据请求、状态管理、筛选逻辑全搅在一起代码越来越乱。后来重新设计列表页拆成两层外层是 Server Component直接调用 tRPC 的 server caller 从数据库拿数据传给子组件只有搜索框和筛选栏才是 Client Component通过 URL searchParams 跟服务端通信。这么一改有几个立竿见影的好处首屏 HTML 直接包含数据SEO 能看到内容刷新页面不会白屏闪烁搜索逻辑变成“改 URL 参数 → Server Component 重新取数”天然支持浏览器的前进后退和分享链接同时 Client Component 的 bundle 体积小了很多因为列表渲染逻辑全在服务端跑完了。代价是筛选交互多了一次服务端往返但对 t3code 这种数据量级来说毫无感知。2.2 tRPC 路由怎么组织才不乱tRPC 的路由组织是 T3 项目里最容易踩坑的地方之一。很多人上来把一堆 procedure 塞进一个 router 文件写一两个月之后一个文件上千行改个逻辑要找半天。我的做法是按业务领域拆 router再用appRouter合并// server/api/root.ts import { snippetRouter } from ./routers/snippet; import { tagRouter } from ./routers/tag; import { createCallerFactory, createTRPCRouter } from ~/server/api/trpc; export const appRouter createTRPCRouter({ snippet: snippetRouter, tag: tagRouter, }); export type AppRouter typeof appRouter;snippet router 里面按操作语义分成几组query 开头的是读操作create/update/delete是写操作需要登录的加protectedProcedure公开分享的查询用publicProcedure。这样哪怕以后功能多了每个文件只管一块业务同事 review 代码的时候也清楚该去哪里找。还有一个容易被忽略的点错误处理要在路由层统一做。tRPC 默认抛出的异常会以TRPCError的形式传到前端所以我在所有写操作里都做了 try/catch统一把数据库错误转成可读的提示信息。比如 Prisma 遇到唯一约束冲突抛出来的错在前端显示为“这个标签已经存在”而不是一串英文堆栈。2.3 Prisma 数据建模前后要注意什么t3code 的数据库模型并不复杂但有两个设计细节我想展开说。首先是 snippet 和 tag 的关系我用了多对多关系这也是最自然的建模方式model Snippet { id String id default(cuid()) title String content String language String note String? isPublic Boolean default(false) shareSlug String? unique createdAt DateTime default(now()) updatedAt DateTime updatedAt author User relation(fields: [authorId], references: [id]) authorId String tags Tag[] } model Tag { id String id default(cuid()) name String color String? snippets Snippet[] userId String user User relation(fields: [userId], references: [id]) unique([userId, name]) }第二个细节是分享链接的生成。我没有用自增 id 作为公开地址因为snippet/3这种链接太好猜了别人遍历一下就能看到你不打算公开的内容。我用了nanoid生成一个 10 位的短 ID 存到shareSlug字段并且加了唯一索引。只有用户手动把 snippet 标记为公开时才生成这个字段私有的 snippet 永远没有公开入口。用 Prisma 还有一个习惯性建议每改一次 schema 都要养成分三步走的肌肉记忆——prisma format格式化、prisma migrate dev生成迁移、prisma generate重新生成客户端类型。我见过太多人改了 schema 忘记 generate然后前端类型跟实际数据库对不上白排查半天。2.4 代码高亮方案的选择代码片段工具的核心体验就是代码高亮。我试过两种方案先说结论生产环境我用的是Shiki因为它基于 TextMate 语法高亮效果跟 VS Code 一模一样而且支持服务端渲染。第一种方案是react-syntax-highlighter上手很快但有两个问题bundle 体积大动不动几百 KB其次是样式跟 GitHub 风格有偏差代码块看起来总有点“不对劲”。第二种方案 Shiki 的思路不同它在服务端把代码转成带span classshiki的 HTML 字符串前端几乎零成本。我配合了 On-demand Loading 特性按语言加载语法包import { codeToHtml } from shiki; const html await codeToHtml(code, { lang: language, theme: github-dark, });渲染详情页的时候我在 Server Component 里调用codeToHtml把生成的 HTML 直接塞进页面。缺点是每次请求都要重新做一次语法高亮对性能有影响所以我加了一层缓存——同一个 snippet 的高亮结果用 key 存起来第二次访问直接读缓存。实测一个 200 行的 Python 片段首次渲染能控制在 300ms 以内完全够用。3. 实操过程与核心环节实现3.1 从 create-t3-app 初始化到第一个页面初始化的命令很简单但里面的选项值得说清楚pnpm create t3-applatest t3code交互式命令行会问你要装哪些模块我的选择是Next.js、TypeScript、tRPC、Prisma、Tailwind CSS认证选 NextAuth.js。注意这里有几个坑。第一项目名不要用中文或带空格npm 包名和目录名都要合规不然后面 Prisma 初始化会出幺蛾子。第二如果你暂时不想接数据库可以只勾 Next.js tRPC但 T3 的优势就在全链路缺了 Prisma 等于少了一条腿。第三初始化完成后它会自动执行prisma migrate此时要有.env里的DATABASE_URL默认 SQLite 就指向prisma/dev.db直接跑就行。初始化完之后我习惯先跑一遍pnpm dev确认 Hello World 页面能开。然后立刻把默认代码清掉按自己的目录结构创建snippet和tag两个业务模块同时把 tailwind 的全局样式调成自己顺手的暗色主题。这个阶段的目标不是写业务而是把“我想怎么组织代码”的骨架立起来后面填肉就顺畅了。3.2 tRPC 核心 API 的完整实现写 tRPC procedure 最爽的一点是你根本不用定义接口文档函数签名就是契约。下面是 snippet 的创建和列表查询两个核心 procedure我做了一些业务上的处理直接贴出来给各位参考// server/api/routers/snippet.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; import { nanoid } from nanoid; const snippetInput z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().min(1), note: z.string().optional(), tagNames: z.array(z.string()).max(10), isPublic: z.boolean().default(false), }); export const snippetRouter createTRPCRouter({ create: protectedProcedure .input(snippetInput) .mutation(async ({ ctx, input }) { const shareSlug input.isPublic ? nanoid(10) : null; return ctx.db.snippet.create({ data: { ...input, authorId: ctx.session.user.id, shareSlug, tags: { connectOrCreate: input.tagNames.map((name) ({ where: { userId_name: { userId: ctx.session.user.id, name } }, create: { name, userId: ctx.session.user.id }, })), }, }, }); }), list: protectedProcedure .input( z.object({ search: z.string().optional(), language: z.string().optional(), tag: z.string().optional(), }), ) .query(async ({ ctx, input }) { return ctx.db.snippet.findMany({ where: { authorId: ctx.session.user.id, ...(input.search ? { OR: [ { title: { contains: input.search } }, { content: { contains: input.search } }, ], } : {}), ...(input.language ? { language: input.language } : {}), ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), }, include: { tags: true }, orderBy: { updatedAt: desc }, take: 100, }); }), });几个注意点。第一connectOrCreate是 Prisma 处理多对多标签的标准姿势它会自动找同名的 tag找不到就创建但要注意这样创建的 tag 会被所有用户共享所以where里必须带上userId做隔离。第二搜索我用的是 SQLite 的contains在 PostgreSQL 上会自动翻译成ILIKE但字段一多性能会掉到时候可以换pg_trgm或全文索引。第三take: 100是故意加的个人工具没必要一次拉全量数据分页可以后面再加。3.3 前端怎么优雅地调用这些接口在 Client Component 里调用 tRPC 的写操作我用的是useUtils()来做乐观更新体感非常顺滑。比如创建 snippet 成功后我希望列表页立刻看到新数据不用刷新整个页面use client; import { api } from ~/trpc/react; import { useRouter } from next/navigation; import { useState } from react; export function SnippetForm() { const router useRouter(); const utils api.useUtils(); const [title, setTitle] useState(); const [content, setContent] useState(); const createSnippet api.snippet.create.useMutation({ onSuccess: async () { await utils.snippet.list.invalidate(); router.push(/); }, }); const handleSubmit (e: React.FormEvent) { e.preventDefault(); createSnippet.mutate({ title, content, language: typescript, tagNames: [前端], isPublic: false, }); }; return ( form onSubmit{handleSubmit} classNamespace-y-4 {/* 表单字段省略 */} /form ); }invalidate()是 tRPC 里很优雅的一个机制它告诉 React Query “这条数据已经过期了”下次用到的时候会自动重新拉取。配合router.push(/)用户完成创建后进入列表页看到的是最新的数据全程没有闪烁、没有手动刷新。另一个实用技巧是用 tRPC 的工具类型推导前端参数。比如我在表单里用useForm的时候可以这样获得完整类型提示type CreateInput z.infertypeof snippetInput;后端改了一个字段的类型前端立刻编译报错这种体验在传统 REST 项目里想都不敢想。3.4 详情页、分享页与服务端取数详情页我用了 Server Component 加 tRPC server caller 的方式。所谓 server caller就是在服务端直接调用后端函数不走 HTTP省掉一次网络请求// app/snippet/[id]/page.tsx import { appRouter } from ~/server/api/root; import { createCallerFactory } from ~/server/api/trpc; import { auth } from ~/server/auth; const createCaller createCallerFactory(appRouter); export default async function SnippetDetail({ params, }: { params: { id: string }; }) { const session await auth(); const caller createCaller({ session: session, headers: new Headers(), db: db, }); const snippet await caller.snippet.byId({ id: params.id }); if (!snippet) { notFound(); } return SnippetView snippet{snippet} /; }server caller 的好处是同一个 tRPC procedure 既能在浏览器里被api.snippet.byId.useQuery()调用也能在服务端被直接调用逻辑完全复用。这对于详情页尤其有价值首屏就是完整 HTML不需要先加载 JS 再发请求SEO 和用户体验双赢。分享页的取数逻辑更简单。它不要求用户登录只根据shareSlug查一条公开的 snippetgetByShareSlug: publicProcedure .input(z.object({ slug: z.string() })) .query(async ({ ctx, input }) { return ctx.db.snippet.findUnique({ where: { shareSlug: input.slug, isPublic: true }, }); }),这里有个容易被忽略的细节findUnique的 where 条件里同时写了shareSlug和isPublic: true双重保险。就算有人猜出了 slug只要 snippet 不是公开状态就查不到数据。3.5 部署上线的完整流程部署我选的是 Vercel原因只有一个跟 Next.js 的适配是零配置的。流程分三步。第一步准备数据库。SQLite 在 Vercel 的无服务器环境里是没法持久化存储的因为每次函数执行都是一个新的文件系统文件写入会直接丢失。所以我把数据库换成了 PostgreSQL用的是 Supabase 的免费套餐。改一行.env就完成迁移DATABASE_URLpostgresql://...然后重新执行prisma migrate deploy把已有的迁移记录同步到新数据库。第二步配置环境变量。Vercel 项目设置里要填DATABASE_URL、NEXTAUTH_SECRET、GitHub OAuth 的GITHUB_CLIENT_ID和GITHUB_CLIENT_SECRET。这里有个坑OAuth 回调地址在本地是http://localhost:3000/api/auth/callback/github在线上是https://你的域名/api/auth/callback/github两个地址都要在 GitHub OAuth App 里注册否则线上登录会一直转圈。第三步连接 Git 仓库push 触发自动部署。Vercel 会自动识别 Next.js 项目执行pnpm build。我第一次部署时卡在一个构建错误上排查发现是 Prisma 客户端没有在构建阶段生成需要在package.json的postinstall脚本里加一行postinstall: prisma generate加上之后重新部署一次通过。4. 常见问题与排查技巧实录4.1 类型报错tRPC 类型不同步怎么办T3 项目里最经典的问题就是改了后端 procedure 的返回类型前端一直报旧类型。这通常是两个原因。第一dev server 没重启。tRPC 的类型是在.next编译产物里缓存的改了server/api下的代码如果没有热更新干脆手动重启一下pnpm dev。第二prisma generate没跑导致ctx.db.snippet的返回类型还是旧的。养成习惯改 schema 之后立刻pnpm prisma generate。还有个更隐蔽的情况如果你在同一个文件里既定义了input的 Zod schema又用了z.infer在前端推导类型改了一处忘了另一处编译器会提示你在前端文件里改类型。这时候别急着改前端回到后端 schema 把源头改对了再让编译器自己把错误清理干净。4.2 登录回调死循环NextAuth 的经典坑NextAuth 在部署后最常见的症状是点击登录按钮跳转到 GitHub授权完又跳回登录页来回转圈。排查思路按顺序来。先看日志Vercel 的日志里如果有Invalid check: pkce之类的报错说明AUTH_SECRET或NEXTAUTH_SECRET不一致。本地和线上分别用了不同的 secret导致 GitHub 回调里的 state 校验失败。解决办法是生成一个固定的 secret全环境统一。再看回调地址GitHub OAuth App 的 Authorization callback URL 必须严格匹配实际的/api/auth/callback/github路径不能多斜杠、不能少大写。还有朋友遇到过把NEXTAUTH_URL写成了https://xxx.vercel.app/api/auth的情况也会炸——这个值只要写到域名根路径就够。最后检查数据库NextAuth 的 session 默认存在数据库里如果SESSION_TABLE相关迁移没跑全登录也会失败。跑一遍prisma migrate deploy确认所有迁移都应用了。4.3 搜索慢和列表性能的优化记录项目刚上线的时候首页列表拉 100 条 snippet 没什么感觉但测试数据攒到千条左右就开始卡了。排查发现瓶颈不在数据库而在前端渲染因为我把内容字段也查出来了包括几千个字符的大代码块前端要同时渲染 100 个高亮后的代码块。优化动作有两个。第一列表接口不返回content全文只返回截断后的摘要list: protectedProcedure.query(async ({ ctx, input }) { const snippets await ctx.db.snippet.findMany({ ... }); return snippets.map((s) ({ ...s, contentPreview: s.content.slice(0, 200), })); });第二列表页的代码高亮改成懒加载——默认只显示纯文本预览点进详情页才做完整的语法高亮。这样列表页的渲染时间从原来的 800ms 降到 80ms 左右体感完全不同。搜索性能方面SQLite 的contains在万条数据内表现还可以但如果 snippet 多了建议在 PostgreSQL 上启用全文搜索。Prisma 支持fulltext索引PostgreSQL 的tsvector搜索会快一个数量级。4.4 分享链接失效和隐私边界的处理上线后有个使用场景有点意外有用户问我为什么把 snippet 改成私有之后之前分享出去的链接还是能打开这是设计上要明确的边界——我处理的是“访问控制”不是“文档销毁”。如果分享链接已经发到别人手里把 snippet 转私有不代表链接立即失效因为数据库里那条记录的isPublic已经变成false查询条件是双重的所以链接确实访问不了了。用户问的大概率是浏览器缓存分享页加了Cache-Control: no-store响应头之后问题就消失了。另一个隐私细节分享页的 HTML 里我不渲染任何作者信息只显示代码和备注减少不必要的暴露面。这个从产品角度考虑很简单但很容易被忽略。5. 一些只有自己跑过才知道的经验最后聊几个比较碎、但含金量不低的体会。第一个是开发和生产的“环境差异”远比想象的大。本地跑得很顺的 SQLite部署到 Vercel 就废了本地能用的 NextAuth到线上会因为 secret 不一致而失灵。如果你打算做 T3 项目建议从第一天就用 PostgreSQL 的远程实例不要用 SQLite 做本地开发省得后面迁移的时候被一堆环境差异折磨。第二个是tRPC 的代码组织方式直接决定项目能长多大。如果你的 router 文件开始超过 300 行赶紧拆。不要觉得 function 少就不用拆等加了权限、审计、复杂查询之后再拆成本会高很多。我现在的经验是一个 router 文件最多管一个业务实体查询逻辑抽到 service 层procedure 只做参数校验和会话检查。第三个是别迷信“全栈类型安全”该写测试还是要写。T3 栈确实减少了类型层面的 bug但业务逻辑错误、边界条件错误它管不了。我给 snippet 的创建和分享两个 procedure 各写了一层 tRPC 的集成测试用 vitest 跑真实数据库虽然增加了工作量但后面每次重构都有安全感。第四个是关于Shiki 的缓存。生产环境里同一个代码块会被多次重复高亮这是 CPU 密集操作。我的解决办法很简单给高亮结果加一个以内容 hash 为 key 的内存缓存再配合 CDN 缓存分享页。如果你用的是 Vercel还可以在next.config里给分享页加stale-while-revalidate策略进一步降低冷启动的影响。做个总结其实没什么必要代码已经摆在那了。我更想说的是t3code 这个项目规模不大但它把我平时积累的全栈技术点完整串了一遍类型安全、服务端渲染、Server/Client Component 分层、数据库建模、第三方登录、部署上线、性能调优。如果你也想找一个合适的项目来验证自己对 T3 Stack 的理解我强烈建议不要只跑 demo而是做一个能解决你真实问题的工具——那种每天都会用、用得不爽就想改的需求才是驱动你把技术吃透的最好动力。现在这个项目我还在持续用每隔一阵就给它加个小功能每次改动都让我对这些工具的边界和取舍理解得更清楚。这大概就是做个人项目最有意思的地方。