我最近在做一个代号叫 t3code 的全栈项目,起因很简单:手里积压了一堆零散的代码片段,每次要用都得翻聊天记录、翻 gist、翻本地文件,实在有点烦。我更想把这些 snippet、小工具、常用配置统一管理起来,顺手再练一练这两年很热门的 T3 Stack 技术栈。这个项目折腾了大半个月,从初始化、建模、写接口、做前端到最终部署上线,一路上踩了不少坑,也积累了不少真实可复用的工程经验,今天把这些东西完整复盘一遍,希望能给正在学习 T3 技术栈、或者打算自己从零搭一个全栈应用的朋友一些参考。
这个项目本身不做复杂的业务,核心就是把代码片段管理做成一个真正的产品形态:用户可以注册登录,把自己的 snippet 按标签分类存起来,支持语法高亮、模糊搜索、深色模式,也能把代码片段分享出去给别人看。技术上选择的是 T3 Stack 标准路线——Next.js 做前端界面和 API 层,tRPC 做前端到后端的数据调用,Prisma 负责数据库建模和迁移,TailwindCSS 处理样式,NextAuth 负责登录认证。文中所有流程都是我实际跑通的,不是搬运文档,每一步都有可复现的价值。
1. 为什么用 t3code 这个名字:技术选型背后的组合逻辑
1.1 名称由来与项目定位:T3 Stack 一次完整的结对实践
T3 Stack 这个名字源自 Theo 的 create-t3-app 脚手架,核心团队在推广时反复强调一个理念:type-safe first,类型安全优先。但我更愿意把它理解成“一套不给开发者留隐患的默认约定”。它不像传统前后端分离项目那样,你需要自己定义 REST 接口、自己维护接口文档、自己在后端写一遍校验、在前端又写一遍校验——T3 的每一层都在尽量消除这种重复。
我给自己定的产品定位也很清楚:这是一款面向开发者的 snippet 管理与分享应用。它解决三个具体痛点:代码片段散落在各处、搜索效率低、分享成本高。所以功能边界非常收敛,不多做,只做注册登录、snippet 的增删改查、标签过滤、搜索、代码高亮展示和分享页。
因为是学习型项目,在命名上我还刻意保留了一点技术梗:t3code 中的 t3 直接取自 T3 Stack 的三位核心成员——TypeScript、tRPC、Tailwind(另外两个大件是 Next.js 和 Prisma,组成了完整的 T3 五件套)。项目名和实际技术栈一致,查代码、写博文、做演示的时候都不会产生认知偏差。
1.2 五个核心技术成员各司其职
如果你对 T3 Stack 还不太熟,我先花一点篇幅把这五个成员的职责拆开,因为它们后续的分工协作直接决定了项目的整体架构。
| 技术选型 | 职责定位 | 在 t3code 中的角色 |
|---|---|---|
| Next.js | 应用框架 | 提供页面路由、服务端渲染、API 能力,是整个应用的外壳 |
| TypeScript | 语言层 | 前后端统一使用,保证数据类型在链路中不丢失 |
| tRPC | 数据通信 | 替代传统 REST,让前端直接调用后端函数,且全程类型推导 |
| Prisma | 数据库工具 | ORM 层,负责建表、迁移、客户端查询 |
| NextAuth.js | 认证方案 | 接管登录注册,支持 GitHub OAuth 与 Credentials 两种方式 |
| TailwindCSS | 样式方案 | 原子化 CSS,负责所有页面外观和响应式布局 |
我第一次看这个组合的时候也在想,为什么不用 Express?为什么不用 GraphQL?为什么不用 Redux?实际建起来之后感触很深:T3 这套方案最大的好处不是单个组件多强,而是它们之间的衔接几乎没有“胶水代码”。比如 tRPC 的 router 输入和输出直接由 TypeScript 推导,Prisma 生成的 Client 类型又可以直接被 tRPC 引用,这意味着我在前端写snippet.list()的时候,返回值的结构、字段的类型都是编辑器自动提示的——没有接口文档,没有手动定义 DTO,更没有联调阶段对字段名对半天的痛苦。
2. 从 create-t3-app 起步:初始化选项与数据建模
2.1 脚手架初始化的选项取舍
创建项目我用的是官方脚手架,命令一行:
pnpm create t3-app@latest t3code这个交互式命令会问一堆问题,包括是否包含 NextAuth、Prisma、Tailwind,以及使用什么样的包管理器。我当时的勾选情况是:NextAuth 选上,Prisma 选上,Tailwind 选上,tRPC 默认选上,ESLint 和 Prettier 都选上。原因很直接:这是一次完整的全栈学习与实践,凡是能省掉手工配置的环节,尽量让脚手架生成,我再在生成结果上做二次开发。
启动后项目结构非常规整,关键路径几个:
src/server/api/routers/:所有 tRPC 路由的定义src/server/api/root.ts:路由聚合入口src/server/db.ts:Prisma Client 单例src/pages/api/trpc/:Next.js 到 tRPC 的桥接处理器src/server/auth.ts:NextAuth 配置
这里我要给新手一个非常具体的建议:不要急着改目录结构,先跑通脚手架默认的页面和接口链路。默认项目里有一个/api/trpc的调用示例,浏览器里能看到post.all()的返回。先把这条链路跑通,再开始加自己的业务模块,排查问题时会容易很多——因为你能确认“脚手架是健康的”,之后出的问题都是你自己代码的问题。
2.2 业务数据模型设计:snippet 需要哪些表
t3code 的核心数据其实非常直观。我需要管理实体:用户 User、代码片段 Snippet、标签 Tag、以及它们之间的关联关系。
用 Prisma 定义数据模型时,我一开始建得很朴素,后面因为业务需要又加了一些字段。最后落定的简化版本长这样:
model User { id String @id @default(cuid()) name String? email String? @unique image String? snippets Snippet[] createdAt DateTime @default(now()) } model Snippet { id String @id @default(cuid()) title String description String? code String language String @default("plaintext") isPublic Boolean @default(true) tags Tag[] author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String snippets Snippet[] }这里有几个设计决策值得展开说一下。
第一,为什么代码内容用 String 而不是 Text 类型?Prisma 早期版本其实没有原生的 Text 类型,String 在底层映射到数据库的 text / varchar,完全够用。真正需要注意的是updatedAt DateTime @updatedAt这个字段,Prisma 会在每次更新记录时自动刷新,不需要你在代码里手动赋值,这是很省心的一个细节。
第二,为什么是一个多对多关系?一个 snippet 可以有多个标签,一个标签也可以挂在多个 snippet 上。Prisma 会自动生成一张关系表来维护这种关联。如果不需要按标签统计之类的复杂操作,多对多就够了,不用自己手动建中间表。
第三,为什么isPublic这个字段很重要?因为这个项目有分享页的需求。分享页访问者未必是注册用户,snippet 可以公开给所有人看,但只有作者本人能编辑或删除。也就是说,鉴权粒度不仅到“用户是否登录”,还要到“这条 snippet 是否属于当前用户”。没有isPublic的话,分享功能就无从谈起。
写完 schema 之后,跑一次迁移:
pnpm prisma migrate dev --name init pnpm prisma generate第一条命令会生成 SQL migration 文件并同步开发数据库,第二条命令会给 Prisma Client 生成最新类型。每次改完 schema 这两条都要跑,字面意义上缺一不可——只改 schema 不跑 generate,编辑器里所有关联查询的类型提示都是旧的,写代码和编译时会反复踩坑。
3. 核心链路打通:tRPC 接口、Zod 校验与 Prisma 持久化
3.1 端到端类型安全的调用链路到底意味着什么
如果你以前写过传统前后端分离,大概熟悉这个流程:前端定义接口函数,后端写接口文档,两边各自维护类型定义或 DTO。一旦字段改名或者返回结构变更,前端经常拿到过时文档,联调时才能发现问题——这通常已经是开发后期了。
tRPC 解决这个问题的思路很有意思:它不建独立 API 层,而是直接让前端“调用”后端的一个函数。前端不是发 HTTP 请求到某个 URL,而是调一个跨进程的方法,tRPC 在底层帮你把序列化、传输、反序列化全部包办。因为前端调用的名称和后端函数名是同一个符号,TypeScript 能直接推导出参数和返回值类型。
我实际在 t3code 里写了一个snippetRouter,核心代码大概是这样的:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ tag: z.string().optional(), search: z.string().optional() })) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { authorId: ctx.session.user.id, ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), ...(input.search ? { title: { contains: input.search, mode: "insensitive" } } : {}), }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), code: z.string().min(1), language: z.string().default("plaintext"), description: z.string().optional(), isPublic: z.boolean().default(true), tags: z.array(z.string()).default([]), }), ) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { ...input, authorId: ctx.session.user.id, tags: { connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })), }, }, }); }), });在编辑器里,前端写api.snippet.create({ title, code })的时候,如果少了必填字段或者类型不对,会直接标红。这种能力的价值怎么强调都不为过:你省掉的不是几行类型定义,而是整个联调阶段。实际项目中,我从写完后端 router 到前端调用打通,几乎没有发生“字段对不上”的返工。
3.2 Zod 校验:前后端安全的共同边界
在 tRPC 里,zod 不是可选的装饰品,它是真实的数据防护层。每个input定义就是一个运行时校验器,非法请求会在进入业务逻辑前被拦截。
拿 create 接口来说,我做了三层限制:
title至少 1 个字符code至少 1 个字符,不能提交空片段tags必须是字符串数组,且默认空数组
曾经有一版,我没有给 title 加min(1),结果前端接口可以直接提交空标题的 snippet,列表页就出现了一堆空白条目。这类问题在传统方案里通常要后端多写一道 if 判断,而在 tRPC 架构下只需要在 schema 里加一行约束。
这里想多提醒一句:除了参数校验,还要注意数据属主校验。tRPC 提供protectedProcedure和publicProcedure两种过程类型。Protected 意味着未登录用户请求一律 401,它在内部保证ctx.session存在。你写代码时可以放心地取ctx.session.user.id,这大大减少了空指针和“未登录访问”这类低级错误。
但 protected 不能替代业务层的属主校验。比如更新和删除 snippet,不能因为用户已登录就允许操作,必须判断这条 snippet 的authorId是否等于当前用户:
update: protectedProcedure .input(z.object({ id: z.string(), title: z.string().optional(), code: z.string().optional() })) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.snippet.findFirst({ where: { id: input.id, authorId: ctx.session.user.id }, }); if (!existing) throw new Error("Snippet not found or not owned by user"); return ctx.db.snippet.update({ where: { id: input.id }, data: input }); }),这里核心思想是:先用 where 把 id 和 authorId 绑在一起查询,查不到就报错,而不是查出来再在内存里判断。前者直接挡在数据库层,后者则慢一步而且容易漏。
3.3 数据填充与开发体验:种子数据的价值
开发阶段最痛苦的事情之一就是每次清库重新登录、重新手工录入数据。我写了一个 seed 脚本,用 Prisma 官方支持的方式填充演示数据:
import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const user = await prisma.user.upsert({ where: { email: "demo@example.com" }, update: {}, create: { name: "Demo User", email: "demo@example.com" }, }); const tags = ["react", "nextjs", "typescript", "algorithm"]; for (const tagName of tags) { await prisma.tag.upsert({ where: { name: tagName }, update: {}, create: { name: tagName }, }); } await prisma.snippet.createMany({ data: [ { title: "useDebounce hook", code: "export function useDebounce<T>(value: T, delay = 300) { ... }", language: "typescript", authorId: user.id, isPublic: true }, { title: "Next.js middleware example", code: "export function middleware(request: NextRequest) { ... }", language: "typescript", authorId: user.id, isPublic: true }, ], }); } main();这个脚本后续演进成了 t3code 最有用的工具之一。因为有了种子数据之后,前端页面的空态、列表态、编辑态都能快速看到,不用边开发边手工造数。
4. 前端体验落地:Tailwind 布局与代码展示交互
4.1 页面骨架与响应式设计思路
T3 项目默认用 Tailwind 做样式,这对我来说最顺手的一点是:不再需要来回切换 CSS 文件和组件文件,所有样式都在 JSX 里以类名的形式内聚。t3code 的页面结构并不复杂,主要是三个视图:dashboard 列表页、编辑器页、分享页。
列表页最核心的布局就是一个两栏结构,左边是标签筛选栏,右边是 snippet 卡片流。卡片上我要展示标题、描述、语言标签、创建时间,还要一个删除或编辑入口。Tailwind 的自由度在于任何尺寸、颜色、间距都可以直接用类名控制,比如卡片用了rounded-xl border border-slate-200 p-4 shadow-sm hover:shadow-md的组合,既保持清爽,又让交互动效有反馈感。
响应式这里有一个非常容易被忽略的细节:移动端下的代码块展示。代码内容往往很长,小屏幕下如果做自动换行,缩进和格式会被破坏;不做换行,则必须支持横向滚动。我最终的选择是overflow-x-auto+ 等宽字体,配合text-sm,让横向滚动只在真正需要时出现。桌面端则用max-w-4xl限制宽度,避免代码行过长导致阅读疲劳。
4.2 代码高亮与复制交互的坑
代码高亮我最终选了 Shiki,接入方式是用它在前端做语法高亮。为什么挑 Shiki 而不是 Prism?主要原因有两个:
- Shiki 的着色引擎基于 TextMate 语法,和 VS Code 同源,各种语言的表现力和编辑器中基本一致;
- 输出是纯 HTML 字符串,不像 Prism 那样依赖运行时操作 DOM 去追加 token。
接入代码大致这样:
import { codeToHtml } from "shiki"; async function highlight(code: string, lang: string) { return codeToHtml(code, { lang, theme: "github-dark" }); }然后我在组件里用useEffect把高亮后的 HTML 喂给dangerouslySetInnerHTML。这里我要提醒一句:接受用户输入的代码片段再做高亮和渲染,是有 XSS 风险的。我的规避方案是两层:数据库写入前后都要做基础清洗,展示时把代码内容包成文本节点再交给 Shiki 处理,而不是直接拼接用户字符串到 HTML。如果你完全不想碰这个风险,也可以用react-syntax-highlighter,它内部的转义处理更省心,代价是体积大一些。
复制到剪贴板也做了一个不算难但经常被低估的交互:点击卡片上的复制按钮,调用navigator.clipboard.writeText,同时把按钮文案短暂变成 “Copied”。这里有个兼容性问题,非 HTTPS 环境下 clipboard API 不可用,开发环境里要用localhost访问,线上则必须走 HTTPS。我用了一个简单降级:捕获异常时退回document.execCommand("copy")。
4.3 深色模式切换:为什么我推荐 class 策略
代码管理工具的用户群体基本是程序员,深色模式属于刚需,所以 t3code 从第一天就支持了明暗主题。Tailwind 默认的深色模式是media策略,跟随系统偏好;但如果用户希望在应用内手动覆盖系统设置,必须改用class策略。
做法是在tailwind.config.ts里设置:
export default { darkMode: "class", content: ["./src/**/*.{ts,tsx}"], };然后写一个主题切换 hook,把主题值存到localStorage,并在根布局上动态增删dark类。核心逻辑并不复杂,踩坑的点在于:服务端渲染的首屏样式会有闪烁。因为 localStorage 是客户端的东西,SSR 阶段无法感知用户偏好,最稳妥的做法是在根组件挂载前先读一次 localStorage(或者默认读取系统偏好),带suppressHydrationWarning的html标签上提前设置dark类。这块我后来抽成了一个useThemehook,内部同时监听系统prefers-color-scheme变化和手动切换事件。
5. 部署前必须处理的认证、配置与性能细节
5.1 NextAuth 的两种登录方式配置
t3code 的登录用的是 NextAuth,我做了两套方式:GitHub OAuth 和邮箱密码登录。GitHub OAuth 配置最直接,在 GitHub 开发者设置里新建一个 OAuth App,回调地址填http://localhost:3000/api/auth/callback/github(生产环境换成你的真实域名),然后把 Client ID 和 Secret 放进环境变量。
Credentials 方式稍微绕一点,因为它本身不内置数据库账号表,需要自己写 authorize 逻辑来查询数据库并校验密码。我的实现:
import type { NextAuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import bcrypt from "bcryptjs"; import { db } from "~/server/db"; export const authOptions: NextAuthOptions = { session: { strategy: "database" }, providers: [ CredentialsProvider({ name: "email", credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" } }, async authorize(credentials) { const user = await db.user.findUnique({ where: { email: credentials?.email } }); if (!user?.passwordHash) return null; const ok = await bcrypt.compare(credentials!.password, user.passwordHash); return ok ? { id: user.id, email: user.email, name: user.name } : null; }, }), ], callbacks: { session({ session, user }) { if (session.user) session.user.id = user.id; return session; }, }, };这里有一个比较隐蔽的问题值得展开:credentials 登录时 session 回调里的参数结构和其他 provider 不一样。GitHub OAuth 时,session 回调拿到的是token;而数据库 session 策略下,session 回调拿到的是user。如果不注意这个差异,session 里可能拿不到 user.id,后面protectedProcedure里取ctx.session.user.id就会直接报错。
5.2 环境变量管理和生产部署注意点
因为这个项目依赖 GitHub OAuth、数据库连接串和 NextAuth 密钥,环境变量管理必须清晰。我的.env文件结构是:
DATABASE_URL="postgresql://user:password@localhost:5432/t3code" GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET="" NEXTAUTH_SECRET="" NEXTAUTH_URL="http://localhost:3000"部署到 Vercel 的时候,需要在 dashboard 里把同样一组变量重新填一遍。千万注意 NEXTAUTH_URL 不要填错——如果用 preview 域名调试、生产域名上线,两套都要配独立的环境变量。我一次上线的时候就是因为 preview 环境里的回调地址和生产域名不一致,GitHub 登录一直报 redirect_uri 不匹配,排查了半小时才反应过来。
数据库我用的 PostgreSQL,直接接了 Neon 的免费实例,连接串是postgresql://...的格式。对部署做迁移时,必须先在本地prisma migrate deploy把迁移推到生产库,再开始部署应用代码。顺序反了的话,应用启动时 Prisma Client 会因找不到表而报错。
5.3 性能优化:Next.js 的加载策略选择
t3code 是一个以代码展示为主的页面,首屏性能和代码高亮的渲染开销是主要矛盾。我的解决组合拳:
- 列表页用
getServerSideProps预取数据,SSR 直接把 HTML 发到客户端,减少白屏时间; - 代码高亮改成异步:只有当代码块进入视口附近才触发
codeToHtml,首屏只渲染未高亮的纯文本; - 列表分页用
useInfiniteQuery,滚动加载,避免一次性拉太多 snippet 导致页面卡顿。
这里补充一个容易忽视的性能点:tRPC 查询默认是不会自动缓存失效的。React Query 底层接管了缓存,但如果你在新增一条 snippet 后没有做invalidate,列表页会一直显示旧数据。我在新增和删除的 mutation 成功回调里都会调用:
await utils.snippet.list.invalidate();这个操作让所有与snippet.list相关的缓存自动失效并重新拉取,既保证了数据一致性,也避免手动刷新页面带来闪屏。
6. 从 t3code 建站全流程里总结的避坑清单
6.1 tRPC 类型推导失效的两种常见场景
tRPC 最吸引人的特性是类型安全,但它对代码组织方式非常敏感。我实际排查过两个“类型推导突然失效”的问题:
第一种是在 router 内直接引用了未初始化完成的 ctx 字段,比如在 auth 流程中给 session 加自定义字段,但src/server/auth.ts的类型定义没有同步更新。解决方式是在src/server/auth.ts的session回调里给session.user.id做类型声明,同时保证authOptions被createTRPCContext复用时类型一致。
第二种是在服务端组件里调用 tRPC 接口。App Router 的 Server Component 环境没有 React Query 上下文,tRPC 客户端无法直接使用。我的规避方式:所有数据逻辑都放在 Client Component 中,通过useQuery调用 tRPC。服务端组件只做静态壳子,不碰动态数据。这个设计虽然牺牲了一部分 SSR 直出能力,但换来的是开发链路极其顺畅。
6.2 Prisma 类型生成的时序坑
Prisma 的generate每次都会重写node_modules/.prisma/client,对应的类型声明文件也会更新。如果你在某个新版本 Prisma 升级后忘记跑generate,编辑器不会报错,但编译时会有一堆“模块不存在”的怪错误。
这类错误还有个更隐蔽的表现:改完 schema 后创建了很多字段,但写查询时编辑器不提示新字段。这时候不要瞎猜逻辑,八成是 generate 过期了。我现在的习惯是每次改完 schema 立即跑prisma migrate dev,而不是攒到统一迁移。
6.3 我最想提前告诉自己的三个小细节
回头看整个 t3code 的过程,有三个细节如果当初提前知道,会省很多弯路:
细节一:密码 hash 存储不要偷懒。最开始我甚至想过直接存纯文本密码,后来被自己劝住,用了 bcryptjs。不要在 dev 环境图省事,因为迟早你会忘记清理数据库里的测试数据,一旦库被泄露,影响是全面的。
细节二:tag 名称要唯一,不然 connectOrCreate 会出问题。Prisma 的connectOrCreate需要匹配唯一字段,所以我给 Tag 的 name 加了@unique约束。没有唯一约束时,这个名字不存在的场景下会创建新记录,但如果并发请求,就可能插入重复的 tag。
细节三:isPublic 与编辑权限分离设计。一开始我犯过一个错:snippet 设为私有后,作者本人的列表页也看不到了,因为list查询只查isPublic: true。后来我把查询改为“要么是公开的,要么是当前用户自己的”,代码变成:
where: { OR: [ { isPublic: true }, { authorId: ctx.session.user.id }, ], }核心原则是:公开性控制的是“访客能看什么”,而不是“作者能看什么”。
最后说说我对这个项目的整体感受。t3code 不是一个功能多复杂的应用,但它完整走过了“从一个模糊想法到产品上线”的全过程,每一步都让我更理解 T3 Stack 各成员之间的关系。尤其是 tRPC 出现后,传统的后端开发心智模型需要更新——你不再写一堆需要文档化的接口,而是写一组类型安全的过程让前端直接调用。下一次如果再做一个类似的片段管理应用,我会把标签体系改成树形结构,并加入全文检索;但这次实践下来,我已经确认这套技术栈在开发效率上的优势是真实存在的,不是社区吹出来的。希望这篇复盘能对你的下一个项目有所启发。