
把内部工具项目从零做成一个能上线的东西这件事听起来简单真正做起来全是细节。最近我利用业余时间做了一个代号叫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. 初始化 t3codecreate-t3-app 的细节与目录拆解2.1 交互式创建与方案取舍创建项目只需要一行命令npx create-t3-applatest t3code交互过程会让你选择需要的模块。我的实际选择如下模块选择理由TypeScript是默认强制Tailwind CSS是原子类写起来快适合工具型 UItRPC是T3 核心替代手写 RESTPrisma是数据层必须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_URLpostgresql://postgres:passwordlocalhost:5432/t3code NEXTAUTH_SECRETgenerate-a-strong-secret NEXTAUTH_URLhttp://localhost:3000 GITHUB_IDyour-github-oauth-app-id GITHUB_SECRETyour-github-oauth-app-secretNEXTAUTH_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 createTRPCReactAppRouter();AppRouter来自src/server/api/root.tsexport 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: demot3code.dev }, create: { email: demot3code.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.tsimport { 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.contexttypeof 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.appGITHUB_IDGitHub OAuth App 的 IDGITHUB_SECRETGitHub OAuth App 的 Secret有一个非常隐蔽的坑NextAuth v4 默认读NEXTAUTH_SECRETv5 改成了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-applatest初始化然后把代码片段、标签、用户这三个模型落进 Prisma schema沿着本文的顺序从 tRPC 路由写到前端页面最后再按部署清单逐一排查环境变量应该很快就能跑通。