T3 Stack实战:构建代码片段管理工具的全栈实践

发布时间:2026/10/28 6:29:51
T3 Stack实战:构建代码片段管理工具的全栈实践

把内部工具项目从零做成一个能上线的东西,这件事听起来简单,真正做起来全是细节。最近我利用业余时间做了一个代号叫t3code的小项目——一个面向个人开发者的代码片段管理工具,本质是把自己散落在各处编辑器、聊天记录、本地 Markdown 里的代码片段统一收集起来,支持打标签、全文检索、分享链接。项目前端要响应式、后端要鉴权、数据要持久化,技术选型我直接锁定了 T3 Stack。这篇就完整复盘一下 t3code 从初始化到部署的实践过程:为什么选 T3 Stack、tRPC 的类型安全到底怎么落地、Prisma 数据建模有哪些坑、NextAuth 和 tRPC 如何联动,以及部署阶段的真实踩坑记录。如果你正准备用全栈 TypeScript 做一个中大型工具型项目,这篇应该有参考价值。

1. 从项目命名说起:t3code 到底在做什么

1.1 一个工具型项目的真实需求边界

很多人会把代码片段管理工具想复杂,实际上我复盘 t3code 的需求边界,核心只有四个动作:存、找、分享、管理权限。存是指支持 Markdown 和常见语言代码块,保存语言类型和标签;找是指能按关键词、语言、标签过滤;分享是指生成一个只读链接让别人查看;管理权限则是区分登录用户和匿名访客,只有本人能编辑或删除自己的片段。

初期我用过一个本地 JSON 文件方案,把所有代码块以 JSON 形式存下来,用 Node 脚本搜索。跑了一段时间发现两个痛点:一是跨设备访问麻烦,二是想给同事分享某个片段时就得把完整 JSON 发过去。这让我下定决心改成 Web 应用,而一旦到了"多人访问 + 数据持久化 + 身份认证"这个复杂度级别,前端 React、后端 API、数据库、鉴权中间件这些组件就缺一不可了。

1.2 为什么是 T3 Stack,而不是自研脚手架

T3 Stack 是 TypeScript 社区里一套非常受欢迎的全栈组合,官方定义的核心是三件套:TypeScript、Tailwind CSS、tRPC,同时把Next.js作为框架底座、Prisma作为 ORM、NextAuth.js作为认证方案。这套组合由 create-t3-app 一键生成,社区里也习惯把整个全家桶统称为 T3。

选它的第一理由是端到端类型安全。在传统前后端分离架构里,后端写一套 REST API,前端再写一套类型定义,然后花大量时间保证两边同步。接口字段名拼错一个字母、后端返回结构改了前端没更新,这类问题在常规项目里非常常见。T3 Stack 用 tRPC 让前端直接调用后端函数,类型定义从服务端自动派生到客户端,相当于把"对接"变成"敲代码",类型不匹配在编译期就暴露了。

第二理由是启动成本低。create-t3-app 生成的模板已经配置好了 TypeScript 严格模式、ESLint、Tailwind、Prisma 客户端生成、tRPC 路由骨架,开箱即用。对一个工具型项目来说,省下来的时间可以全部放在业务逻辑上。

第三理由是生态相对收敛。Next.js 管页面渲染,Prisma 管数据库,NextAuth 管认证,tRPC 管 API 调用,每个环节都是各自领域的头部方案,遇到问题搜索社区资料几乎都能找到答案。对于单人开发的项目来说这很重要,我不希望卡在一个小众工具的文档里。

1.3 三个"T"分别解决什么问题

