先说结论我最近把一个开发了大概三周的小项目 t3code 推上线了这是一个基于 T3 StackNext.js TypeScript Tailwind CSS tRPC的代码片段管理工具。核心功能很简单注册登录、创建代码片段、打标签、全文检索、生成分享链接。技术栈上用了 create-t3-app 的标准组合补上了 Prisma 和 NextAuth数据库用的 PostgreSQL部署在 Vercel。之所以想做这个东西是因为我手头的代码片段一直散落在本地笔记、GitHub Gist 和微信收藏里真要找的时候哪个都不顺手。如果你刚好也在学 T3 Stack或者想找一个“中等复杂度、但不至于失控”的全栈项目练手这篇内容应该对你有用。我会把从立项、建模、写 tRPC 接口、调 Tailwind 到部署上线的完整过程写出来包括那些只在真实项目里才会碰到的坑。每段我都会解释“为什么这么选”毕竟单纯贴代码的教程到处都是真正值钱的是选择背后的权衡。1. t3code 是怎么来的一个被“复制粘贴”逼出来的项目1.1 我对 T3 Stack 的判断先说技术选型。现在做一个全栈 Web 项目可选的组合太多了Next.js 全家桶、Nuxt、Remix、Astro后端再配个 FastAPI 或者 NestJS 也完全可行。之前在公司项目里我长期用“Next.js 前端 独立 Node 后端 手写 REST 接口 前后端各维护一份类型定义”的模式这种模式的问题大家心里都清楚接口文档经常过期、字段名两边不一致、改一个实体字段要同步改四五处。T3 Stack 的核心卖点就是拿 TypeScript 把前后端类型串起来让整个调用链路是端到端类型安全的。我在 t3code 里最终选的组合是 Next.js 14 App Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth。简单说一句我的判断这个组合非常适合“中等复杂度、需要登录态、有数据库交互”的 Web 应用恰好代码片段管理就是这类应用。它能让我把更多精力放在业务逻辑上而不是在类型定义和接口对接上反复摩擦。1.2 产品边界不做编辑器只做整理立项的时候我反复提醒自己别过度设计。t3code 不是编辑器不是 IDE 插件也不是要跟 CodePen 竞争。它只解决一件事把零散的代码片段统一收进来能在几秒内找到。所以 MVP 范围压缩成几条邮箱密码注册登录、片段 CRUD、标签、搜索、分享只读链接。编辑器用 textarea 加上基础 Tab 支持就够了代码高亮放到展示页做。范围控制好后续才有精力把技术细节打磨到位。这个边界我现在回头看非常重要。很多个人项目死掉不是技术上做不到而是想做的东西太多最后连 MVP 都没跑通。如果你也想复刻一个类似 t3code 的项目我建议你先把“不做清单”写出来比“功能清单”还重要。2. 初始化落地create-t3-app 每一步选择背后的原因2.1 脚手架选项逐项拆解初始化用官方脚手架最省事npm create t3-applatest它会问你用哪个包管理器、是否需要 NextAuth、Prisma、Tailwind、ESLint 等。第一次用的时候我把能勾的都勾了结果发现里面藏了不少坑。下面把关键选项逐个说一遍我的选择理由。App Router 还是 Pages Routercreate-t3-app 默认给 App Router我选了 App Router。虽然当时我对 Server Components 还不算熟但 tRPC 的 server caller、Server Actions 这些新方向都建立在 App Router 上我不想项目刚起步就走旧路线。Prisma必选没有 ORM 直接写 SQL 的话开发效率会低很多尤其是后面要加字段的时候。NextAuth我一开始也选了后来发现默认的 JWT 策略和 tRPC 的服务端 session 查询需要自己串一下这个细节我会在第三章展开。Tailwind没悬念它就是为这种“个人全栈项目”的量身定做的样式方案。ESLint保留默认配置。Prettier 我单独装了一下个人习惯是 lint 和 format 分开避免争论。整个初始化大概五分钟就完成了。这里有个提醒create-t3-app 会生成一份非常完整的 README里面写了目录结构、环境变量、生产部署注意事项。很多人直接跳过不看里面的“Deployment”章节其实比网上大部分教程都准确。2.2 目录结构与数据模型设计初始化完成后关键目录是这样的src/ pages/api/trpc/[trpc].ts server/ db.ts auth.ts api/ root.ts routers/ snippet.ts user.ts trpc/ server.ts react.tsx我对 Prisma 数据模型的初始定义很简单只有三个模型User、Snippet、Tag。下面是核心 schema 的一部分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 content String language String default(plaintext) visibility String default(private) author User relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String tags Tag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt index([authorId]) index([updatedAt]) } model Tag { id String id default(cuid()) name String snippets Snippet[] createdAt DateTime default(now()) unique([name]) }几个建模时的关键决策Tag 用多对多而不是在 Snippet 上存一个字符串数组。PostgreSQL 本身支持字符串数组但多对多关系能让我后续做“按标签聚合”“标签自动补全”时不用动 schema。language 字段用字符串没有建枚举。因为代码语言列表太长几十种枚举维护成本高直接字符串配合前端下拉框足够。visibility 字段虽然 MVP 里可以先写成private写死但我还是留了字段位后面做“公开分享”功能就不需要改表结构。updatedAt 索引列表页要按更新时间排序这个索引几乎是必需的。一开始我没加数据量到几百条的时候排序性能已经能感觉到差异。3. tRPC 实现核心业务让全局类型安全真正跑起来3.1 query 和 mutation 的实战写法t3code 最有代表性的代码是 snippet 的 CRUD。我直接放一个 router 的实现这是整个项目的中枢import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; const snippetInput z.object({ title: z.string().min(1).max(100), content: z.string().min(1).max(30000), language: z.string().max(50).default(plaintext), tagNames: z.array(z.string().max(30)).max(10).default([]), }); export const snippetRouter createTRPCRouter({ list: protectedProcedure .input( z.object({ search: z.string().max(100).optional(), tag: z.string().max(30).optional(), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) { const where { authorId: ctx.session.user.id, ...(input.search ? { OR: [ { title: { contains: input.search, mode: insensitive } }, { content: { contains: input.search, mode: insensitive } }, ], } : {}), ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), }; const items await ctx.db.snippet.findMany({ where, include: { tags: true }, orderBy: { updatedAt: desc }, take: 20, ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}), }); return { items }; }), create: protectedProcedure .input(snippetInput) .mutation(async ({ ctx, input }) { const tagNames Array.from(new Set(input.tagNames)); const tags tagNames.map((name) ({ where: { name }, create: { name }, })); return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, authorId: ctx.session.user.id, tags: { connectOrCreate: tags }, }, include: { tags: true }, }); }), delete: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) { const snippet await ctx.db.snippet.findUnique({ where: { id: input.id }, }); if (!snippet || snippet.authorId ! ctx.session.user.id) { throw new Error(Not found or not allowed); } return ctx.db.snippet.delete({ where: { id: input.id } }); }), });写 tRPC 的过程其实是“先定义输入输出类型再写业务逻辑”。输入用 zod 定义比如 snippet 标题 1 到 100 字content 最长 30000 字。写完 router前端调用时类型会自动透传不需要任何代码生成步骤。前端调用长这样const utils api.useUtils(); const createSnippet api.snippet.create.useMutation({ onSuccess: () { utils.snippet.list.invalidate(); }, }); // 调用时入参类型已经由 snippetInput 推导 createSnippet.mutate({ title: 防抖函数, content: export function debounce..., language: typescript, tagNames: [工具函数, 前端], });最爽的一个细节后端 z.string().min(1) 这类校验前端在createSnippet.mutate的入参里会得到 TypeScript 的类型约束提示但运行时校验是后端做的。这意味着我不需要在表单里再写一套“标题不能为空”“内容不能超过 30000 字”的校验逻辑zod 错误可以直接从 tRPC 的 error shape 里拿出来展示。一个约束前后端同时生效这才是端到端类型安全真正的价值。3.2 protectedProcedure 与权限校验t3code 有个关键需求片段默认只有创建者可见。我一开始偷懒把 list 和 getById 都挂成 publicProcedure结果自己在浏览器里一测发现登录前访问接口也能把别人创建的 snippet 拉出来。这个问题必须堵住。tRPC 的 protectedProcedure 是通过 middleware 判断 session但 NextAuth 默认的 JWT 里并不含数据库 user id需要在 middleware 里查一次用户表把 user 挂到 ctx 上。我在server/api/trpc.ts里做了这个处理import { initTRPC, TRPCError } from trpc/server; import { type Session } from next-auth; import { db } from ~/server/db; export const createTRPCContext async (opts: { session: Session | null; }) { return { db, session: opts.session, }; }; const t initTRPC.contexttypeof createTRPCContext().create(); const isAuthed t.middleware(async ({ ctx, next }) { if (!ctx.session?.user?.email) { throw new TRPCError({ code: UNAUTHORIZED }); } const user await db.user.findUnique({ where: { email: ctx.session.user.email }, }); if (!user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: { ...ctx.session.user, id: user.id } }, }, }); }); export const protectedProcedure t.procedure.use(isAuthed);这里有几个细节值得单独说明为什么用 email 查 user 而不是直接用 session.user.idNextAuth 的 JWT 里默认确实会带sub字段但不同 provider 下这个值的含义不稳定OAuth 的 sub 是社交账号 IDCredential 登录又是另一套。统一用 email 去数据库反查一次最稳即使未来加了新的登录方式这里也不用改。middleware 里只允许 next() 带 ctx 转发不能直接改 context 的引用。我一开始写过ctx.session.user.id user.id结果在 strict mode 下直接报错因为 context 对象被冻结了。查询语句有依赖关系时要小心 N1我在 snippet.list 里用了include: { tags: true }一次性把关联标签捞出来避免前端渲染时再逐条查。服务端权限判断做好之后每个 query 只需要一行protectedProcedure就安全了。前端再配合 next-auth/react 的useSession控制 UI 显隐双保险。个人项目里用户量不大这种简单直观的方案比复杂的 RBAC 系统靠谱得多。4. 样式与交互Tailwind 在真实项目里的组织方式4.1 组件的样式层级Tailwind 写起来快但页面一多就会乱成一大锅 className。分享下我在 t3code 里实际落地的方式这套组织对 5000 行以内的前端项目基本够用。我把样式拆成三个层级原子组件层Button、Input、Card、Badge 这些通过variantsprop 控制风格变化不直接暴露一堆布尔 prop。业务组件层SnippetCard、TagInput、CodeBlock 这些组合原子组件内部样式相对集中。页面布局层布局和间距类样式放在页面组件里不往业务组件里塞。一个典型的 Button 组件长这样import { cva, type VariantProps } from class-variance-authority; const buttonVariants cva( inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50, { variants: { variant: { primary: bg-blue-600 text-white hover:bg-blue-700, secondary: bg-gray-100 text-gray-900 hover:bg-gray-200, danger: bg-red-600 text-white hover:bg-red-700, ghost: hover:bg-gray-100 hover:text-gray-900, }, size: { sm: h-8 px-3 text-xs, md: h-10 px-4, lg: h-11 px-6 text-base, }, }, defaultVariants: { variant: primary, size: md, }, } ); export function Button({ variant, size, className, ...props }: React.ComponentPropsbutton VariantPropstypeof buttonVariants) { return ( button className{cn(buttonVariants({ variant, size }), className)} {...props} / ); }class-variance-authority 是 Tailwind 生态里处理组件变体最顺手的库没有之一。没有它的话你会在每个组件里写一堆variant primary ? bg-blue-600... : bg-gray-100...的三元判断改起样式来真的头疼。颜色和间距我全部走 Tailwind 主题变量。tailwind.config.ts里自定义了品牌色并开启了暗黑模式的 class 模式import type { Config } from tailwindcss; import { fontFamily } from tailwindcss/defaultTheme; export default { darkMode: [class], content: [./src/**/*.{ts,tsx}], theme: { extend: { colors: { brand: { 50: #eff6ff, 500: #3b82f6, 600: #2563eb, 700: #1d4ed8, }, }, fontFamily: { mono: [var(--font-mono), ...fontFamily.mono], }, }, }, plugins: [], } satisfies Config;4.2 暗黑模式与代码高亮的适配代码片段展示是 t3code 的主场景代码高亮我选了 Shiki。它跟 textarea 编辑的代码块无关只在“只读分享页”和“详情页”做高亮。暗黑模式用 next-themes 包一层这样主题切换不会导致闪烁。关键问题是Shiki 的语法高亮是按主题渲染出带 style 的 HTML 的不能直接响应 Tailwind 的暗黑切换。我的做法是在服务端组件里用 Shiki 的codeToHtml分别生成 light 和 dark 两套高亮 HTML然后根据主题切换用 CSS 控制哪套显示。听起来有点笨但实测下来性能和体验都很好import { codeToHtml } from shiki; export async function CodeBlock({ code, language, }: { code: string; language: string; }) { const [lightHtml, darkHtml] await Promise.all([ codeToHtml(code, { lang: language, theme: github-light, }), codeToHtml(code, { lang: language, theme: github-dark, }), ]); return ( div classNamelight div dangerouslySetInnerHTML{{ __html: lightHtml }} / div classNamedark:hidden dangerouslySetInnerHTML{{ __html: darkHtml }} / /div ); }这个方案有两个坑一是 Shiki 的codeToHtml只能传已经安装的语言和主题遇到不认识的 language 值会直接抛错我在调用前做了 fallback 处理二是包体积Shiki 全量语言打包会让服务端 bundle 膨胀到 2MB 以上需要用动态加载或只预装常用语言集合。个人项目里直接预装 20 个常见语言就够用了。5. 部署与上线Vercel PostgreSQL 的实测记录5.1 环境变量与构建失败的坑部署前我先把.env里的变量理了一遍区分了生产环境和本地环境DATABASE_URLpostgresql://user:passwordhost:5432/t3code?sslmoderequire NEXTAUTH_SECRETopenssl rand -base64 32 NEXTAUTH_URLhttps://your-app.vercel.app第一次部署到 Vercel 的时候构建失败日志指向 Prisma Client。原因是 Vercel 的安装依赖阶段没有自动执行prisma generate导致运行代码里prisma/client找不到生成的 client。解决办法是在 package.json 的构建脚本里显式加上{ scripts: { build: prisma generate next build } }如果你用的是 Vercel还需要在 project 的 Environment Variables 里把.env的变量照着填一遍。Vercel 会用环境变量文件覆盖同名配置但不会自动加载本地.env文件二者是独立的。这个问题我遇到不止一次每次都以为是代码问题最后发现只是环境变量没同步。另外要特别提醒NEXTAUTH_SECRET 千万别用同一个固定字符串。我见过有项目直接用 my-secret 上线等于给攻击者留了一扇大门。用openssl rand -base64 32生成一个真正的随机值然后放到环境变量里。新版本 NextAuth 已经支持AUTH_SECRET命名我实测下来两者兼容以实际报错提示为准。5.2 连接池与冷启动的取舍Vercel 的 Serverless 环境下每个请求都可能被分发到不同的函数实例。如果每个实例都各自 new 一个 PrismaClient数据库连接数会指数级上涨PostgreSQL 默认最大连接数一般是一百个左右流量稍微上来就会打爆。业内标准解法是让 Prisma 实例保持全局单例。我在服务端用了一个独立的模块来管理import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV development ? [query, error] : [error], }); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }这段代码在开发环境使用 global 对象缓存避免 Next.js 的 Fast Refresh 反复创建实例生产环境则走单例。数据库连接串上对 Serverless 场景我建议加上连接池参数Prisma 官方在连接串文档里也提到了这一点。如果用的是连接池中间件连接串要写成?pgbouncertrueconnection_limit1如果是直连直接sslmoderequire就够。tRPC 是打包在 Next.js API 路由里的每个独立函数都有冷启动成本。冷启动最直观的表现是上线后第一次访问可能要等 2 到 3 秒后续请求就正常了。t3code 的 API 路由数量不多所以我用了一个很简单的观察办法在 tRPC 的 onError 钩子里打上耗时日志冷启动慢的路由一眼就看出来了。对个人项目来说不必一上来就上各种边缘缓存或持续在线插件先把单例、连接池、索引这几个基本功做好性价比高得多。6. 代码评审视角如果有人想复刻 t3code我会让他注意什么这个项目做完之后我自己做了一轮代码评审。下面这几条是我踩过坑之后最想让别人提前知道的地方。6.1 schema 迁移别小看 updatedAt 和索引Prisma 的updatedAt不会在数据库层自动触发而是由 Prisma Client 写入时维护的。如果你在 SQL 层直接改数据updatedAt 不会自动更新。另外mode: insensitive的contains在 PostgreSQL 上会走 ILIKE数据量到万级别之后性能会急剧下降。t3code 现在几百条数据没问题但我会在 README 里标注如果搜索成为核心场景尽早切换到 PostgreSQL 全文检索而不是继续用 contains。6.2 NextAuth 的 session 类型别乱传我有一次在 tRPC 的 protectedProcedure 里直接读ctx.session.user.id当时 TypeScript 没报错但是运行拿到 undefined。原因就是 NextAuth 默认的类型声明里 user.id 不是必选字段而我在 middleware 里重新挂载之后类型定义没有同步。后来我在项目的类型声明文件里扩展了 Session 和 User 类型import { DefaultSession } from next-auth; declare module next-auth { interface Session { user: { id: string; } DefaultSession[user]; } }类型系统只在你诚实地描述数据形状时才有用。糊弄过去一时爽排查起来火葬场。6.3 zod schema 的复用策略我在前端表单里直接用了服务端的 snippetInput 来推导类型import type { RouterInputs } from ~/trpc/react; type CreateSnippetInput RouterInputs[snippet][create];这样后端改了字段前端类型立刻报错鸭梨全在开发阶段而不是线上运行时。同时zod schema 本身我放在了server/api/routers/snippet.ts里导出前端如果连表单运行时校验都想要可以直接调safeParse不需要在客户端复制一份。6.4 react-query 的缓存 key 设计tRPC 内置了 react-query前端调用api.snippet.list.useQuery()时key 是根据 input 自动生成的。要注意的是如果你的 input 里传了一个每次渲染都重新创建的对象比如{ search: undefined }可能导致缓存失配。我的做法是传空值时直接用 undefined不让对象里残留无意义的字段。这个小问题看起来不起眼遇到列表不刷新的 bug 时很恼人。6.5 多环境的数据库隔离t3code 的本地开发和生产数据库我是完全分离的。本地用 Docker 起一个 PostgreSQL生产用云数据库。千万别为了省事让本地直接连生产库你的一次prisma migrate reset会清空所有线上数据。这个教训我是在另一个项目里用真金白银买回来的说出来都是泪。最后t3code 上线后我自己的使用习惯已经明显变了以前随手丢进临时文件的片段现在都会统一收进去。代码片段这种东西关键不在存了多少而在你要用的时候能不能三秒内找到。T3 Stack 这套组合让我在写这个项目时几乎没在“前后端对接”上浪费时间大部分精力都放在了数据建模和交互细节上。如果让我从头再做一次我会在一开始就把“公开分享页”的缓存策略想清楚而不是上线后临时补。这也算是给后来者留一条捷径项目初期写代码时可以粗糙一点但 schema、类型边界和权限模型这三样一定要在一开始就钉死。它们决定了项目后面会不会越改越乱。