先说结论:我最近把一个开发了大概三周的小项目 t3code 推上线了,这是一个基于 T3 Stack(Next.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-app@latest它会问你用哪个包管理器、是否需要 NextAuth、Prisma、Tailwind、ESLint 等。第一次用的时候我把能勾的都勾了,结果发现里面藏了不少坑。下面把关键选项逐个说一遍我的选择理由。
- App Router 还是 Pages Router?create-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.context<typeof 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.id:NextAuth 的 JWT 里默认确实会带
sub字段,但不同 provider 下这个值的含义不稳定(OAuth 的 sub 是社交账号 ID,Credential 登录又是另一套)。统一用 email 去数据库反查一次最稳,即使未来加了新的登录方式,这里也不用改。 - middleware 里只允许 next() 带 ctx 转发,不能直接改 context 的引用。我一开始写过
ctx.session.user.id = user.id,结果在 strict mode 下直接报错,因为 context 对象被冻结了。 - 查询语句有依赖关系时要小心 N+1:我在 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.ComponentProps<"button"> & VariantProps<typeof 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 className="light"> <div dangerouslySetInnerHTML={{ __html: lightHtml }} /> <div className="dark:hidden" dangerouslySetInnerHTML={{ __html: darkHtml }} /> </div> ); }这个方案有两个坑:一是 Shiki 的codeToHtml只能传已经安装的语言和主题,遇到不认识的 language 值会直接抛错,我在调用前做了 fallback 处理;二是包体积,Shiki 全量语言打包会让服务端 bundle 膨胀到 2MB 以上,需要用动态加载或只预装常用语言集合。个人项目里直接预装 20 个常见语言就够用了。
5. 部署与上线:Vercel + PostgreSQL 的实测记录
5.1 环境变量与构建失败的坑
部署前我先把.env里的变量理了一遍,区分了生产环境和本地环境:
DATABASE_URL="postgresql://user:password@host:5432/t3code?sslmode=require" NEXTAUTH_SECRET="openssl rand -base64 32" NEXTAUTH_URL="https://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 官方在连接串文档里也提到了这一点。如果用的是连接池中间件,连接串要写成?pgbouncer=true&connection_limit=1;如果是直连,直接sslmode=require就够。
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、类型边界和权限模型这三样,一定要在一开始就钉死。它们决定了项目后面会不会越改越乱。