这里补充一个容易混淆的点:T3 名字里的三个 T 指的是 TypeScript、Tailwind、tRPC,但 Next.js 和 Prisma 其实是整套方案的隐藏核心,没有它们项目根本跑不起来。

  • TypeScript:全栈类型统一,这是所有其他设计的前提。t3code 的数据库模型、API 入参、页面 props 全部共享同一套类型系统。
  • Tailwind CSS:样式层不加新语言,在 JSX 里直接写原子类,对工具型小项目很友好。t3code 的界面元素不多,Tailwind 完全能覆盖,又不至于像传统 CSS 那样维护一堆类名。
  • tRPC:替代 REST 的一层远程调用,让前端像调用本地函数一样调用后端逻辑。

实际开发中你会发现,T3 Stack 真正让人上瘾的不是某个单独的框架,而是"类型引用链"。从 Prisma schema 到 tRPC 入参再到 React 组件 props,类型一路贯穿,IDE 里到处能跳转、能自动补全,这对排查问题帮助非常大,后面我会展开讲。

2. 初始化 t3code:create-t3-app 的细节与目录拆解

2.1 交互式创建与方案取舍

创建项目只需要一行命令:

npx create-t3-app@latest t3code

交互过程会让你选择需要的模块。我的实际选择如下:

模块选择理由
TypeScript是默认强制
Tailwind CSS是原子类写起来快,适合工具型 UI
tRPC是T3 核心,替代手写 REST
Prisma是数据层必须
NextAuth是需要登录和权限控制
Docker否本机开发直接用本地的 PostgreSQL,不需要容器
包管理器pnpm磁盘占用小,依赖安装快

有一个细节值得注意:create-t3-app 在较新版本里会把 Prisma 的数据库连接默认指向 SQLite,但 t3code 部署到线上后我打算用 PostgreSQL,因为全文检索和并发性能更好。所以创建完项目后,我第一时间改了DATABASE_URL,并安装了pg驱动包。

如果你在交互界面看到Docker选项,不要以为选上它会自动生成一个完整的数据库容器。它只是会帮你多生成一份docker-compose.yml模板。我选择不选然后用本机 PostgreSQL,省一个容器资源。

2.2 生成后的目录结构拆解

create-t3-app 生成的目录结构非常规整,核心部分如下:

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── pages/ │ │ ├── api/ │ │ │ ├── auth/[...nextauth].ts │ │ │ └── trpc/[trpc].ts │ │ ├── index.tsx │ │ └── snippets/[id].tsx │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ │ │ │ ├── snippet.ts │ │ │ ├── tag.ts │ │ │ └── post.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── client.ts │ │ ├── react.tsx │ │ └── server.ts │ └── styles/ ├── .env └── package.json

重点说三个容易忽略的文件:

src/server/db.ts是 PrismaClient 单例。在 Next.js 开发模式下,热更新会反复加载模块,如果每个模块都直接new PrismaClient(),会堆积大量数据库连接,最终把数据库连接池打满。create-t3-app 模板用了一个全局单例模式:

import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

这段代码的含义是:开发环境下把 PrismaClient 实例挂到globalThis上,避免每次热更新重复创建连接,生产环境则直接创建新实例。我建议任何 Next.js + Prisma 项目都保留这个写法,否则本地跑几个小时就可能报Too many connections。

src/trpc/react.tsx是 tRPC 的前端 React 绑定,里面创建了api对象,之后在页面里调api.snippet.list.useQuery()就是走这里。src/trpc/server.ts则是服务端调用方,在 getServerSideProps 或 Server Components 里可以直接调用 tRPC 路由,不走网络请求,后面部署性能相关章节我会再说。

2.3 .env 环境变量先行

create-t3-app 默认生成.env,但与 Prisma、NextAuth 相关的变量需要手动填。t3code 的初始配置长这样:

DATABASE_URL="postgresql://postgres:password@localhost:5432/t3code" NEXTAUTH_SECRET="generate-a-strong-secret" NEXTAUTH_URL="http://localhost:3000" GITHUB_ID="your-github-oauth-app-id" GITHUB_SECRET="your-github-oauth-app-secret"

NEXTAUTH_SECRET用以下命令生成比较安全:

