t3code不是那种冲上 GitHub 排行榜的开源项目我自己把它定位成一个能真正上线的中型案例用 T3 Stack 做的一个代码片段管理工具。T3 Stack 这四个字母分别指 TypeScript、Tailwind、tRPC 和 Next.js一句话说就是用一套类型安全的组合拳把 Web 前端和后端串起来。这个项目适合两类人一类是想从看教程进入做一个完整产品阶段的前端开发者另一类是准备接手 T3 栈企业项目、想提前踩平坑位的同学。写这篇文章不是为了复述官方文档而是把 t3code 从纸上到线上的完整决策过程摊开来讲。包括为什么选 tRPC 而不是 REST、Prisma 模型怎么设计、NextAuth 怎么接入 tRPC 上下文、部署到 Vercel 时被哪些隐藏问题卡住以及最终我是怎么通过先定数据模型、再补业务路由的顺序把项目稳定推进下去的。1. 项目概况与设计拆解1.1 t3code 到底是什么t3code 的定位非常朴素一个支持多用户、可公开分享、带标签检索的代码片段管理平台。你可以把它理解成自己私有的收藏夹也可以理解成一个轻量级的代码分享版块。它解决的是一个挺常见的痛点浏览器收藏夹里存了几百个代码片段不是找不到是哪条就是复制下来之后忘记它是干什么用的。所以这个项目最核心的功能被压缩成三条登录用户可以创建、编辑、删除自己的代码片段代码片段按语言分类支持标签关联。非登录用户可以浏览所有人标记为公开的片段通过标题和标签做基础搜索。每个片段有独立的详情页语法高亮、一键复制、作者信息展示。当时下定决心做 t3code就是因为市面上工具要么太重要么太封闭。我想要一个自己能完全掌控数据、能随意扩展字段、能拿来做技术试验的代码库。至于为什么名字里带个 t3没太多玄机项目就是基于 T3 Stack 做的后面接上 code 正好说明它是个代码类产品。1.2 核心需求与用户路径设计我不是那种上来就画完整 UML 的人但用户路径必须先想清楚。t3code 主要服务两条路径第一条是游客路径进入首页看到公开代码片段列表按语言过滤点进详情页复制代码最多看一眼作者主页。这条路径不涉及任何状态所以需要保证极致的速度任何多余的请求都应该被避免。第二条是登录用户路径注册登录以后进入控制台可以维护自己的片段。这里的核心动作是创建、编辑、删除需要明确的数据写入接口和权限校验。这个设计带来的最大约束是首页列表必须是服务器端直出不能等所有 JS 加载完才渲染内容。T3 Stack 默认用的 Next.js App Router 在这一点上帮了大忙配合 tRPC 的预取能力可以用少量代码实现首屏数据随 HTML 一并输出的效果。1.3 功能范围与边界控制很多项目做不下去不是因为功能太少而是因为功能太贪。t3code 立项时我就划了一条明确的边界线只做代码片段本身不做社交、不做实时协作、不做复杂的权限体系。社交功能比如点赞、评论虽然能丰富产品形态但会显著增加数据库表设计、通知系统、实时更新等复杂度。对于一个要完整走通开发-部署流程的项目来说这些功能是灾难级的时间黑洞。我宁可把有限的精力放在打磨编辑器体验、搜索速度、类型安全和部署稳定性上。最终的功能清单就是这样模块功能点优先级用户体系注册、登录、会话状态高代码片段CRUD、公开/私有切换高标签体系标签创建、关联、过滤中搜索标题搜索、标签搜索中部署Vercel 自动化部署高先圈定这个范围后面的技术选型就变得非常清晰。2. 技术选型与关键权衡2.1 T3 Stack 为什么适合这个项目选 T3 Stack 不是跟风是算过账的。传统前后端分离模式里我需要先定接口文档再分别写前端请求逻辑和后端路由逻辑中间还有一个反复沟通契约的成本。t3code 只有我一个人开发但即使如此如果前后端类型不互通改一个字段类型就要翻半天代码那维护成本依然很高。T3 Stack 的核心特点是类型安全全链路覆盖TypeScript 管前端、Prisma 管数据库、tRPC 管接口、Next.js 管页面渲染整条数据链路从数据库表结构到页面上拿到的数据类型是通的。改掉 Prisma 模型里一个字段类型编译期就会提示所有相关代码报错这比什么代码检查工具都实在。另一个关键点是 T3 Stack 对最小复杂度的追求。它默认不引入 Redux、不强制用 GraphQL而是尽量用 Next.js 自带能力和轻量库解决。这种理念很适合我这种能用工具解决、就不写框架的习惯。2.2 tRPC 带来的接口组织方式tRPC 这个库很多没接触过的人会本能抗拒因为它名字里带个 RPC感觉像是什么高深的东西。其实把它理解为一个带类型自动推导的函数调用器就够了前端直接调用一个函数函数在后端执行并返回数据中间的 HTTP 序列化、错误处理、参数校验由 tRPC 帮你封装好。放在 t3code 里它长这样// 前端调用 const snippets await api.snippet.getAll.query(); // 后端定义 export const snippetRouter createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) { return ctx.db.snippet.findMany({ where: { visibility: Public }, orderBy: { createdAt: desc }, }); }), });前端不用写 fetch 封装、不用处理 404 错误、不用记住接口路径拿到的是一个有明确返回类型的函数。后端也不是漫无目的写接口每加一个 procedure 就等于声明一段能力。但 tRPC 也有自己的代价它和 TypeScript 绑定得非常深如果项目里还存在外部系统需要对接或者有非 TypeScript 的客户端要调用接口tRPC 就帮不上忙了。t3code 里所有调用方都是自家页面所以这个限制完全无所谓。2.3 认证方案与数据库选型用户登录我选了 NextAuth这是 T3 Stack 官方默认推荐的方案。NextAuth 的好处是配置文件驱动接 GitHub OAuth 几乎不用写业务代码会话管理、JWT 签发、回调处理都是现成的。坏处是文档写得比较跳跃尤其是和 tRPC 上下文结合的时候容易让人摸不着头脑。数据库这块本地开发我用 SQLite部署之后换成了 PostgreSQL。这个切换其实是刻意为之的。SQLite 对单机开发太友好了不需要启动容器、不需要配账号密码、一个文件就能跑起来非常适合先验证数据模型。而部署到 Vercel 这种 Serverless 环境后SQLite 的文件存储没有持久保证所以必须切到 PostgreSQL。这里有个小建议Prisma 的 schema 文件里尽量不要写死数据库类型尽量保持字段层面的兼容性。t3code 的模型很简单所以 SQLite 到 PostgreSQL 的迁移几乎是无痛的这也是 Prisma 的一大价值。3. 数据模型与核心功能落地3.1 最终的 Prisma 模型t3code 的表结构我前前后后改了三个版本。第一版把标签存成字符串数组虽然简单但无法做标签统计和精确过滤。第二版加了 User 和 Snippet 的关联但忽略了标签实体。第三版才确定用一张中间表做多对多关系。最终模型大概长这样enum Visibility { Public Private } model User { id String id default(cuid()) name String? email String? unique snippets Snippet[] createdAt DateTime default(now()) } model Snippet { id String id default(cuid()) title String db.VarChar(200) description String? db.Text content String db.Text language String default(text) visibility Visibility default(Public) author User relation(fields: [authorId], references: [id]) authorId String tags SnippetTag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique snippets SnippetTag[] } model SnippetTag { snippetId String tagId String snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag relation(fields: [tagId], references: [id]) id([snippetId, tagId]) }这里最值得强调的是SnippetTag中间表。直接把标签挂在代码片段上用数组存查询某个标签下的所有片段就会非常尴尬而一张中间表能支持我后面做按标签过滤时的高效 JOIN 查询也保留了未来扩展标签颜色、标签描述等字段的空间。这个取舍很基础但对新手来说特别容易忽视。另外每个字段都尽量限制了长度。比如title用VarChar(200)这能防止用户无意识提交超长字符串content用Text因为代码片段确实可能很长。不要相信数据库会帮你自动做这些事情建模时候定义清楚比以后在代码里做校验更靠谱。3.2 tRPC 路由怎么拆我把路由拆成三个层级userRouter、snippetRouter、tagRouter。每个文件对应一类资源的操作集合避免把一个文件写成一千行的上帝文件。snippetRouter是业务核心主要 procedure 包括getAll公开片段列表游客可用。getById片段详情需要校验可见性。createOne创建片段登录用户可用。updateOne更新片段只能操作自己的数据。deleteOne删除片段只能操作自己的数据。toggleVisibility切换公开/私有。以createOne为例import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; export const snippetRouter createTRPCRouter({ createOne: protectedProcedure .input( z.object({ title: z.string().min(1).max(200), description: z.string().max(500).optional(), content: z.string().min(1), language: z.string().min(1), visibility: z.enum([Public, Private]).default(Public), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { const { db, session } ctx; const snippet await db.snippet.create({ data: { title: input.title, description: input.description, content: input.content, language: input.language, visibility: input.visibility, authorId: session.user.id, tags: { create: await Promise.all( input.tags.map(async (name) { const tag await db.tag.upsert({ where: { name }, update: {}, create: { name }, }); return { tag: { connect: { id: tag.id } } }; }) ), }, }, }); return snippet; }), });这部分有一个特别重要的细节权限判断要收敛在服务端绝不能依赖前端隐藏按钮。在updateOne里我用where: { id: input.id, authorId: ctx.session.user.id }去更新记录查不到就直接抛错。这样的好处是哪怕有人绕过前端界面直接调用接口也没有办法修改别人的片段。3.3 前端页面与类型联动前端我用的是 Next.js App Router页面结构分了两块公共区域首页、片段详情页主要走publicProcedure。用户区域控制台、创建/编辑页主要走protectedProcedure。tRPC 带来的最大爽感就在这一步。写完后端路由后前端 import 代码时已经带上了完整的类型比如api.snippet.getAll.query()返回的是Snippet[]编辑器里直接能补全出snippet.title和snippet.language。如果后端某个字段被改掉了前端立刻在编译期报错。这种联动效应在上手阶段可能感觉只是方便但项目迭代到第二周、第三个模块时它的价值会成倍放大。我再也不用靠翻阅接口文档来确认返回值里到底有没有某个字段不用在联调时对着 Network 面板发呆。页面样式全部交给 Tailwind没写一行独立的 CSS 文件。这里说一句实话刚开始用 Tailwind 的人会觉得 class 名又长又黑但写习惯之后确实回不去了。响应式、暗色主题、伪类状态都能在 HTML 里直接控制尤其适合这种没有设计师参与的个人项目。4. 实操过程从初始化到部署4.1 项目初始化与依赖确认所有 T3 Stack 项目的起点都是创建脚手架npm create t3-applatest t3code交互式问答里CLI 会问你要不要装 NextAuth、Prisma、Tailwind、tRPC。在这里我的建议是如果一开始没有特殊理由拒绝尽量全选。T3 栈的设计初衷就是提供一套经过验证的组合单独去掉其中任意一个后续都可能要花大力气补回来。首次初始化完成后默认目录里会出现一个package.json和一个清晰的src目录。如果你之前用的是 Next.js Pages Router初次看到 App Router 的layout.tsx可能会有点不习惯但这不是障碍先跑通再说。然后清理掉脚手架自带的示例页面和默认路由保留必要的目录结构让项目干干净净地从零开始。这一步很重要否则你会一直在官方示例代码上做接生很难形成自己的代码组织习惯。4.2 配置 Prisma 与数据库本项目我从 SQLite 起步所以.env里是这样写的DATABASE_URLfile:./db.sqlite写完上面那个数据模型后执行迁移命令npx prisma migrate dev --name init这条命令会创建一张 SQLite 数据库文件并且生成对应的 Prisma Client。注意如果你使用npm install而不是pnpm生成出来的客户端位置可能不一样但 T3 脚手架通常已经帮我们处理好了。切换到 PostgreSQL 的时候只做了三步操作把DATABASE_URL换成 PostgreSQL 连接串、跑一次npx prisma migrate deploy、在业务代码里清理掉依赖 SQLite 特性的查询。因为模型里没有用任何数据库私有类型整个过程几乎没有报错。数据库驱动的正确打开方式是先定义模型再考虑查询。我在做 t3code 的时候凡是查询写起来特别绕的需求第一反应不是写更复杂的查询而是回头检查模型大概率是模型设计得不够契合功能。4.3 实现 tRPC 路由的完整链路链路分为三层router、procedure、input。router负责把同类操作组织在一起。procedure决定这个操作是公开的、需要登录的还是需要管理员的。input决定参数类型和校验规则。t3code 里我用了publicProcedure和protectedProcedure两种后者在调用时会先检查ctx.session如果没有会话就抛一个UNAUTHORIZED错误。这个能力来自 T3 脚手架默认生成的trpc.ts文件它会自动把 NextAuth 的 session 注入到每个请求的上下文里。实际操作时不需要手写一堆中间件只需要在应该受保护的 procedure 列表里统一选择protectedProcedure。比如游客只能读取登录用户才能写入这比在业务代码里堆if (!session)要干净得多也避免了漏写某个接口的权限检查。4.4 前端页面接入数据页面接入 tRPC 有两种方式我分别体验过。一种是直接在客户端组件里调用api.snippet.getAll.query()配合useQuery的封装实现加载状态和错误状态。这种方式写起来快适合控制台这类交互频繁、数据实时性要求高的页面。另一种是服务端查询在 Server Component 里直接调用 tRPC 的查询方法然后一次性把数据传递给页面。这种方式对 SEO 很友好首页的公开片段列表我用的是这种方式。import { api } from ~/trpc/server; export default async function HomePage() { const snippets await api.snippet.getAll.query(); return SnippetList snippets{snippets} /; }一个很实用的细节是tRPC 服务端客户端和客户端客户端可以共享同一套路由定义不用给每个页面单独配消费端。T3 脚手架默认已经生成了trpc/server.ts和trpc/react.tsx直接用不会错。4.5 部署到 Vercel 与数据库上云部署这块没什么戏剧性但有几个必踩的坑必须说清楚。Vercel 的构建环境是无状态的这意味着 SQLite 文件要么存在于某个持久磁盘上要么就面临每次部署都被清空的风险。t3code 最终选择了 Neon 提供的 PostgreSQL 免费实例原因是它兼容 Serverless、连接串直接支持 Vercel 环境变量而且冷启动延迟可以接受。部署流程是在 Neon 创建项目拿到DATABASE_URL。在 Vercel 项目设置里添加环境变量。在 Vercel 的 Build Command 中加入prisma migrate deploy npx prisma generate。本地跑一次迁移后推代码等自动构建。这里最容易忘的不是环境变量而是Prisma Client 的生成时机。本地开发时没有暴露出来是因为脚手架在postinstall里帮你跑了生成。但 Vercel 的构建环境不一定执行postinstall所以最好在构建命令里显式加一段生成命令否则项目一上线就开始报PrismaClient is not initialized之类的错误。5. 常见问题与排查实录5.1 tRPC 类型不刷新怎么办这是被问得最多的问题。现象是前端代码里明明已经调用了api.snippet.xxx但类型推断还是旧的甚至直接提示这个方法不存在。原因一般出在 TypeScript 对 tRPC 路由类型做了缓存修改后端路由后需要重新生成 tRPC 类型引用。解决很简单npx tsc --noEmit或者直接重启 Next.js 开发服务器。另外如果你的项目里 glob 配置有问题导致后端路由文件没被包含进类型检查范围也会出现类似现象。检查根目录tsconfig.json的include字段是否覆盖了src/server目录。5.2 NextAuth 会话在 tRPC 上下文中拿不到t3code 开发过程中我第一次把protectedProcedure接上去时调用接口永远返回 401回头一查 session 竟然是 null。排查思路是这样的先访问/api/auth/session确认登录态正常再检查 tRPC 的createContext是否读取了 NextAuth 的 session。T3 脚手架提供的trpc.ts里默认调用了getServerAuthSession但如果你的路由分组结构比较特殊上下文可能没有被传递到对应路由。这个问题最常见的解法是把createContext里的异步 session 读取改为惰性读取让 tRPC 在解析 procedure 时再去拿 session避免调用链上某个中间步骤把 session 吞掉。5.3 Prisma 迁移在部署时失败本地跑得好好的部署到 Vercel 后数据库表没建出来这类问题通常在两个环节一构建环境没有执行迁移。解决方式是给build脚本加上prisma migrate deploy prisma generate next build。二DATABASE_URL环境变量在构建阶段不可见。Vercel 的环境变量如果不在设置里打开Build 阶段可用的开关构建过程就读不到连接串迁移自然跑不起来。还有一个容易被忽略的点永远不要在生产环境执行prisma migrate reset这个命令会清空数据库数据。我见过不止一个人误操作把生产环境数据抹掉的惨案。5.4 部署后所有页面白屏这类问题 90% 和客户端组件的use client有关。App Router 里如果某个组件同时使用了浏览器的 localStroage 或 window 对象而你又把它当 Server Component 直接 import构建能过但运行时很可能直接给你抛一个未捕获异常。我排查 t3code 白屏时先在浏览器控制台看到真正的报错某个代码高亮组件在服务端渲染时访问了window。最终处理方式是动态导入这个组件并且设置ssr: false。如果你遇到类似场景先别急着怀疑 tRPC 或数据库打开浏览器控制台往往比看构建日志更直接。5.5 常见问题速查表现象常见原因推荐处理类型无法补全tRPC 类型缓存未刷新npx tsc --noEmit后重启开发服务接口一直 401session 未注入 tRPC 上下文检查trpc.ts中getServerAuthSession调用数据库表不存在构建阶段没跑迁移在 build 命令中显式加入prisma migrate deploy页面白屏组件在服务端使用浏览器对象动态导入或设置ssr: falsePrismaClient 未初始化Client 未生成构建前执行npx prisma generate6. 从 t3code 项目中沉淀出的一些体会如果只让我留一句最有价值的话那就是类型安全不是锦上添花而是把项目复杂度控制在可见范围内的唯一方法。t3code 做到后期数据库模型、接口、前端页面的类型完全联动我可以放心大胆地改字段、加逻辑不用反复人肉排查到底是哪里类型不对。这种安全感在传统前后端分离项目里很难体验到。还有一个切身的体会是项目不要贪大但要追求闭环。t3code 麻雀虽小五脏俱全——有用户、有权限、有数据库、有服务端渲染、有接口层、有部署。把这条链路完整走通比写十个半途而废的 CRUD 示例更有意义。你能从真实报错里学到的东西远比跟着教程敲代码学到的多。最后分享一个小技巧如果你也想做类似的 T3 项目先把数据模型写清楚比先做页面好看重要得多。模型是地基地基歪了后面每加一个功能都会觉得别扭。数据模型定下来之后tRPC 路由跟着模型走页面跟着路由走整个项目的节奏就会非常顺。