
t3code 这个名字听起来像某个技术圈子的暗号其实它是我最近用 T3 Stack 做的一个个人项目代号——一个私有的代码片段管理工具。起因很实在做前端开发这几年我攒了一堆随手验证过的小片段分布在本地文件、公司 Wiki、微信收藏、甚至聊天记录里真到要用的时候不是找不到就是版本不对所以干脆自己搞一个统一入口把散落的内容收拢起来支持打标签、全文搜索、一键复制也算给自己一亩三分地做一次彻底清理。但项目真正做下来最有价值的反而不是收藏夹本身而是把 T3 这套全栈 TypeScript 方案在真实场景里跑通的过程。如果你正准备上手 T3 技术栈或者正在纠结内部工具要不要从 REST 切到 tRPC这篇文章里的选型理由、代码骨架、部署细节和踩坑复盘应该能帮你省下不少排查时间。我按设计思路 - 数据链路 - 前端实现 - 部署运维 - 踩坑复盘的顺序摊开讲尽量做到看过就能抄作业。1. 项目整体设计与思路拆解1.1 为什么选了 T3 这套全家桶先交代一下 t3code 的定位它不是一个公共产品而是解决我自己代码碎片管理问题的垂直工具。这类内部工具有一个共同特点并发量低、功能迭代快、团队规模小甚至就我一个人。这种场景下前后端分离 接口文档 联调流程的成本明显偏高我更想要的是改一个类型定义全链路立刻感知的开发体验这一点恰好是 T3 整套设计哲学最擅长的地方。对比一下主流方案会发现T3 的选择非常激进直接放弃传统 REST API用 tRPC 在客户端调用服务端函数数据访问层用 Prisma 而不是手写 SQL样式直接用 Tailwind 原子类。给人的第一感觉可能是不适应但用顺手之后最大的收益是几乎不需要维护接口层代码类型从数据库一路穿到 UI 组件改个字段编译期就能把错误暴露出来。选这套技术栈不是因为它潮而是因为它让一个人维护全栈项目时的心智负担降到了最低。当时我也短暂纠结过要不要用传统方案后端用 Express 或 NestJS 出一套 REST 接口前端用 Axios 接。但算了一笔账就放弃了——一个小工具要写 router、controller、dto、前端 api 封装、类型定义至少小几百行重复代码。tRPC 能把这部分直接省略换来的是可接受的运行时开销。收益和成本一比答案很明显。1.2 功能边界与数据流设计t3code 的功能不算多我把它拆成三大块认证与用户体系、片段管理、标签与搜索。因为 App Router 的约定式路由和 tRPC 的 router 结构天然适合模块化拆分所以每个功能块都对应一个独立的路由模块后面加功能像搭积木一样不会互相污染。数据流走的是 T3 的标准链路React 组件发起 tRPC 调用请求被轻量级序列化后送到服务端 resolverresolver 再调用 Prisma 操作 PostgreSQL结果原路返回并由 React Query 接管缓存和状态更新。这个链路里几乎不存在手写的请求 - 响应 - 转换 DTO代码所有数据形状都是 TypeScript 类型直接推导出来的。这也是我后来哪怕只是做一个几十行的小功能也不想再回到 REST 的原因。权限模型也做得比较保守片段要么是私有的要么是公开的公开片段需要登录后才能查看防止爬虫扫到敏感内容。标签采用多对多关系搜索用 PostgreSQL 的 ILIKE 做基础实现没有上全文索引理由很简单——个人工具的搜索量级根本用不上复杂方案过度设计才是最大的敌人。2. 核心细节解析与实操要点2.1 工程初始化与目录约定的关键选择t3code 由 create-t3-app 脚手架生成交互式选项里我勾选了 NextAuth、Prisma、Tailwind、tRPC没选 Drizzle 是因为当时 Prisma 在 T3 生态里更成熟资料多踩坑也相对可控。初始化完成后的目录结构是这样的t3code/ ├─ app/ │ ├─ api/trpc/[trpc]/route.ts │ ├─ (auth)/login/page.tsx │ ├─ (main)/snippets/ │ ├─ layout.tsx │ └─ providers.tsx ├─ components/ ├─ prisma/schema.prisma ├─ server/ │ ├─ api/ │ │ ├─ root.ts │ │ ├─ routers/ │ │ │ ├─ auth.ts │ │ │ ├─ snippet.ts │ │ │ └─ tag.ts │ │ ├─ rpc.ts │ │ └─ trpc.ts ├─ src/或项目根目录的 tsconfig 相关配置 └─ .env注意 App Router 下有个容易混的地方app/api/trpc/[trpc]/route.ts是 tRPC 的 HTTP 入口逻辑非常固定几乎不用改。真正需要自己设计的是server/api/routers目录下的模块边界。我习惯把资源维度作为切分依据而不是按页面切因为一个资源可能被多个页面共用按页面切会导致 router 之间互相引用类型推导变得混乱。初始化的另一个关键是环境变量。.env里至少要配DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL三个变量。当时我没注意NEXTAUTH_SECRET在生产环境必须手动设置结果部署到线上后一直报 JWT 解密失败查了半天才发现是环境变量缺失脚手架默认只在开发环境给你生成一个临时密钥。2.2 tRPC 路由设计与类型安全机制tRPC 的核心原理很朴素把服务端函数像本地函数一样暴露给客户端调用中间通过一个轻量 JSON 协议传输参数和返回结果。它最大的优势是类型安全不是靠运行时校验而是靠 TypeScript 的类型推导——服务端 resolver 的返回类型定了客户端拿到的数据形状就定了中间没有 DTO、没有手动类型断言。我在 t3code 里把 tRPC 分成三层设计底层server/api/trpc.ts定义createTRPCContext和publicProcedure、protectedProcedure。createTRPCContext里从 NextAuth 拿 session塞进每个请求的上下文。中间层server/api/rpc.ts负责服务端调用入口。业务层server/api/routers/按业务模块暴露接口。核心代码大致长这样// server/api/routers/snippet.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const snippetRouter createTRPCRouter({ list: protectedProcedure .query(async ({ ctx }) { return ctx.db.snippet.findMany({ where: { ownerId: ctx.session.user.id }, orderBy: { createdAt: desc }, include: { tags: true }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string().default(plaintext), tagIds: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, ownerId: ctx.session.user.id, tags: input.tagIds ? { connect: input.tagIds.map((id) ({ id })) } : undefined, }, }); }), });这里的protectedProcedure是 T3 生态里一个很经典的封装它在执行前先检查ctx.session是否存在不存在就抛错不需要在每个 resolver 里重复写鉴权判断。这个模式强烈建议推广到所有业务路由尤其是带写操作的功能。另一个要点是zod的输入校验。tRPC 和 zod 是深度集成的每个 mutation 的输入都可以用 zod schema 声明运行时做校验编译期做类型推导。写过一段时间的同学应该能体会到这等于把参数校验和类型安全一次性解决了不再需要前后端各写一套校验逻辑。2.3 Prisma 数据模型与迁移策略t3code 的数据库只有四张表User、Snippet、Tag、SnippetTag多对多关系表。Prisma schema 如下// 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 emailVerified DateTime? image String? snippets Snippet[] tags Tag[] } model Snippet { id String id default(cuid()) title String content String db.Text language String default(plaintext) isPublic Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt owner User relation(fields: [ownerId], references: [id], onDelete: CASCADE) ownerId String tags Tag[] relation(SnippetTags) } model Tag { id String id default(cuid()) name String unique snippets Snippet[] relation(SnippetTags) }多对多关系是这里最需要留意的部分。Prisma 对隐式多对多会生成一张中间表但如果中间表需要额外字段比如某个片段在某个标签下的排序权重就必须改为显式关系模型。我在 t3code 里用的是隐式多对多因为暂时没有扩展需求等真需要时再改也不迟——这符合 Prisma 官方推荐的演进步骤先跑通功能再按需演化。迁移策略上本地开发建议直接用prisma migrate dev它会自动生成 SQL 迁移文件并同步到数据库。但有一点要提醒如果你改 schema 只是临时加个字段测试用prisma db push更轻量它不产生迁移文件只把 schema 同步到数据库。生产环境则要走正规prisma migrate deploy确保迁移历史一致。我前期在本地频繁改表把db push和migrate dev混用结果导致迁移历史文件出现冲突最后只能清空重建教训很深刻。2.4 Tailwind 主题设计与暗色模式样式部分我用 Tailwind 搭配 next-themes 实现了明暗主题切换。Tailwind 3.3 之后的暗色模式有两个选择media策略跟随系统class策略手动切换。t3code 因为是开发者工具用户习惯自己控制主题所以选了class策略并在tailwind.config.ts里把 darkMode 设为 class。组件层面我尽可能先把颜色抽成语义化命名避免在 JSX 里堆满dark:bg-gray-800之类的重复类名。这个习惯在项目变复杂后收益明显改动一下全局配色只动配置文件就够了。另外代码高亮用的 rehype-pretty-code 集成在 MDX 流程里但 t3code 的片段内容并不是 Markdown 文档而是纯文本所以高亮直接交给 Prism 在组件里做没有引入重量级渲染器。对于只想展示代码块的场景轻量方案比全面方案合适得多。3. 实操过程与核心环节实现3.1 从零开始的项目初始化步骤如果你也想复刻一个类似项目初始化的完整操作是这样的# 1. 创建项目 pnpm create t3-applatest t3code # 进入交互后勾选 NextAuth、Prisma、Tailwind、tRPC # 2. 安装依赖并启动开发数据库这里用 Docker 起 PostgreSQL docker run --name t3code-db -e POSTGRES_USERt3code -e POSTGRES_PASSWORDt3code -e POSTGRES_DBt3code -p 5432:5432 -d postgres:16 # 3. 配置 .env 里的 DATABASE_URL # postgresql://t3code:t3codelocalhost:5432/t3code # 4. 首次同步数据库 pnpm prisma migrate dev --name init # 5. 启动开发服务器 pnpm dev这里有个值得说的细节create-t3-app默认把 NextAuth 的依赖和示例代码都生成好了但你仍然需要去server/api/trpc.ts里确认 session 的获取逻辑。默认生成的createTRPCContext是从getServerSession拿 session 的这在服务端组件和路由处理器里没问题但如果某个页面是纯客户端组件渲染就要注意 session 可能为空需要显式传 token 或改用其他获取方式。3.2 CRUD 接口的完整实现链路以前面列出的 snippet router 为基础前端调用逻辑通过 tRPC 的 React Query 集成来写。t3code 的列表页大概长这样// app/(main)/snippets/page.tsx use client; import { api } from ~/trpc/react; export default function SnippetListPage() { const utils api.useUtils(); const { data: snippets, isLoading } api.snippet.list.useQuery(); const deleteSnippet api.snippet.delete.useMutation({ onSuccess: () utils.snippet.list.invalidate(), }); if (isLoading) return div加载中.../div; return ( div classNamegrid gap-4 {snippets?.map((snippet) ( SnippetCard key{snippet.id} snippet{snippet} onDelete{() deleteSnippet.mutate({ id: snippet.id })} / ))} /div ); }注意api.useUtils()的用法它是 tRPC v10 之后推荐的缓存管理方式可以通过invalidate让某个查询重新拉取数据。以前很多人习惯在 mutation 成功后手动调router.refresh()或用useState强行刷新页面但正确的做法是让数据层感知变化。这个认知转变对使用 T3 框架非常重要React Query 的缓存、失效、重试机制本身就是状态管理没必要再用 Redux 或者自研 store。删除接口的实现也很简单delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { return ctx.db.snippet.deleteMany({ where: { id: input.id, ownerId: ctx.session.user.id, }, }); }),这里我用的是deleteMany而不是delete同时把ownerId放进过滤条件。这样即使请求被恶意构造也只能删自己的片段等于在数据库层面又加了一道权限保护。很多新手写删除接口时只传 id那是非常危险的。3.3 认证流程与用户体系接入基于 T3 生态的推荐方案用户体系直接用 NextAuthGitHub Provider 作为登录方式。认证流程实现起来意外地短核心配置如下// server/auth.ts import NextAuth from next-auth; import GitHub from next-auth/providers/github; import { PrismaAdapter } from next-auth/prisma-adapter; import { db } from ~/server/db; export const { handlers, auth, signIn, signOut } NextAuth({ adapter: PrismaAdapter(db), providers: [GitHub], session: { strategy: jwt }, callbacks: { jwt({ token, user }) { if (user) token.id user.id; return token; }, session({ session, token }) { if (session.user token.id) { session.user.id token.id as string; } return session; }, }, });这里有个值得注意的选择PrismaAdapter配合 JWT 策略。默认情况下使用数据库适配器会倾向于把 session 存在数据库里但在 serverless 环境每次请求都查 session 表会额外引入一次数据库查询性能上不划算。JWT 方案把 session 信息编码进 cookie 里减少一次 IO代价是登出状态有一定的缓存时间。对 t3code 这种对安全等级要求不极端的小工具JWT 是更务实的选择。认证接入后前端可以很方便地判断用户状态。登录页直接调用signIn(github)退出用signOut()中间页面判空跳转即可。整个认证链路在 T3 里属于脚手架级体验真正要花心思的反而是 session 类型扩展——注意在next-auth.d.ts里声明session.user.id否则 TypeScript 会一直提示你访问了不存在的属性。3.4 部署上线与环境配置部署我用的是 Vercel因为和 Next.js 的亲和度最高一个小工具直接推 GitHub 仓库自动构建即可。部署前需要准备的配置项包括DATABASE_URL指向生产数据库建议用云厂商的 PostgreSQL不要用 Vercel Postgres 或者其他和平台绑定的存储来做核心库避免未来迁移成本。NEXTAUTH_SECRET必填可以在本地用openssl rand -base64 32生成。NEXTAUTH_URL正式域名地址比如https://t3code.vercel.app。GITHUB_ID/GITHUB_SECRETGitHub OAuth App 的回调地址要改成生产域名。部署后最常踩的坑是 Prisma Client 没有正确生成。Vercel 构建时需要在postinstall或构建命令里加上prisma generate否则线上环境找不到客户端。我一开始没配结果本地好好的线上报PrismaClient is not defined排查十分钟才想到是构建阶段的问题。另一个线上环境的重要技巧如果数据库连接字符串用的是连接池形式的 URL比如带?pgbouncertrue或加pool_timeout参数的Prisma 在 serverless 环境对连接数很敏感很容易触发连接数上限。解决方式是在简历中把 Prisma 的客户端配置改成?connection_limit5限制单实例的连接池大小避免冷启动时创建过多连接。这个细节不放真实环境里跑一遍靠看文档很难意识到。4. 常见问题与排查技巧实录4.1 类型推导失灵的三个典型场景tRPC 用起来最顺畅的时刻是类型全对的时候但类型一旦静默失效排查起来就很折磨。我遇到最多的三类情况跨度组件的类型不刷新。在 Next.js App Router 下如果你在 Server Component 里调用了 tRPC 并传递数据给 Client Component这时类型会通过 props 传递如果中途做了JSON.stringify或as any类型链就会断开后续报错会让你误以为是组件问题。解决方式尽量不让 tRPC 查询结果跨过组件边界每个需要数据的地方自己调一次 query让 React Query 去处理缓存。输入类型不匹配。zod 的默认类型和 Prisma 的字段类型偶尔会出现推导差异比如数字字段返回给前端后变成字符串。遇到这种情况优先检查表单提交有没有做隐式转换而不是怀疑 tRPC。没有调用useQuery就访问 data。api.snippet.list.useQuery()返回的 data 可能在首帧是 undefined有些人会在useEffect里直接访问导致类型收窄失败。最佳实践是用isLoading或isSuccess做分支判断让 TypeScript 在控制流分析里收窄类型。4.2 Prisma 迁移冲突与连接问题Prisma 的坑是 T3 项目里最脏最累的部分。最典型的场景是多人协作或本地多次改动 schema 后migrations文件夹和数据库的迁移记录不一致跑prisma migrate dev时会提示检测到漂移然后要求你重置数据库。这个重置行为会把所有数据清空在开发环境无所谓但对那些已经往数据库里塞了真实测试数据的人非常容易造成心理阴影。我的规避办法是本地开发尽量用db push来快速同步 schema只有在生产环境或者需要稳定迁移记录时才跑migrate。同时每次改 schema 前先备份一次数据库用一条pg_dump命令把数据拉下来成本极低收益极高。连接问题主要在部署后出现。serverless 环境里每次函数冷启动都会创建新的 Prisma 客户端实例如果直接用全局单例的写法可能因为连接数堆积导致数据库请求延迟飙升。官方推荐的全局单例写法如下// server/db.ts import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;这个写法在开发环境利用 global 缓存单例在生产环境每次冷启动仍然会新建实例但配合连接数限制后实测可控。4.3 组件边界与use client误用问题App Router 的组件边界是所有 Next.js 开发者绕不开的门槛。t3code 里我遇到过这样一段代码// 错误示范 import { SnippetCard } from ~/components/SnippetCard; // 内部用了 useState import { api } from ~/trpc/server; // 服务端调用 export default async function Page() { const snippets await api.snippet.list(); return SnippetCard snippets{snippets} /; // 传给客户端组件时类型兼容出问题 }api从~/trpc/server导入返回的是一个 Promise 的结果传给客户端组件后组件内部如果要操作数据删除、更新需要重新发起客户端查询直接用服务端返回的数据会丢失响应式。后来我统一改成页面组件一律用use clientuseQuery需要 SEO 的少数页面再用服务端组件包一层壳避免在边界问题上反复折腾。还有一个细节所有与 tRPC 交互的组件都必须被TRPCProvider包裹这是 t3code 里放在app/providers.tsx的核心根因。如果你在某个独立组件里忘了包裹就调用api.*运行时会报No TRPCClient instance found且这个错误不像类型错误那么好理解。4.4 React Query 缓存与数据一致性问题React Query 是 tRPC 前端状态管理的发动机但大多数人只会用useQuery和useMutation对缓存失效机制理解不足。t3code 里我只做了一个很小的功能——创建新片段后列表页立即刷新。实现方式是在创建 mutation 的onSuccess回调里调用utils.snippet.list.invalidate()。这个操作会标记查询为过期触发重新拉取。但这里有个隐藏问题如果列表查询带分页参数invalidate只让当前页的查询失效其他页可能仍然是旧数据。解决方案有两个要么把缓存 key 设置成包含所有参数的形式要么干脆用utils.snippet.list.refetch()强制全量重拉。个人工具的规模小我选择了 refetch 全量简单粗暴效。理解这套缓存语义比背住几个 API 方法重要得多。5. 复盘与后续扩展思路5.1 最有价值的三条工程经验单体优先不是口号是真的省心。t3code 的核心链路从数据库到 UI 都是 TypeScript我没写过一行接口文档没做过一次联调也没维护过 mock 数据。这种内聚性在一个人或小团队场景下简直是效率倍增器。类型安全不等于不需要运行时校验。虽然 tRPC 和 zod 的组合让类型推导极其强大但外部输入、数据库返回值、第三方回调仍然存在运行时的不确定性。所以在所有 mutation 入口保留 zod 校验在关键查询上保留isLoading分支是我坚持不破坏的底线。工具链的原生配置别乱改。create-t3-app 生成的默认配置路径别名、tsconfig、eslint 规则都是社区反复打磨过的如果不是有明确理由尽量不要做激进的替代。我有一次为了更清晰调整了 tsconfig 的 paths 配置结果导致部分类型别名解析异常浪费了半小时。5.2 下一步想做的改进方向t3code 目前能跑能用但离爽还有距离。后续如果继续迭代我优先考虑三个方向把搜索从ILIKE升级到 PostgreSQL 全文搜索加一个tsvector索引让检索速度和相关性排序都上一个台阶。做浏览器插件或命令行工具方便在任意编辑器里直接保存选中代码到 t3code真正成为一个随时可用的第二大脑。补充更多安全措施比如公开片段的访问频率限制、敏感模式自动剔除以及审计日志。不过这些都是以后再说的清单。这次开发最大的体会已经拿到手了技术选型不需要追新而是要找能让你用最少的代码解决真实问题的那套组合。T3 对很多人来说只是又一个框架缩写但它在 t3code 里确实把我的开发节奏带快了不少。如果你也打算做内部工具或小体量产品可以拿这篇文章当一份可行性参考剩下的就是自己动手踩一踩那些只属于你的坑了。