openssl rand -base64 32

这一步看似简单,实际是部署阶段最容易出事故的地方。很多人本地跑得好好的,一部署到 Vercel 就登录失败,八成就是忘了同步NEXTAUTH_SECRET或者环境变量名写错。后面第 6 章我会专门讲这个坑。

3. 端到端类型安全的核心:tRPC 路由与类型推导实战

3.1 Router 与 Procedure 的基本形态

tRPC 的核心概念就两个:**Router(路由器)**和Procedure(过程)。Router 是多个 Procedure 的集合,Procedure 就是一个个后端函数,分为query(查询)和mutation(写操作)两种。

t3code 里最核心的 snippet 路由大致长这样:

import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const snippetRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ search: z.string().optional(), language: z.string().optional(), tag: z.string().optional(), cursor: z.string().optional(), }) ) .query(async ({ ctx }) => { return await ctx.db.snippet.findMany({ where: { ...(ctx.session?.user ? { OR: [{ authorId: ctx.session.user.id }, { isPublic: true }] } : { isPublic: true }), }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string().default("text"), tags: z.array(z.string()).default([]), isPublic: z.boolean().default(false), }) ) .mutation(async ({ ctx, input }) => { return await ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, isPublic: input.isPublic, authorId: ctx.session.user.id, tags: { connectOrCreate: input.tags.map((tag) => ({ where: { name: tag }, create: { name: tag }, })), }, }, }); }), });

几个值得注意的设计细节:

第一,查询语句里对匿名访客和非本人做了可见性过滤。代码片段可能是私有的,也可能是公开分享的,所以list查询的where条件要根据ctx.session是否存在动态拼接。这个逻辑放在 tRPC 路由内部而不是放在前端过滤,是为了保证数据边界在服务端,前端永远拿不到无权访问的数据。

第二,入参校验全用zod。zod 的好处是类型可以反向推导,z.object({...})的 schema 本身就能生成 TypeScript 类型,input在任何地方使用都是完全类型安全的。你不需要手动写一个interface SnippetInput然后担心它与运行时校验不一致。

3.2 类型如何从服务端自动流向客户端

这一步是 t3code 最让我惊艳的地方。在src/trpc/react.tsx里,模板会导出这么一句:

export const api = createTRPCReact<AppRouter>();

AppRouter来自src/server/api/root.ts:

export const appRouter = createTRPCRouter({ snippet: snippetRouter, tag: tagRouter, }); export type AppRouter = typeof appRouter;

当一个类型定义通过typeof appRouter暴露给前端时,tRPC 会在编译期递归地推导出所有路由的入参类型和返回类型。前端调用:

const { data: snippets, refetch } = api.snippet.list.useQuery({ search: keyword, language: selectedLanguage, tag: selectedTag, });

useQuery的入参类型由服务端自动约束:字段名不能拼错,类型不对立刻报编译错误。返回的snippets类型也和 Prisma 查询结果一致,前端不需要额外的 interface。这就是"端到端类型安全"的实际体验——以前前后端联调要打开浏览器 Network 面板确认返回结构,现在写完路由,前端编辑器里直接就有完整的类型提示。

3.3 httpBatchLink 与请求合并

create-t3-app 默认给 tRPC 配了httpBatchLink,它会把同一时间窗口内的多个请求合并成一个 HTTP 请求。t3code 首页同时要调snippet.list、tag.list、session三个查询,如果没有批量链接,就会发三次请求;开启批量后,tRPC 会把这三个调用合并成一个请求体发给服务器,服务器再按数组逐个执行并返回结果数组。这样能明显减少网络往返。

但在实际使用中我要提醒一句:批量链接的合并只对同时发起的请求有效,如果某个页面在用户点击后才触发单独的 query,那仍然是一个独立请求,这没问题。真正的坑出现在上传大对象时。如果你在 mutation 里传一个很大的字符串(比如整个代码文件内容),建议单独创建一条不带 batch 的 httpLink,否则可能触达 HTTP 请求体大小上限。t3code 单条代码片段最多几十 KB,实际用下来没出过问题,但如果未来要支持完整文件快传,这是要注意的点。

