t3code这个名字,最早只是我本地一个文件夹的名字,后来被我拿来当了一个全栈项目的代号,也成了这套代码仓库的统称。t3code做的事很简单:让开发者把自己常用的代码片段存进一个 Web 应用里,附带标题、标签、语言、公开或私有属性,并能生成一个可以分享出去的详情页。功能不复杂,但它把 T3 Stack 的核心链路完整过了两遍——Next.js 负责界面和路由,TypeScript 保证类型,Prisma 管数据库,tRPC 接管接口层,Tailwind CSS 处理样式。如果你正准备上手这套组合,或者已经在这条路上踩坑,这篇记录应该对你有用;如果你只是好奇一个全栈项目从搭建到上线会遇到哪些真实问题,也可以往下看。
1. t3code是什么:从代号到项目定位,我为什么认定这套技术组合
1.1 一个刻意不做大的代码片段仓库
t3code 不是技术社区产品,也不是又一个代码托管平台。我当时把功能范围压缩在三条主流程上:
- 用户创建片段:写标题、贴代码、选语言、打标签、设置可见性;
- 列表与详情:按标签或可见性筛选片段,详情页展示渲染后的代码;
- 权限边界:公开片段任何人都能看,私有片段只有作者自己能看到。
这个范围看起来不大,但已经覆盖了关系型数据建模、接口设计、权限过滤、页面渲染和部署上线等完整环节。我刻意没把 t3code 做成一个复杂的商业产品,因为复杂业务会把技术栈本身的观察稀释掉。做一个刚好能跑通全流程的小项目,反而能逼着我把每一层都理清楚。事实证明这个判断是对的:t3code 的开发时长只有三周,但我在里面解决的问题,比去年做一个两个月的大项目还要多。
选择"代码片段"这个主题还有个私心:它天然适合展示字符串、标签、用户关系、全文检索和语法高亮,几乎每张表都能用到真实业务里常见的字段类型和关系。如果你想复现一个类似项目,不用非得做代码仓库,做一个笔记应用、收藏夹或者博客后台,数据模型和链路也差不多。
1.2 T3 Stack的“类型安全优先”,到底优先在哪里
T3 Stack 的核心主张是:整个应用从数据库到页面,尽量让类型沿着同一条链路推导下去,让类型错误在编译期就暴露出来,而不是等到运行时才报错。具体到 t3code 里,这个优势体现在三个点上。
第一,Prisma 的模型定义本身就是类型来源。只要我在prisma/schema.prisma里改了表结构,重新 generate 之后,所有关联查询的入参和返回值都会被 TypeScript 编译器检查。比如我误把authorId拼成了authorID,编辑器会直接报红,而不是等到接口返回 undefined 之后再排查。
第二,tRPC 把服务端路由和前端调用连成了一条类型线。前端trpc.snippet.list.useQuery()拿到的数据,类型上等价于后端路由函数的返回值。我不需要为每个接口单独维护一份手工写的类型声明,也不需要在前端定义Snippet[]接口去和后端对齐,改了数据库模型之后,前端引用处会自动跟着变。
第三,Zod 输入校验和 tRPC 的 procedure 绑定后,调用参数类型会直接推导到前端。前端传错字段、少传参数、枚举值写错,编辑器立刻提示。这一点在团队协作里价值格外明显,因为真正的坑往往不是运行时崩溃,而是接口两边对不上。
1.3 与传统“后端模板 + 前端框架”的差别
以前做全栈项目,常规路径是后端用 Express 或 Django 写一套 REST API,前端用 React 或 Vue 通过 fetch 去拉数据。这个模式本身没有错,但它存在一条明显的裂缝:接口文档和类型定义是靠人肉保证同步的。
t3code 用 tRPC 之后,这层裂缝基本被抹平了。你可以把 tRPC 理解成一个不用写路由路径的远程函数调用:后端定义一个snippetRouter,前端直接调用同名方法,参数校验、错误类型、返回值类型全都在编译期对齐。上手时会有一点不习惯,因为你会下意识去找GET /api/snippet这种字符串,但适应之后,你会发现整个开发过程更像在“写一个函数名的调用”,而不是“拼接 HTTP 请求”。
Tailwind 在这里扮演的角色也很明确:它负责所有视觉层面,让我不需要为样式单独建一套组件再包装一层的维护成本。我甚至没在 t3code 里写一行全局 CSS 文件,所有布局、颜色、响应式都在 JSX 的 className 里完成。这套写法的取舍后面会细说,但至少在 t3code 这个体量下,它是效率最高的选择。
2. 脚手架与目录:create-t3-app的每个交互选项,我为什么这样选
2.1 初始化命令与交互项对照
创建项目的命令很简单:
npx create-t3-app@latest t3code这个命令会拉起交互式问答,每项看起来都是"要不要装 XXX",但不同选项会影响项目骨架的走向。我把 t3code 的选择和理由整理成一张表,方便你对照自己项目去决定:
| 交互项 | t3code的选择 | 理由 |
|---|---|---|
| Next.js 路由模式 | App Router | 新项目默认方向,RSC 潜力大,后续可平滑升级 |
| TypeScript | 是 | T3 Stack 的第一性原则就是类型安全 |
| Tailwind CSS | 是 | 样式方案统一,避免样式文件分散 |
| tRPC | 是 | 这是链路的核心,必须启用 |
| Prisma | 是 | 要管理用户、片段、标签的关系型数据 |
| NextAuth.js | 否 | 这个项目想保留自己写鉴权逻辑的学习过程 |
| ESLint | 是 | 代码统一规范,尤其是团队协作 |
| Prettier | 是 | 配合 ESLint,少在格式问题上争论 |
你可能注意到,t3code 唯独没有选 NextAuth.js。不是因为它不好,而是因为 t3code 是练手项目,我希望能亲手写一遍会话管理和权限判断。如果你做的是一个需要多平台登录、账号合并、邮箱验证的正式产品,直接选 NextAuth 会省下非常多时间。反过来说,如果你今天刚开始体验这套技术栈,去掉 NextAuth 可以降低理解负担,先把链路看明白再说。
2.2 样板代码清理后的目录结构
create-t3-app 初始化出来的目录会附带一些示例代码,第一步我建议把它们清理干净,因为示例代码往往会把文档地址、示例路由和一堆演示文案留在页面里,很容易干扰阅读。t3code 最终保留的核心目录大致是这样:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── snippets/ │ │ ├── [id]/page.tsx │ │ └── new/page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ └── routers/ │ │ │ ├── snippet.ts │ │ │ └── user.ts │ │ └── db.ts │ └── trpc/ │ ├── server.ts │ └── react.ts ├── .env └── tailwind.config.ts这份结构最大的优势是职责单一:prisma/放模型和迁移,src/server/只写服务端逻辑,src/trpc/负责 tRPC 的客户端与服务端桥接,src/app/纯粹做页面层。你在新项目里不用照搬这个结构,但要守住一个原则——数据库访问只出现在server相关目录里,不要在组件里直接拿 Prisma Client 又导给纯前端组件,否则类型安全很容易在边界处被打穿。
2.3 同时被前后端引用的类型共享
T3 Stack 下,类型并不需要被一个专门的shared/types.ts文件托管,真正共享的类型是从 tRPC Router 定义里推导出来的。t3code 的做法是:Prisma Client 生成User、Snippet、Tag等模型类型,tRPC 的 query/mutation 返回值又基于这些类型做组合,前端通过 tRPC hooks 拿到的数据天然已经具备了完整类型信息。
我举个非常实际的例子。我在 t3code 的片段列表接口里加了visibility过滤参数后,前端搜索结果列表的调用处立刻报错,因为调用时漏传了visibility。这种体验在传统 REST 开发里几乎不可能出现,你需要在 postman 里先试一遍接口,再去看前端请求参数,最后才能定位。类型共享不是银弹,但它能把大量低级错误前置到编辑器阶段,这也是 t3code 整个项目能保持快节奏推进的根本原因。
3. Prisma建模与数据迁移:用户、标签、片段这三张关系表怎么落地
3.1 一套能跑通需求的Schema设计
t3code 的数据核心是三张主表加一张关联表。下面这份 schema 是简化后的版本,但基本保留了我在项目里使用的字段和关系:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String? snippets Snippet[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Snippet { id String @id @default(cuid()) title String content String language String @default("text") visibility String @default("public") author User @relation(fields: [authorId], references: [id]) authorId String tags SnippetTag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId, visibility]) } model Tag { id String @id @default(cuid()) name String @unique snippets SnippetTag[] } model SnippetTag { snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) snippetId String tagId String @@id([snippetId, tagId]) }这里有两个点是我想重点解释的。第一,为什么visibility不做成 enum 而是普通 String?我在早期版本里确实用了 enum,但后来发现私有和公开这两个状态以后很可能扩展到“仅好友可见”,enum 型字段改起来需要跑迁移,普通 String 配上前端枚举值约束既灵活又不损失类型检查,权衡之后我选了后者。第二,SnippetTag是标准的多对多关联表,它让“查某个标签下的所有片段”和“删除片段时连带清除标签关系”都能用 SQL 层面高效完成。
3.2 建表迁移、Seed脚本与本地开发
初始化项目之后,模型不会自动建到数据库里,需要执行一次迁移。t3code 在开发阶段用的是 Prisma 的 db push,因为开发期的表结构变动频繁,直接用 push 会更快:
npx prisma db push npx prisma generate表结构稳定后,我才会改成migrate dev来生成迁移记录并提交到版本库,这样生产环境能按顺序回放迁移历史。t3code 还配了一个简单的 seed 文件,用来填充演示用户和几段示例代码片段:
npx tsx prisma/seed.tsseed 脚本里我用了connectOrCreate,它的含义是:如果标签已经存在就连接,不存在就顺便创建。这是处理标签这类“可复用枚举型数据”最舒服的 API,不用先查一遍再判断。
3.3 改模型的三个实际教训
第一,关系字段要尽早把onDelete行为想好。我第一次建SnippetTag时没写级联删除,删除 snippet 后关联表里残留了一堆孤儿记录,查询时还看不出问题,但统计标签数量时数据就不对了。后来补上onDelete: Cascade才彻底解决。
第二,不要把索引全堆在主键上。t3code 的 Snippet 表最常被查询的条件是authorId + visibility,所以我在 schema 里专门加了一个复合索引。不加索引的时候,列表页在数据量几百条时体验差不多,但数据到几千条以后延迟会明显上升,这种问题在上线后才排查会非常被动。
第三,模型字段改名要连同 tRPC 返回值一起看。Prisma 类型会在 generate 之后实时更新,但这不代表前端所有地方都自动修改完。如果你把 Snippet 的description字段改成summary,前端虽然会报错,但你依然要手动改 UI 文案和表单提交参数,这不是类型系统的责任。
4. tRPC路由与前端调用:端到端类型是怎么一条线连通t3code的
4.1 Router定义、输入校验与受保护路由
t3code 的接口层全部集中在src/server/api/routers/snippet.ts里。我以创建片段为例,展示一个完整 procedure 的写法:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const snippetRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ tag: z.string().optional(), visibility: z.enum(["public", "private"]).optional(), }) ) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { visibility: input.visibility ?? "public", ...(input.tag ? { tags: { some: { tag: { name: input.tag } } } } : {}), }, take: 50, orderBy: { createdAt: "desc" }, include: { author: { select: { name: true, email: true } }, tags: { include: { tag: true } }, }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string(), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, authorId: ctx.session.userId, tags: { create: input.tags?.map((tagName) => ({ tag: { connectOrCreate: { where: { name: tagName }, create: { name: tagName }, }, }, })), }, }, }); }), });publicProcedure和protectedProcedure的区别在于:受保护路由会在执行前检查 session 上下文,没有登录用户就直接抛错。这个动作不是靠前端隐藏按钮达成的,而是在 tRPC 层强制校验,避免有人绕过 UI 直接调用。
4.2 前端 useQuery 与缓存更新的实际写法
在 t3code 的前端组件里,调用接口的方式非常直接:
"use client"; import { trpc } from "~/trpc/react"; export function SnippetList() { const { data: snippets, isLoading } = trpc.snippet.list.useQuery({ visibility: "public", }); if (isLoading) return <p>加载中...</p>; return ( <ul> {snippets?.map((snippet) => ( <li key={snippet.id}> <h2>{snippet.title}</h2> <code>{snippet.language}</code> </li> ))} </ul> ); }这里useQuery的第一个参数就是前面 Zod 定义的输入结构,返回值则是后端 query 的返回类型。你在编辑器里把visibility改成"protected"时,TS 编译器会立刻提醒你枚举值不合法。这个提示链路的源头就是z.enum(["public", "private"])。
创建片段之后,不刷新页面也能更新列表。t3code 的做法是用 tRPC 自带的useUtils()让列表失效,常见写法是这样的:
const utils = trpc.useUtils(); await utils.snippet.list.invalidate(); await router.push("/");这比手动清理 cache 更符合直觉,因为 tRPC 知道当前页面上有哪些激活的查询,失效后会自动重新拉取。
4.3 App Router场景里,数据到底该在哪一层拿
t3code 用 App Router 之后,我一开始陷入了一个困惑:到底应该在 Server Component 里拿数据,还是在 Client Component 里通过 tRPC 拿?实际跑了一段时间后,我的结论是分情况处理。
对需要登录态、交互频繁的页面,比如创建片段、筛选列表,我直接用 Client Component 配合 tRPC hooks。这样代码最清晰,类型也最自然。对纯展示的公开阅读页,比如查看一条已公开片段的详情,我会直接在 Server Component 里用 Prisma 查一次,然后把数据作为 props 传给子组件。这样省掉了 tRPC 序列化和客户端水合的成本,也确实更贴近 App Router 的推荐用法。tRPC 在这里依然是主力接口层,但没必要把它当成唯一数据来源。
4.4 错误处理的常见遗漏
tRPC 的错误会在调用处统一抛出,但很多人只盯着error.message,忽略了错误形状。t3code 里做错误处理时,我会先看error.data.code,它是类似UNAUTHORIZED、NOT_FOUND、BAD_REQUEST的机器可读错误码,前端可以根据 code 做分支,而不是用字符串匹配后端返回的 message。这样做的好处是:后端文案改动了,前端逻辑不受影响。
5. Tailwind样式与页面骨架:不写全局CSS的改造实践
5.1 自定义主题:颜色、字体与圆角
Tailwind 的中文教程很多,但落到 t3code 这个项目里,最关键的是先把tailwind.config.ts的主题定义清楚,避免每个页面都靠#38bdf8这种散落的值来控制颜色。我在 t3code 里只做了三项自定义:
import type { Config } from "tailwindcss"; const config: Config = { darkMode: "class", theme: { extend: { colors: { brand: { DEFAULT: "#0ea5e9", dark: "#0369a1", }, editor: { bg: "#0f172a", text: "#e2e8f0", }, }, fontFamily: { mono: ["JetBrains Mono", "ui-monospace", "monospace"], }, borderRadius: { card: "0.75rem", }, }, }, plugins: [], }; export default config;定义之后,页面里只需要写bg-brand text-editor-text rounded-card font-mono,视觉风格就统一了。Tailwind 并没有要求你只能用它替身,但如果你连一个颜色值都要在两个组件里复制粘贴,那说明你的主题配置已经失控了。
5.2 暗色模式与代码高亮
暗色模式在 t3code 里是刚需,因为开发者看代码时普遍习惯深色背景。我在配置里开了darkMode: "class",然后通过一个小的 ThemeProvider 在 document 上切换darkclass。整体思路是:默认按系统偏好初始化,用户手动切换后存储到 localStorage,下次刷新优先读取存储值。
代码高亮部分,我选了 Prism 组件配合 rehype 插件,在服务端完成高亮渲染后再交给页面。这样客户端不需要再执行语法分析,首屏拿到的 HTML 就是带高亮 class 的代码块,减少了一次交互时间。这个方案唯一要注意的是样式冲突:Prism 自带的主题和 Tailwind 的 preflight 偶尔会打架,解决方式是只保留 token 层的高亮 class,布局和颜色全部由 Tailwind 覆盖。
5.3 页面骨架与响应式布局
t3code 的页面结构很朴素,但很能说明 Tailwind 的使用方式。顶部导航用flex布局,内容区用max-w-5xl mx-auto px-4做居中,列表在移动端用单列,桌面端用两列,代码块使用overflow-x-auto防止长行溢出。
如果你之后在这个项目里要做更复杂的组件库,我建议直接从 Tailwind 组件库中找灵感,比如 shadcn/ui,而不是自己先去封装一堆 Button、Input。t3code 因为想保持精简,所以没有引入组件库,但如果从零开始让我重新选一次,我很可能直接装 shadcn/ui,把精力留给业务逻辑。
6. 部署上线与排错:t3code在生产环境里遇到的三类真实问题
6.1 环境变量:本地能跑不等于线上能跑
t3code 本地开发时,所有环境变量都写在.env文件中,但生产环境的变量必须在部署平台上单独配置。最容易踩的坑是:本地.env.local里有DATABASE_URL,但部署平台只配置了NEXT_PUBLIC_*开头的变量,导致构建时 Prisma 找不到数据库连接直接失败。
我的检查清单是:
DATABASE_URL是否在生产环境配置;- 所有非公开密钥是否都没有以
NEXT_PUBLIC_开头; - 需要公开给浏览器读取的变量是否都显式加了
NEXT_PUBLIC_前缀; - 数据库连接是否允许来自部署服务器的 IP。
还有一个容易忽略的点:Prisma 在构建阶段会执行生成客户端和 migrate 校验,如果生产数据库尚未准备好,构建就会失败。t3code 的做法是先手动执行迁移,再触发部署。
6.2 Prisma引擎与云数据库兼容问题
t3code 使用 PostgreSQL,部署目标是无服务环境,于是遇到了 Prisma 引擎二进制和部署平台兼容性的问题。如果你也会遇到,可以看一下部署日志里有没有引擎相关报错,常见解决方向是设置binaryTargets,或者直接使用 Prisma 官方推荐的 Accelerate 方案,把查询引擎放到远端。
但对多数项目来说,最省心的路径是把本地环境的 Prisma 版本和应用锁死,同一个锁定版本在部署平台重新解析,很少会出现二进制不匹配。t3code 的经验是:不要在生产环境手里执行prisma generate之外的动作,尽量把迁移操作放到 CI 或者本地手动控制。
6.3 构建、迁移与回滚的部署顺序
t3code 的部署顺序最终固定成了四步:
- 本地检查代码变更,提交并推送;
- 在部署平台设置好所需的全部环境变量;
- 手动执行数据库迁移或
prisma db push; - 触发生产构建,观察日志直到成功。
顺序上最需要注意的是“先迁移后构建”。如果先构建实例再去迁库,新代码查不到新字段,数据库迁移又依赖旧代码的兼容逻辑,就很容易互相等。把迁移操作放在构建之前,能让新代码跑起来时面对的都是完整的目标 schema。
上线后最好观察至少十分钟日志,重点看 Prisma 查询有没有性能异常、tRPC 有没有频繁报错,以及页面热更新和静态缓存是否正常。t3code 上线后遇到过一次静态页面缓存问题,原因是首版页面没有被重新验证,后来在 Next.js 配置里增加了针对片段详情页的revalidate时间才解决。
7. 复盘与后续:我在 t3code 上最想保留和最想改掉的事
7.1 会继续沿用的三个约定
第一,所有数据库查询尽量通过服务端去执行,服务端逻辑小众化在src/server/下,前端只依赖 tRPC 的调用返回。这保证了 t3code 里没有“一个组件直接 import Prisma Client”的脏代码。
第二,输入参数一律用 Zod 先校验。即使某个接口暂时看起来不会有人传错值,我也会写一个足够严谨的 input schema,因为这个习惯在多人协作时能避免非常多的传参事故。
第三,每次迁移数据库前先备份生产库,哪怕是小表。t3code 的体量不大,数据库只有几万条记录,但一次失败的迁移足以打乱你整天的节奏。养成备份习惯之后,你会发现自己敢于做更大胆的表结构调整。
7.2 如果重做一次,我会砍掉的组件
现在回头看,t3code 里最让我犹豫的组件是“标签管理后台”。那段代码本身没有 bug,但它引出了一整套 CRUD 页面,包括编辑、去重、合并,超出了“代码片段仓库”这个主题的核心范围。如果重来,我会把标签完全放到创建片段时动态生成,不给它单独的后台,这样数据模型会更轻,时间可以花在搜索和分类体验上。
另一个想调整的是用户系统。t3code 没有接 NextAuth,但自己写 session 也让项目多出了不少维护量。真实产品里我大概会直接选择成熟方案,把登录、会话过期、密码重置这些问题交给社区和底层框架去打理。
最后说一条比较具体的建议:如果你也想做一个类似的代码片段仓库,记得把多对多关系的标签用单独一张关联表存,不要图省事存成逗号分隔字符串,否则等你开始做筛选和统计时,会为自己的偷懒付出更多时间。做完 t3code 之后,我最大的体会不是学会了某个框架的 API,而是理解了“类型在整条链路中被信任”这件事给自己带来的安心感——它让重构变得没那么可怕,也让团队里新成员更快上手。希望这篇记录能帮你少走几步弯路,早点把时间花在真正有意思的业务逻辑上。