如果你关注全栈开发,最近大概率被 T3 Stack 这个词刷过屏。我用它做了个小项目 t3code,简单来说,这是一个面向开发者的代码片段管理与分享平台:你可以把平时调试通过的代码块存起来,按语言、标签分类,还能一键分享给别人。项目做下来最大的感受是:以前在类型安全上反复纠结的时间,现在可以完全花在产品迭代上。
T3 Stack 这套技术栈的内容相当紧凑:Next.js 负责页面与接口,tRPC 解决客户端和服务器之间的类型联动,Prisma 处理数据库访问,Tailwind 管样式,NextAuth 负责登录。整个链路都是 TypeScript,接口签名就是类型定义,几乎不需要单独维护一份 API 文档。t3code 最初只是我用来验证这个说法的试验田,结果做着做着就停不下来了。
这篇内容适合下面几类人:刚接触 T3 Stack、想找一个完整全栈项目参考的同学;对 tRPC 的工作机制好奇、想知道它到底怎么省事的开发者;以及正在折腾代码片段管理工具、想做点能落地的项目而不是练手 demo 的朋友。我会把项目的设计思路、关键接线、部署过程和踩坑实录都摊开来讲,尽量让每一个结论都有实际操作作为支撑。
1. 项目概述与整体设计思路
1.1 t3code 要解决什么问题
先说痛点。做开发的这些年,我试过很多种记录代码片段的方式:打开 IDE 就拿本地 Snippet 插件存一段;浏览器书签里扔着一堆 Stack Overflow 链接;偶尔用文件传输工具给自己发一段关键代码。结果就是东西散,而且没有统一的标签体系,时间一长想找某段代码要翻好几个工具。更别提分享的时候,复制粘贴到聊天窗口,格式全乱了。
t3code 就是为了解决这些零散问题做了个集中管理器。它的核心功能听起来朴素,但实际用途不小:
- 通过 GitHub OAuth 登录,用户体系从一开始就建立了
- 创建、编辑、删除代码片段,支持标题、正文、编程语言、标签四个维度
- 片段可以设为公开或私有,公开链接可以分享给同事或社区
- 列表页支持按语言过滤和关键词搜索
- 公开片段拥有独立的详情页,方便外链和存档
我在设计功能边界时做了个重要决定:不做在线运行、不做协作编辑。原因很简单,这两块功能会引入容器执行、WebSocket 协同等一大堆复杂度,完全偏离了我想验证 T3 Stack 的核心目标。作为一个全栈项目,t3code 想要证明的是:用一套 TypeScript 技术栈,能不能用尽可能少的代码覆盖一个真实产品的 MVP。结果是,从立项到跑通第一条公开分享链接,整个时间比我预想的短了一大截。
1.2 技术选型:T3 Stack 到底香在哪里
T3 Stack 的核心哲学是约定优于配置。它不像 Spring 那样给你一堆组件让你自己装配,而是直接告诉你:全栈 TypeScript 项目,用 Next.js 做壳,tRPC 做接口,Prisma 做数据库,Tailwind 做样式,NextAuth 做认证。这套组合不是什么框架发明家的灵光一现,它解决的是真实协作场景里最痛的类型断桥问题。
传统的前后端分离项目里,前端和后端各写各的接口文档,总会出现“前端等了三天后端说接口还没写”的荒谬局面;就算接口写好了,字段名改一下,前端可能得等编译到运行时才发现。tRPC 的思路是把后端路由直接作为类型导出,前端引用的api对象天生具备后端返回值的类型推导。也就是说,后端定义完一个 query,前端调用时就能看到入参和出参的完整补全,压根不存在“接口不一致”这种问题。
具体到 t3code,整个项目用一张表就能看清各组件的分工:
| 组件 | 职责 | 选择原因 |
|---|---|---|
| Next.js | 页面路由、服务端渲染 | React 生态事实标准,部署到 Vercel 格外省事 |
| tRPC | API 层 | 端到端类型安全,省掉接口文档和手工类型维护 |
| Prisma | 数据库 ORM | schema 就是数据模型声明,迁移工具成熟 |
| Tailwind | 页面样式 | 原子类开发快,配合响应式布局顺手 |
| NextAuth | 登录认证 | GitHub 等 OAuth Provider 集成简单 |
对比 REST 和 GraphQL,tRPC 在小型全栈项目里优势很明显。REST 需要你手动维护 OpenAPI 文档或者手写类型定义,GraphQL 则引入了 schema 编排、resolver 拆分和客户端缓存一堆概念。tRPC 只是把“函数调用”搬到了网络上,心智模型极其简单。当然它也不是万能的,如果要做面向公众的开放 API,REST 仍然是更通用的选择。这就是 t3code 选它但没有无脑吹它的原因。
2. 核心细节解析与实操要点
2.1 tRPC 路由:类型安全是如何贯穿前后端的
tRPC 的核心概念很好理解:router是一组路由的集合,procedure是具体接口。procedure 分两种,读数据用query,写数据用mutation;访问权限也有两档,publicProcedure对所有请求可见,protectedProcedure要求在登录状态下才能调用。
在 t3code 里,我把用户相关逻辑放在userRouter里,把代码片段相关逻辑放在snippetRouter里,最后在根 router 合并。这样做的原因是让每个 router 保持小而专,当 snippet 逻辑复杂起来时,不会和用户逻辑互相污染。下面是 snippetRouter 的典型形态:
// server/api/routers/snippet.ts import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const snippetRouter = createTRPCRouter({ getAllPublic: publicProcedure .input( z.object({ lang: z.string().optional(), keyword: z.string().optional(), }) ) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { visibility: "PUBLIC", ...(input.lang ? { language: input.lang } : {}), ...(input.keyword ? { title: { contains: input.keyword, mode: "insensitive" } } : {}), }, include: { author: true, tags: true }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(120), language: z.string().min(1), code: z.string().min(1), tags: z.array(z.string()).max(5), }) ) .mutation(async ({ ctx, input }) => { const authorId = ctx.session.user.id; const tagObjs = await Promise.all( input.tags.map((name) => ctx.db.tag.upsert({ where: { name }, create: { name }, update: {}, }) ) ); return ctx.db.snippet.create({ data: { title: input.title, language: input.language, code: input.code, visibility: "PUBLIC", author: { connect: { id: authorId } }, tags: { connect: tagObjs.map((t) => ({ id: t.id })) }, }, }); }), });注意几个细节。第一,protectedProcedure的底层是一个 tRPC middleware,执行时会检查ctx.session.user,没有登录直接抛 UNAUTHORIZED,不用每个接口重复写判断。第二,入参校验用了 zod,它和 tRPC 的 input 合并得非常自然,类型会从 zod schema 自动推导到前端。第三,创建片段的标签用了upsert而不是先查后建,防止多人同时创建同名标签时撞上唯一约束。
客户端调用同样简单,在 React 组件里只需要:
const { data, isLoading } = api.snippet.getAllPublic.useQuery({ lang: "typescript", });这里的api对象是从~/utils/api导出的 tRPC 代理,它知道这个 query 的入参和返回类型。后端改字段,前端立刻在编译阶段报错,这种联动让“前后端契约”变成了一件编译器帮忙盯着的事情。
2.2 Prisma 数据模型:用户、代码片段与标签的关系设计
数据库设计直接决定后面写接口的心情。t3code 的 schema 很简单,但有几个点放在这里说比较合适。
model User { id String @id @default(cuid()) name String? email String @unique image String? snippets Snippet[] } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] @relation("SnippetTags") } model Snippet { id String @id @default(cuid()) title String language String code String visibility Visibility @default(PRIVATE) author User @relation(fields: [authorId], references: [id]) authorId String tags Tag[] @relation("SnippetTags") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum Visibility { PUBLIC PRIVATE }为什么主键用cuid()而不是数据库自增?因为公开分享页会用到片段 ID,连续的整数主键意味着任何人都能按序遍历你的所有片段,隐私风险太大。cuid 是随机的,虽然比自增长一点,但完全值得。
Tag和Snippet是多对多关系,标签通过关联表SnippetTags连接。虽然最初完全可以用一个字符串数组存标签,但那样做按标签筛选就得在应用层遍历全部数据,效率不划算。数据库层面的多对多关系让“找某个标签下的所有片段”变成一次干净的关联查询。
visibility的默认值我特意设成了PRIVATE,而不是公开。用户创建的代码片段默认私有,只有手动改成公开才会出现在列表页。这个设计习惯是从真实事故里学来的:在项目早期,默认公开导致我用临时数据测试时,不小心把一个带有内网地址的片段暴露了出去,还好发现及时。
2.3 身份认证:NextAuth 与 tRPC 的权限联调
权限是任何产品的第一道防线,t3code 这里直接用 NextAuth 的 GitHub OAuth,没有自己造轮子。用户的完整流程是:点击登录按钮跳转到 GitHub 授权页,同意后回调到本站,NextAuth 在回调里创建 session,tRPC 的 context 再把这个 session 塞进上下文。这样后面每个 procedure 都能拿到当前登录用户,不需要每个接口各自判断登录状态。
关键的接线在server/api/trpc.ts:
import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; export const createTRPCContext = async (opts) => { const session = await getServerSession(authOptions); return { session, ...opts }; }; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: ctx.session } }); });注意,getServerSession只应该在服务端调用,不能在客户端组件里直接 import。tRPC 的 query 本质上运行在服务端,所以在 context 里读一次 session 就够了,不需要每个接口再各自调用一次。
本地开发时最容易踩的坑是 OAuth 回调地址写错。GitHub App 里设置的 callback 必须是http://localhost:3000/api/auth/callback/github;部署后要记得改成线上域名,而且环境变量AUTH_URL或平台自动注入的 URL 不能被随意覆盖。还有AUTH_SECRET一定要用足够随机的字符串,直接上生成器:
openssl rand -base64 32这个值一旦泄露,攻击者就可以伪造 session cookie。
2.4 代码高亮与搜索:引入 shiki 和 PostgreSQL 全文检索
代码片段的灵魂是代码高亮。主流的方案就两个:Prism 和 shiki。Prism 是纯客户端渲染,包体积小但语法高亮质量一般;shiki 使用 TextMate 语法,高亮效果和 VS Code 基本一致,代价是包体积大得多。
t3code 最后选了 shiki,因为分享页是对外展示的门面,代码块好不好看直接影响别人愿不愿意点开。为了控制体积,我在分享页直接用服务端渲染,通过 shiki 把代码转成带语法高亮的 HTML 字符串再输出,客户端拿到的是纯 HTML,不加载 shiki 本身。在编辑器的实时预览里,则用动态 import 按需加载语法,避免把整个 shiki 塞进首屏 bundle。
搜索部分没有引入 Elasticsearch 之类的重型组件,因为代码片段这种量级用不到。公开列表页的两个搜索需求,一个是对标题和标签做模糊匹配,用 Prisma 的contains加mode: "insensitive"就能满足;另一个是对代码内容做关键词检索,考虑到默认 PostgreSQL 自带全文索引,我用了一段$queryRaw来跑tsvector。这个方案对英文代码和标识符很有效,但对中文注释的切分不够好,所以中文场景还是走标题和标签的模糊查询。现实项目里,满足业务优先级比追求技术完美更重要。
3. 实操过程与核心环节实现
3.1 Create T3 App 初始化与目录结构解析
初始化命令一条:
npm create t3-app@latest t3code交互式命令行里会让你选需要哪些模块。我选了 Next.js(App Router)、Tailwind、tRPC、Prisma、NextAuth,这几个是 T3 Stack 的标准套餐。生成完毕后的目录结构是下面这个样子,我当时看到的第一反应是“该有的都有了,不该有的一样没多”:
src/ ├── app/ │ ├── layout.tsx │ ├── page.tsx │ ├── providers.tsx │ └── snippets/ │ └── [id]/ │ └── page.tsx ├── server/ │ ├── api/ │ │ ├── root.ts │ │ ├── trpc.ts │ │ └── routers/ │ │ ├── user.ts │ │ └── snippet.ts ├── utils/ │ ├── api.ts │ └── db.ts └── styles/ └── globals.cssserver/api/trpc.ts里已经初始化好了 tRPC 实例和 context;server/api/root.ts是你注册所有 router 的入口;utils/api.ts是前端创建 tRPC React hooks 代理的地方。模板除了生成这些基础文件,还贴心地配好了环境变量示例。
选 App Router 还是 Pages Router 这件事,我的建议是直接用 App Router。T3 模板对 App Router 的支持已经在逐渐成熟,虽然 tRPC 和 App Router 的 Server Component 集成还有一点绕,但团队的新项目不值得再用旧路由模式起步。
3.2 编写 snippetRouter:从数据库到接口的完整链路
前面已经展示了 snippetRouter 的核心代码,这里补充一条完整的链路:从页面上点击“创建”,到数据库写入,中间到底发生了什么。
前端提交表单时,调用的是:
const createSnippet = api.snippet.create.useMutation(); createSnippet.mutate({ title: "读取环境变量并打印", language: "typescript", code: "console.log(process.env.NODE_ENV)", tags: ["env", "debug"], });tRPC 把这次 mutation 打包到/api/trpc/snippet.create,服务端protectedProcedure检查 session 通过后,zod 先验证入参,然后进入 mutation 逻辑。这里ctx.db是模板注入的 PrismaClient,接口里的author通过connect: { id: authorId }关联到当前用户,tags则是把解析好的标签对象全部connect到片段关联表。
如果中途哪一步出错,tRPC 会按错误码返回给前端。用 zod 校验失败返回的是BAD_USER_INPUT,没登录返回的是UNAUTHORIZED。前端可以用 React Query 的error状态统一处理,不需要写一堆 try/catch。
有一点必须提醒:不要在 mutation 里直接信任前端传来的authorId,应该从 session 拿。我见过不少项目为了让前端少传一个参数,直接把userId放在客户端请求体里,这是典型的越权漏洞。
3.3 前端页面:代码列表、编辑器和分享页
列表页用服务端渲染并不复杂,但 t3code 更典型的使用场景是客户端交互:用户在搜索框里输入关键词,可能希望实时过滤,所以我用 tRPC 的useQuery加enabled参数控制请求时机。页面组件看起来像这样:
"use client"; import { api } from "~/utils/api"; export default function SnippetList() { const [keyword, setKeyword] = useState(""); const { data, isLoading } = api.snippet.getAllPublic.useQuery({ keyword: keyword || undefined, }); return ( <div className="mx-auto max-w-4xl p-6"> <input value={keyword} onChange={(e) => setKeyword(e.target.value)} className="w-full rounded border px-3 py-2" placeholder="搜索标题、标签…" /> {isLoading ? ( <p className="mt-4">加载中…</p> ) : ( data?.map((snippet) => ( <SnippetCard key={snippet.id} snippet={snippet} /> )) )} </div> ); }注意这里给组件加了"use client",因为在 App Router 下,客户端状态和 React Hooks 只允许出现在客户端组件里。t3code 的分享页则更倾向于服务端渲染,因为搜索引擎和社交分享需要能直接抓到页面标题和摘要。分享页的布局很简单,左侧是代码区域,右侧挂标签和作者信息,底部放一个“复制原文”按钮。
编辑器页是整站交互最重的部分,我用了<textarea>作为输入,再用 shiki 做右侧实时预览。刚开始做的时候踩了性能的坑,每一次按键如果都做高亮,页面就肉眼可见的卡。后来加了一层 300ms 的防抖,只在用户停止输入后再触发高亮,体感才恢复正常。
3.4 部署到 Vercel:数据库迁移与环境变量配置
部署到 Vercel 几乎是 T3 项目的默认归宿。不过有几个地方不提前处理,一定会在线上炸。
数据库我用的 Neon 的免费 Postgres 实例,它提供一个普通连接串和一个连接池连接串。Prisma 在 Serverless 环境下需要把连接池串填到DATABASE_URL,普通串填到DIRECT_URL。在.env里大概是:
DATABASE_URL="postgresql://user:password@ep-xxx.pooler.neon.tech/neondb?pgbouncer=true" DIRECT_URL="postgresql://user:password@ep-xxx.neon.tech/neondb"部署前必须先在本地执行一次迁移生成 SQL 文件,提交到仓库后,在 Vercel 的构建命令里加上prisma generate,并在构建环境或 Release 阶段执行prisma migrate deploy。直接在线上跑migrate dev是大忌,因为它会走交互式命令并可能要求重置数据。
环境变量就四件套:DATABASE_URL、DIRECT_URL、AUTH_SECRET、AUTH_GITHUB_ID和AUTH_GITHUB_SECRET。Vercel 后台填好之后,还要检查 GitHub OAuth App 的 Homepage URL 和 Callback URL 是否改成了线上域名,这两个 URL 少改一个,线上登录就会无限回调失败。
4. 常见问题与排查技巧实录
4.1 tRPC 类型不刷新,改完路由前端报错
这是 t3code 开发期间遇到最多的一个坑。场景非常典型:后端给snippetRouter新增了一个 query,或者在 input 里加了一个字段,结果前端代码里使用api对象时类型根本没变,IDE 里还飘着红色波浪线。
原因是 Next.js 开发服务器长期运行后,.next目录里缓存了旧的 TypeScript 类型快照。解决方式也很简单,重启 dev server 基本都能好,治本的做法是:
rm -rf .next npm run dev如果是升级了 create-t3-app 或 tRPC 版本,这种缓存问题更容易出现。另一个常见原因是路径别名配置不一致,比如~/server/api/trpc在某个文件里被写成了相对路径,类型推导就会产生错位。遇到类型不刷新时,先看报错路径,再按顺序排查缓存和别名,通常不需要怀疑自己写的代码有问题。
4.2 Prisma 在 Serverless 环境下的连接数问题
第一次部署到 Vercel 后,没过多久我就收到了 Prisma 的告警邮件,说是连接数飙升。原因很简单:Serverless 函数是即开即用的,每个冷启动实例都会试图创建新的数据库连接。虽然 create-t3-app 模板默认在db.ts里做了全局缓存,但如果你的 Next.js 项目开启了多个函数实例,连接还是会涨得很快。
解决方法是两条腿走路。应用侧,确保 PrismaClient 被全局单例持有,不要在每个请求里 new 一个。数据库侧,必须使用连接池,把DATABASE_URL指向 Neon 的 pooler 或 Supabase 的 transaction pooler。有一个细节很容易漏:执行prisma migrate deploy的时候,Prisma 需要直连数据库而不是走连接池,所以DIRECT_URL要指向非 pooler 的地址。这个配置不上,migration 会因为协议问题反复失败。
4.3 App Router 与 tRPC 集成时的 Provider 包装方案
新版 Next.js 的 App Router 让 tRPC 的接入多了一道工序。在 Pages Router 时代,你只需要在_app.tsx里包一层TRPCProvider;App Router 下,根布局layout.tsx本身是 Server Component,不能直接使用 React Context,所以必须做一个中间层:
"use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import { createTRPCReact } from "@trpc/react-query"; import { useState } from "react"; import type { AppRouter } from "~/server/api/root"; export const api = createTRPCReact<AppRouter>(); export function TRPCProvider({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()); const [trpcClient] = useState(() => api.createClient({ links: [httpBatchLink({ url: "/api/trpc" })], }) ); return ( <api.Provider client={trpcClient} queryClient={queryClient}> {children} </api.Provider> ); }然后在layout.tsx里引入TRPCProvider包裹页面。记住TRPCProvider必须带"use client",否则运行时会直接报 “No QueryClient set”。如果你的项目用到了 React Query 的缓存,还要注意queryClient不要在组件渲染过程中创建,最好用useState初始化,否则组件重渲染时缓存会被重置。
4.4 shiki 体积过大导致页面卡顿
t3code 首屏 Bundle 曾经一度飙到 500KB,排查的时候发现罪魁祸首就是 shiki。shiki 为了支持几十种语言,把一堆 TextMate 语法文件直接打包进了客户端 chunk。解决办法是让代码高亮完全远离客户端主 bundle。
具体做到两点。第一,分享页用 Server Component 或服务端 API,在服务端直接调用codeToHtml,输出高亮后的 HTML,浏览器只接收静态片段。第二,编辑器预览页的 shiki 通过next/dynamic动态加载,并按需注册语言。比如:
const getHighlighter = dynamic(() => import("~/lib/highlighter"), { ssr: false });这样的改动做完,首屏 bundle 降到了 200KB,编辑页不再出现输入卡顿。另外一个很实用的小技巧是,在高亮之外,不要忘记给代码块设置合理的白名单样式和最大高度,以免长代码把页面撑到失去阅读节奏。
4.5 分享链接权限与数据隐私
这个坑我差点没发现。某天朋友转给我一个链接,说是从 t3code 列表里看到一段“内网数据库地址”的片段,我一下冷汗就出来了。查完日志才发现,getAllPublic这个方法确实忘了加visibility: "PUBLIC"的过滤条件,所有私有片段都被当成公开数据显示了。
修复只是在查询条件里加上一行where: { visibility: "PUBLIC" },但这件事给我提了个醒:权限过滤不能只靠前端路由隐藏,后端接口必须作为最后一道防线。后来我给所有公开查询都补了测试,并且在列表页加了一个账号维度的展示逻辑,登录用户只能看到自己的私有片段,其他用户的私有片段在接口层就直接被过滤掉。
这里还要多说一句,代码片段这东西看着不像密码,但里面经常藏着内网 IP、密钥轮换前的旧 Token、甚至数据库账号。哪怕是私有片段,存储时也不要明文记录高敏感信息。t3code 的 schema 允许code字段存长文本,但这不意味着它适合当密钥保险柜。
最后说点实在话。t3code 这个项目做下来,我对 T3 Stack 最大的感受是:它确实把中间商砍掉了。前后端共享类型,接口改动的前一分钟编译器就会告诉你哪里坏了;Prisma 的 schema 基本就是数据库设计文档;NextAuth 把最难搞的 OAuth 回调流程封装成了一行配置。但你得接受一件事,这套技术栈的约定感很强,遇到问题的时候,社区资料虽然多,版本之间的坑也不少。我的建议是,想学 T3 Stack 先别盯着理论看,直接拿一个小项目从create-t3-app开始,完整跑通一条从登录到发布的链路,远胜过对着教程抄十遍。后面我大概率会把 t3code 加上在线运行和团队协作这两个模块,到时候再来分享一轮实战经验。