说到这,给一个排查 tRPC 报错的经验:tRPC 的错误信息默认只在服务端日志里打出完整堆栈,前端看到的 error.message 往往只有一句Unexpected token u in JSON at position 0。遇到这种提示,先看服务端控制台,别在前端猜。我们团队一开始排查 500 错误浪费了不少时间。

4. 数据层的建模与实践:Prisma 在 t3code 中的落地

4.1 schema 设计:代码片段、标签与用户关系

t3code 的数据模型不算复杂,但有一个典型的多对多关系:一个代码片段可以有多个标签,一个标签可以属于多个代码片段。Prisma schema 里我定义如下:

model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? snippets Snippet[] accounts Account[] sessions Session[] } model Snippet { id String @id @default(cuid()) title String content String @db.Text language String @default("text") isPublic Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String tags SnippetTag[] } model Tag { id String @id @default(cuid()) name String @unique snippets SnippetTag[] } model SnippetTag { snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) snippetId String tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) tagId String @@id([snippetId, tagId]) }

我说一下几个字段选型背后的考量:

id用cuid()而不是自增数字,原因在公开分享场景下很明显——数字 ID 容易被人遍历猜出别人的资源,cuid 带上随机性,外部无法通过递增 ID 遍历全部片段。你可能说反正有权限控制,但少一个扫描入口总归更稳妥。

content用@db.Text,避免长代码被 PostgreSQL 的VARCHAR(255)截断。很多新手在 Prisma 里默认字符串就是VARCHAR,存长文本到一定长度就报错,这个问题在代码片段类项目里很致命。

SnippetTag是一张显式的关联表。Prisma 也支持隐式多对多关系写法,直接tags Tag[],隐式关系生成的表名和字段名不可自定义,而且不方便在关联表上追加额外字段。对于 t3code 这样一个标签体系可能会扩展的场景(比如未来加一个"标签颜色"),显式关联表更可控。

onDelete: Cascade的作用是删除用户时自动删除其所有片段。不加这个约束时,Prisma 会在删除有关联记录的数据时直接报外键约束错误,而不是自动帮你清理。我见过不少项目上线后才发现这个问题,直接在 schema 里定义清楚省很多事。

4.2 迁移与种子数据

定义好 schema 后,执行:

npx prisma migrate dev --name init

这个命令会生成 SQL 迁移文件并同步到数据库。注意migrate dev只在开发环境用,它会尝试重置数据,生产环境要用npx prisma migrate deploy。这个区别很重要,我在之前的项目里就因为在生产环境误跑了migrate dev,导致本地数据被清空过一次,做数据管理一定要区分开发命令和生产命令。

t3code 还加了一个prisma/seed.ts,用于往库里塞一些演示用的代码片段:

import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { await prisma.snippet.create({ data: { title: "防抖函数演示", content: "function debounce(fn, delay) { ... }", language: "typescript", isPublic: true, author: { connectOrCreate: { where: { email: "demo@t3code.dev" }, create: { email: "demo@t3code.dev", name: "demo" }, }, }, tags: { create: [{ tag: { connectOrCreate: { where: { name: "js" }, create: { name: "js" } } } }], }, }, }); } main().then(() => prisma.$disconnect());

package.json 里加一行:

"prisma": { "seed": "tsx prisma/seed.ts" }

然后npx prisma db seed就能把演示数据灌进去。对个人工具项目来说,种子数据最大的价值不是测试,而是让前端开发过程中有真实数据可看,不用一遍遍手动创建记录。

4.3 查询性能与文本检索的取舍

t3code 的查询有一个演进的细节值得记录:最开始用的content.contains(search)实现搜索,也就是LIKE '%keyword%'。中文和短文本场景下这个写法完全够用,但 snippet 数量到几千条后,全表扫描会明显变慢。

后续我做了两步优化:

第一步,给language和title加普通索引:

@@index([language]) @@index([authorId])

第二步,把简单搜索保持为contains,但给自己部署的 PostgreSQL 增加一个全文检索列。Prisma 本身不直接支持 PostgreSQL 的tsvector字段类型,完整方案需要手写原生 SQL 或迁移语句,对一个小工具来说有点重。我的选择是:标题搜索用contains,标签筛选走关联表索引,代码内容搜索则限制在登录用户自己的片段范围内。实测下来,个人开发者收藏几千条代码片段完全在可控范围内,不需要额外引入 Elasticsearch 或 Postgres 全文检索。

这也顺便说明一个观点:工具型项目优先用最简单能满足需求的方案,等数据规模真上来了再优化也不迟。不要为了"看起来专业"一开始就上重型搜索中间件。

5. 用户认证与权限:NextAuth 如何和 tRPC 联动

5.1 配置 GitHub 登录

t3code 的登录方式我选了 GitHub OAuth,因为目标用户就是开发者,GitHub 账号基本人人都有。创建 OAuth App 的步骤不细说,核心配置在src/server/auth.ts:

import { getServerSession, type NextAuthOptions } from "next-auth"; import GitHubProvider from "next-auth/providers/github"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "~/server/db"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(db), providers: [ GitHubProvider({ clientId: process.env.GITHUB_ID!, clientSecret: process.env.GITHUB_SECRET!, }), ], session: { strategy: "database", }, callbacks: { session: ({ session, user }) => ({ ...session, user: { ...session.user, id: user.id, }, }), }, }; export const getServerAuthSession = () => getServerSession(authOptions);

看两个细节:

session.strategy我设成了database,也就是 session 存数据库表里。默认 JWT 策略可以在无数据库情况下运行,但既然已经有 Prisma,用 database 策略有一个好处:可以做"踢出用户"操作,禁用账号后直接删 session 记录,用户立即失效。对需要权限控制的工具来说更安心。

session回调里把user.id塞进 session,这是 t3code 权限判断的关键。默认情况下前端拿到的 session.user 只有 name、email、image,没有数据库主键 id。而 snippet 的authorId关联的是 User 的 id,如果不加这个回调,后端的 protectedProcedure 就拿不到当前用户 id,无法查询"我创建的片段"。

5.2 在 tRPC 中读取 session 并做权限控制

create-t3-app 把鉴权封装在src/server/api/trpc.ts里:

import { initTRPC, TRPCError } from "@trpc/server"; import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; import { getServerAuthSession } from "~/server/auth"; import { db } from "~/server/db"; export const createTRPCContext = async (opts: CreateNextContextOptions) => { const { req, res } = opts; const session = await getServerAuthSession({ req, res }); return { db, session, }; }; const t = initTRPC.context<typeof createTRPCContext>().create(); export const createTRPCRouter = t.router; export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });

protectedProcedure是 t3code 几乎所有写操作和私有查询的基础。create、update、delete全部用它,前端如果未登录调用这些接口,得到的统一错误是UNAUTHORIZED,不会泄露任何业务代码逻辑。

在delete路由里还有一层资源归属校验:

delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const snippet = await ctx.db.snippet.findUnique({ where: { id: input.id }, select: { authorId: true }, }); if (!snippet) throw new TRPCError({ code: "NOT_FOUND" }); if (snippet.authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } return ctx.db.snippet.delete({ where: { id: input.id } }); });

这段代码的意图是"服务端强制资源归属校验",而不是依赖前端隐藏删除按钮。很多人第一次做权限系统时会忽略这一点,觉得前端不显示按钮就安全了。但只要请求能到达服务端,直接调 tRPC 路由就能执行删除操作,前端界面只是遮挡,不是安全边界。所以每个 mutation 里都判断当前用户是否资源所有者,是我非常坚持的一条原则。

前端叫登录态也很简单:

const { data: session, status } = api.auth.getSession.useQuery();

不过 create-t3-app 的模板更推荐直接用 NextAuth 的useSession钩子。t3code 里页面组件用useSession()判断是否显示登录按钮,tRPC 查询用publicProcedure配合查询条件里的 session 判断做可见性过滤,两者职责清晰,不重复。

这里的联动逻辑值得再强调一遍:NextAuth 负责"你是谁"(认证),tRPC 负责"你能干什么"(授权)。认证状态放在 tRPC 的 context 里,每条路由天然能访问 session,不需要每个 API 都单独获取一遍用户信息。

6. 部署上线与踩坑实录

6.1 部署到 Vercel 的环境变量清单

t3code 的部署目标一开始就是 Vercel,因为 Next.js 项目部署上去基本零成本,自带 HTTPS、全球 CDN、Serverless 函数。部署操作的本身不复杂,把 GitHub 仓库连上 Vercel,选择根目录,Build 命令默认即可。真正折磨人的是环境变量同步。

以下变量必须全部在 Vercel 项目设置里填一遍:

变量名说明
DATABASE_URL指向生产数据库的连接串
AUTH_SECRET/NEXTAUTH_SECRET根据 NextAuth 版本选择
AUTH_URL/NEXTAUTH_URL生产环境域名,通常是https://t3code.vercel.app
GITHUB_IDGitHub OAuth App 的 ID
GITHUB_SECRETGitHub OAuth App 的 Secret

有一个非常隐蔽的坑:NextAuth v4 默认读NEXTAUTH_SECRET,v5 改成了AUTH_SECRET。create-t3-app 升级到新版本后默认是 v5,但教程里大量还是 v4 写法。我的做法是在.env里同时写两个变量,让路由配置兼容。Vercel 面板里也把两个都填上,这样即使依赖版本在部署时被更新,也不会突然登录失败。

还有 GitHub OAuth 的Authorization callback URL,必须在 GitHub 的 OAuth App 设置里填成生产环境域名:https://t3code.vercel.app/api/auth/callback/github。本地开发时用的http://localhost:3000和生产环境是两个完全不同的回调地址,很多人部署后点击登录按钮跳转到 GitHub 授权页,提示 redirect_uri 不合法,基本都是这里漏配了。GitHub 支持一个 App 配多个回调地址,我本地和线上都填进去了。

6.2 Prisma 与 Serverless 的部署摩擦

Serverless 环境对 Prisma 来说不算友好,主要体现在两个地方。

第一个是包体积。Prisma 需要下载对应平台的查询引擎二进制文件,Vercel 的 Serverless 函数有 250 MB 的体积限制。如果项目中 Prisma 版本和平台二进制不匹配,构建阶段就会报错,错误信息往往是:

Prisma failed to detect the libssl/openssl version to use

解法是在 package.json 里显式指定二进制源:

"prisma": { "schema": "prisma/schema.prisma", "seed": "tsx prisma/seed.ts" }

然后执行:

npx prisma generate

如果还是报错,检查是否需要在schema.prisma中指定:

generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl-openssl-3.0.x"] }

linux-musl是很多 Serverless 运行时的基础环境,加上它通常能解决二进制匹配问题。

第二个是数据库连接数。Serverless 函数是"用完即走"的,每次冷启动都会创建一个新的 PrismaClient,直接连数据库,在高并发下很容易打满连接池。除开之前说的全局单例模式,生产环境更推荐使用连接池代理服务(如 Prisma Accelerate,或直接用 Neon/PgBouncer)。t3code 目前的访问量很小,我用的是数据库连接串里配置一个够用的连接数限制,暂时没上代理。

不过这里建议各位:如果你预计项目上线就会有真实流量,一开始就用 Prisma Accelerate 或 PgBouncer 这类连接池方案,别等问题出现了再迁移。冷启动 + 数据库连接池被占满,是 Next.js + Prisma 部署到 Serverless 平台最经典的组合故障。

6.3 tRPC 在生产环境下的额外配置

create-t3-app 生成的src/trpc/react.tsx默认在生产环境会开启httpBatchLink和superjson转换器。superjson 的作用是让 Date、Map 等非 JSON 原生类型也能在 tRPC 调用中正常序列化。t3code 的 snippet 列表返回包含createdAt: Date,没有 superjson 时前端拿到的是字符串或对象,有了 superjson 才能保持 Date 类型。

默认配置不需要动,但有一个选择要注意:是否禁用批量请求。Vercel Serverless 平台对单次请求有执行时长限制,批量请求合并虽然减少了网络往返,但单个 Serverless 实例需要串行执行多个操作,如果其中一个操作较慢,整体时长可能超限。对 t3code 当前的轻量查询来说没有区别,但如果未来接入 AI 分析代码片段的能力,一个 mutation 可能要跑十几秒,我会单独为它禁用批量:

const aiLink = httpLink({ url: "/api/trpc", transformer: superjson, });

然后让 AI 路由不走默认的 batch 链接。这个方案在项目规划阶段就预留了接口,改起来并不难。

6.4 代码高亮渲染的一个小坑

t3code 展示代码片段时需要语法高亮。我最初用react-syntax-highlighter,在客户端渲染时直接引入,结果页面出现明显闪烁:先看到纯文本,高亮样式才慢慢加载出来。后来换成在服务端组件里用 Shiki 做高亮。Shiki 的包体积比较大,如果直接在顶层组件 import,会显著增大页面 bundle。

我的解法是动态导入高亮逻辑:

const getHighlighter = () => import("shiki").then((mod) => mod.createHighlighter({ themes: ["github-dark", "github-light"], langs: ["typescript", "javascript", "python", "go", "markdown"], }) );

在服务端把代码转成 HTML 字符串后传给前端展示。这个方案的优点是零客户端高亮逻辑,页面首屏就是最终高亮结果;缺点是首屏渲染耗时稍微增加。对代码片段这种以阅读为主的页面来说,这个取舍我完全能接受。

7. 关于 t3code 这个项目,我最想保留的三点经验

项目做完了,回看整个开发过程,有几个经验比具体代码更值得记下来。

第一,别小看"类型贯穿全链路"这件事。t3code 复杂的不是单个功能,而是多个模块之间的契约关系:数据库字段、tRPC 入参、UI 状态、权限边界。T3 Stack 把它们压缩成一套类型体系后,我敢在前端随意重构页面,改完 IDE 立刻告诉我哪里类型不匹配。这种安全感是传统前后端分离项目给不了的。

第二,服务端永远要自己校验权限。tRPC 的 protectedProcedure 解决的是"是否登录",但"是否本人资源"这种业务鉴权必须在每个 mutation 里单独判断。我见过太多人有"登录了就能操作一切"的误区,觉得自己项目小没人会恶意调用。权限漏洞和流量大小无关,正确的是从第一天就把安全边界彻底封死。

第三,部署类问题要提前留时间。t3code 的代码业务开发大概用了两周,部署和环境变量排查却消耗了几乎一样多的时间。如果你要复刻这个项目,建议在写业务代码之前就把 CI/CD 和环境变量清单整理好,部署时逐项核对,能省掉大量"本地正常线上挂了"的焦虑时间。

如果你也想做一个类似的工具型全栈项目,直接按npx create-t3-app@latest初始化,然后把代码片段、标签、用户这三个模型落进 Prisma schema,沿着本文的顺序从 tRPC 路由写到前端页面,最后再按部署清单逐一排查环境变量,应该很快就能跑通。