T3 Stack 实战:用 Next.js 与 tRPC 构建类型安全的代码片段管理站 前阵子我搭了个小项目代号 t3code本质上是一个面向开发者的代码片段管理站。取名 t3code 不是想蹭什么新概念而是它确确实实跑在 T3 Stack 全家桶上Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth。我写它的动机非常私人——平时存代码的工具太散了本地文件夹、在线笔记、临时聊天记录里到处都是真到要用的时候只能靠搜索引擎和模糊记忆。于是想把“代码片段”这件事做成一个有标签、全文检索、代码高亮、可分享的小站。t3code 最大的标签不是功能多而是“端到端类型安全做得很彻底”从 PostgreSQL 表结构、Prisma 返回的数据到 tRPC 的入参和出参再到前端 React 组件里拿到的对象全部由 TypeScript 在同一套类型系统里完成闭环。开发中基本不用猜某个字段是否存在IDE 直接给提示编译期能暴露的问题少了一大半。这个项目的定位很适合作一个完整的 T3 Stack 实战样本有登录、有数据库、有 API、有页面、有权限控制、有部署。如果你正打算学 T3 Stack想用它做一个真实可用的网站又不想看那种只讲 hello world 的教程那么下面这些从设计到落地的细节应该都能直接用上。1. 整体设计与思路拆解t3code 为什么要用 T3 Stack1.1 T3 Stack 的底层逻辑让类型从数据库一路“长”到浏览器T3 Stack 的命名来源很直白它取了三个以 T 开头的关键技术TypeScript、Tailwind CSS、tRPC。再叠上 Next.js 做应用框架、Prisma 做数据库访问层、NextAuth 做认证授权后整个链路就非常完整。先拆开看每一层Next.js 负责页面和渲染Tailwind 负责样式Prisma 把数据库表映射成 TypeScript 模型tRPC 负责服务端函数的远程调用NextAuth 管理登录会话。单独看每一个工具都不复杂复杂的是它们之间怎么配合。t3code 里最核心的组合是 Prisma tRPC。Prisma 负责让数据库记录变成带类型的对象tRPC 负责让浏览器直接调用服务端函数。两层叠加以后前端页面拿到的数据对象是从数据库记录一路推出来的不需要手写 API 文档也不需要在两个项目之间重复维护 interface。这里面的体验差别只有真正写过前后端联调的人才能体会。打个比方方便理解。传统开发模式里前端和后端像是两个公司之间的合作两边各自维护一份合同文本后端改了字段名前端不一定同步得到。tRPC 加 Prisma 的模式则像同一个团队里两个人直接坐在一起协作后端改动一个变量前端立刻能在编辑器里看到红色报错不需要开会对齐。T3 Stack 里还有一个理念在 t3code 里被我用得很彻底“类型安全优先”。不是说每个变量都要写得很复杂而是让类型信息尽量从数据库源头开始自动推导。数据库表长什么样Prisma 模型就长什么样Prisma 模型长什么样tRPC 出参就长什么样tRPC 出参长什么样前端组件接到的对象就是什么样。这条链路顺了开发效率随之提高。1.2 我把 t3code 拆成了哪几个模块做项目之前先把边界划清楚。t3code 的需求池被我收敛成四个模块用户模块注册、登录、会话管理。基于 NextAuth同时支持 GitHub 登录和邮箱密码登录。代码片段模块核心业务包含新增、查看、编辑、删除以及复制、分享、上下架等功能。标签模块片段和标签是多对多关系支持点击标签筛选也支持在新建时自动创建标签。列表与检索模块关键词搜索、按语言过滤、按标签聚合排序规则用最新创建优先。模块边界划清楚了后面写 tRPC router 的时候就不会乱。每个模块一个 routerrouter 之间的关系通过 Prisma 模型串起来业务代码不互相渗透。我也刻意做了一些减法。t3code 没有做团队空间、评论、点赞、关注这些重社交功能也没引入富文本编辑器。因为 T3 Stack 的核心哲学是“保持精简、快速推出”。一个代码片段管理站最核心的体验是把代码存下来、找得到、看起来舒服。功能堆得太多反而会让类型安全和单元测试的边际成本上升第一批使用者也容易被复杂入口吓跑。1.3 技术选型tRPC 和 Prisma 为什么让我舒服很多团队写 Web 应用默认就是 REST API 那一套后端定义路由写请求处理函数前端根据接口文档手动写类型。小项目可能没问题但一旦字段多了接口文档和后端代码很容易不一致。t3code 用 tRPC 后这个困扰基本消失。tRPC 不需要生成独立的 API 定义文件它是把后端函数变成可以远程调用的过程。前端调用api.snippet.getAll.useQuery()传入的 query 参数、返回的数据结构全部由 TypeScript 自动推断。REST 时代那种“看到接口文档和线上响应不一样”的问题在 tRPC 这条路上找不到生存空间。Prisma 同样让数据库操作简单了很多。原来写 SQL 映射到对象需要手工处理字段名、关联关系、类型转换Prisma 通过 schema 文件统一描述数据源和模型迁移命令也能自动生成 SQL。t3code 里新增一张标签表只需要改 schema 文件再跑一次 migrate关联关系的前端类型也自动更新。当然技术选型要分场景。如果 t3code 以后要开放第三方 API让外部开发者接入那么 REST 网关仍然有意义。但内部的增删改查、列表分页、管理后台用 tRPC 是最舒服的。两者不是替代关系可以在同一个项目里共存。方案类型维护成本中小型全栈项目体验是否需要独立 API 服务REST OpenAPI高需要手动维护文档和类型一般联调成本偏高通常需要GraphQL中高需要维护 schema 和 resolver可以但对小项目偏重可以tRPC Prisma低类型由代码自动推导非常好前后端变量同源不需要我会把下表里的选型结论用一句话概括t3code 是一个偏内部产品、重类型安全的中小型全栈项目tRPC Prisma 是当前最顺手的选择。2. 核心细节与实操要点从零初始化 t3code 项目2.1 使用 create-t3-app 初始化项目初始化直接使用官方脚手架省掉一大部分手工配置时间。npx create-t3-applatest t3code cd t3code pnpm install pnpm prisma migrate dev --name initcreate-t3-app 初始化时会问到要不要使用 NextAuth、Prisma、Tailwind、tRPC我的建议是全选。如果你只想做一个纯展示型网站NextAuth 可以不开但 t3code 需要登录和权限控制所以这几个核心依赖都要保留。安装完以后默认项目里已经包含一个基本示例有post相关的 router、一张Post表、一个简单的页面。我实际开发 t3code 时先把示例删除并重置 Prisma migration然后从空 schema 开始一步步重新设计。这里有一个经验包管理器尽量用 pnpm。因为 T3 Stack 的依赖链很长node_modules会占用很多磁盘空间pnpm 在速度和磁盘占用上都有明显优势。2.2 目录结构与文件职责初始化后的目录结构是 create-t3-app 的默认规范t3code 在此基础上做增删。核心路径如下t3code/ prisma/ schema.prisma src/ pages/ index.tsx snippets/[id].tsx api/trpc/[trpc].ts server/ api/ root.ts routers/ snippet.ts tag.ts user.ts trpc.ts auth.ts db.ts utils/ api.ts每个文件的作用大概是这样的src/server/db.ts导出 PrismaClient 单例src/server/api/trpc.ts负责创建 tRPC 实例定义公共/受保护 proceduresrc/server/api/root.ts把业务 router 汇总为 appRoutersrc/server/api/routers下按业务域拆分src/utils/api.ts是前端调用 API 的入口。t3code 的 bug 少一部分要归功于这种把分工落到清晰位置的目录结构。不会出现在页面组件里直接 new PrismaClient 的坏味道。2.3 数据模型设计Prisma Schema 里的关键表t3code 的 Prisma schema 核心部分设计如下NextAuth 涉及的 Account、Session、VerificationToken 表由脚手架的默认代码负责这里不展开。generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? role String default(user) snippets Snippet[] } model Snippet { id String id default(cuid()) title String language String code String description String? authorId String author User relation(fields: [authorId], references: [id]) tags SnippetTag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique snippets SnippetTag[] } model SnippetTag { snippetId String tagId String snippet Snippet relation(fields: [snippetId], references: [id]) tag Tag relation(fields: [tagId], references: [id]) id([snippetId, tagId]) }数据库选用 PostgreSQL 而不是 SQLite主要是为线上部署考虑。SQLite 在本地原型验证阶段很轻但并发写入和部署到 serverless 环境时会受限。t3code 从一开始就按生产环境标准设计省得后面迁移库重新改 schema。这里还有一个设计要点User 表里加了一个role字段用于区分普通用户和管理员。它是字符串类型默认值是user管理员手动改成admin。更严谨的做法是使用 Prisma enum不过 enum 在后续迁移时会带来类型变更的额外成本。对于中小型项目字符串角色加后端校验已经足够。2.4 串联前后端的核心tRPC 的 context 与 proceduret3code 的 tRPC 实例定义集中在src/server/api/trpc.ts。这个文件决定了两件事每个请求能拿到什么上下文以及哪些操作需要登录。import { initTRPC, TRPCError } from trpc/server; import superjson from superjson; import { ZodError } from zod; import { prisma } from ~/server/db; import { getServerAuthSession } from ~/server/auth; export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { prisma, session, }; }; const t initTRPC.contexttypeof createTRPCContext().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); 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, }, }); });初学 tRPC 时很多人会对protectedProcedure的用法感到疑惑。它其实就是一段中间件在正式执行后端函数之前先检查 session 存不存在。不存在就抛出未授权错误。这样的好处是后续所有需要登录的 router 都可以直接复用protectedProcedure不需要每个 mutation 里重复写 if 判断。superjson也是一个容易被忽略但很重要的配置。它可以把 Date、Map、Set 等 JavaScript 特殊类型在序列化时保留下来。没有它的话Prisma 返回的createdAt日期字段到前端会变成字符串处理起来很绕。2.5 Tailwind 排版快速把页面做得像模像样T3 Stack 默认集成 Tailwind这对 t3code 来说受益很大。代码片段列表页、详情页、新建页我都是用 Tailwind 工具类直接堆出来的没有写额外的 CSS 文件。做列表页时我建议先定一个统一的卡片容器样式rounded-lg border bg-white p-4 shadow-sm。代码详情页则用bg-gray-950 text-gray-100作为代码块的底色白字更适合阅读。Tailwind 的原子化类名第一眼会显得杂乱但用顺手后改布局就像搭积木不需要在多个 CSS 文件之间来回切换。3. 核心功能实现t3code 代码片段管理链路3.1 先写 Prisma 层定义 Snippet 与 Tag 的关系t3code 的片段和标签是多对多关系因此中间表SnippetTag必不可缺。之所以不把tagIds存成一个数组字段是因为关系型数据库里做统计和过滤时关联表比数组字段可靠得多。比如筛选出“同时包含 TypeScript 和 Prisma 两个标签的片段”用关联表可以优雅地写条件数组字段则需要额外处理。写完 schema 后执行迁移pnpm prisma migrate dev --name init这条命令会做两件事根据 schema 生成一个新的 migration 文件并且同步到本地数据库。之后如果再次修改 schema需要再建新的 migration。我的习惯是先写 migration 再做业务代码这样所有数据库变更都有了版本记录上线后也能知道每个环境跑到哪一版。这里有一个细节要单独强调本地开发时PostgreSQL 并不一定需要真正安装一个数据库服务。用 Docker 起一个容器是最快的做法。启动后把连接串写进.env的DATABASE_URL即可。不过要注意Docker 容器重启后数据会丢除非挂载数据卷所以本地数据别太当真。3.2 编写 snippet router查询与写入业务代码集中在src/server/api/routers/snippet.ts。这个 router 是 t3code 的核心包含列表、详情、创建、更新、删除五类操作。先看创建和列表这两个最常用的过程。import { createTRPCRouter, protectedProcedure, publicProcedure, } from ~/server/api/trpc; import { z } from zod; const createSnippetSchema z.object({ title: z.string().min(1).max(100), language: z.string().min(1).max(50), code: z.string().min(1), description: z.string().max(500).optional(), tagNames: z.array(z.string().min(1).max(30)).optional(), }); export const snippetRouter createTRPCRouter({ getAll: publicProcedure .input( z .object({ q: z.string().optional(), tagId: z.string().optional(), language: z.string().optional(), }) .optional() ) .query(async ({ ctx, input }) { const where { ...(input?.q ? { OR: [ { title: { contains: input.q } }, { description: { contains: input.q } }, { code: { contains: input.q } }, ], } : {}), ...(input?.tagId ? { tags: { some: { tagId: input.tagId } } } : {}), ...(input?.language ? { language: input.language } : {}), }; const items await ctx.prisma.snippet.findMany({ where, include: { author: { select: { name: true, image: true } }, tags: { include: { tag: true } }, }, orderBy: { createdAt: desc }, }); return items; }), create: protectedProcedure .input(createSnippetSchema) .mutation(async ({ ctx, input }) { const authorId ctx.session.user.id; const tags input.tagNames ? await Promise.all( input.tagNames.map(async (name) { const tag await ctx.prisma.tag.upsert({ where: { name }, update: {}, create: { name }, }); return { tagId: tag.id }; }) ) : []; return ctx.prisma.snippet.create({ data: { title: input.title, language: input.language, code: input.code, description: input.description, authorId, tags: { create: tags, }, }, }); }), });这段代码里有几个值得留意的设计。第一个是 zod 校验。createSnippetSchema对标题长度、语言字段、代码内容都做了基础限制。如果前端绕过界面直接调用 API后端也能把不合法的请求挡在业务逻辑之外。tRPC 会把ZodError自动格式化前端能收到结构化的校验错误信息。第二个是标签的 upsert 逻辑。tag.upsert的意思是这个标签名存在则返回已有记录不存在则新建。这样用户在新建片段时随手输入一个新标签不需要先跑到标签管理页创建流程上非常顺手。第三个是列表搜索条件里用了contains。Prisma 的contains在 PostgreSQL 上会翻译成ILIKE可以匹配不区分大小写的子串。对于 t3code 这种轻量搜索场景已经够用。真的遇到大文本搜索需求再考虑接全文检索引擎。3.3 React 页面把类型安全用到极致前端调用部分t3code 走的是 tRPC 提供的 React Query 集成。在src/utils/api.ts里脚手架已经配置好了httpBatchLink指向/api/trpc。页面里直接写import { api } from ~/utils/api; function SnippetList() { const { data, isPending } api.snippet.getAll.useQuery({ q: keyword, tagId, language, }); if (isPending) { return div加载中.../div; } return ( div {data?.map((item) ( div key{item.id} h2{item.title}/h2 p{item.language}/p /div ))} /div ); }这个data的类型不是手写的而是从snippetRouter的返回类型自动推断出来的。页面里写item.author.name时编辑器会知道author一定包含name和image如果写错字段名直接报错。创建和更新的操作要用useMutationconst createSnippet api.snippet.create.useMutation({ onSuccess: () { void utils.snippet.getAll.invalidate(); }, });invalidate会在创建成功之后让列表页的查询重新拉取页面自动更新不需要手动刷新。React Query 的缓存机制会避免不必要的网络请求用户体验比传统表单提交后整页跳转舒服不少。这里还要提一个常见的性能细节列表页的useQuery默认在组件挂载时才触发请求。如果对首屏渲染速度有要求可以在 getServerSideProps 里用 tRPC 的 server caller 直接请求数据把初始数据通过prefetch传给前端。t3code 目前对首屏要求不算苛刻所以没有刻意做 SSR 预取但 T3 Stack 提供了这个能力按需选用即可。3.4 权限控制从 session 取身份防止越权代码片段是用户创建的内容所以 t3code 需要保证“只有作者本人或管理员可以修改/删除”。这里最容易踩的坑是前端把要操作的 id 传进来后端只校验登录不校验归属。正确做法是在 mutation 内部再去数据库查询一次目标记录对比 session 里的用户信息和记录的作者字段。比如更新片段的逻辑update: protectedProcedure .input(z.object({ id: z.string(), data: createSnippetSchema.partial() })) .mutation(async ({ ctx, input }) { const snippet await ctx.prisma.snippet.findUnique({ where: { id: input.id }, }); if (!snippet) { throw new TRPCError({ code: NOT_FOUND }); } const isAdmin ctx.session.user.role admin; const isOwner snippet.authorId ctx.session.user.id; if (!isOwner !isAdmin) { throw new TRPCError({ code: FORBIDDEN }); } return ctx.prisma.snippet.update({ where: { id: input.id }, data: { ...input.data, title: input.data.title ?? snippet.title, language: input.data.language ?? snippet.language, code: input.data.code ?? snippet.code, }, }); }),千万不要因为前端页面里已经隐藏了编辑按钮就省略后端校验。API 是公开的任何人都可以绕过前端直接发起请求。t3code 的权限规则遵循一条铁律后端必须独立完成三次检查一是是否登录二是资源是否存在三是当前用户是否有权操作。3.5 部署到 Vercel 与环境变量t3code 是 Next.js 应用部署最顺的方案就是 Vercel。具体步骤初始化 Git 仓库把代码推到 GitHub。在 Vercel 里导入项目。在 Project Settings 的 Environment Variables 中配置DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL。执行数据库迁移在本地或 CI 中运行pnpm prisma migrate deploy。部署完成后用正式域名访问并验证登录流程。NEXTAUTH_SECRET的生成方法openssl rand -base64 32生产环境不能沿用开发时的默认密钥否则会话信息可以被伪造。NEXTAUTH_URL则填正式域名的根地址不要在结尾加斜杠。数据库连接串也要注意。如果用的是云数据库服务商一般会提供连接池地址。在 serverless 环境下普通直连可能因为连接数限制而报错。可以在连接串后面追加参数例如DATABASE_URLpostgresql://user:passwordhost:5432/dbname?connection_limit5这样可以让每个 serverless 实例只创建少量连接降低数据库压力。4. 常见问题与排查技巧实录t3code 开发中踩过的坑4.1 改了 router前端类型识别不更新这是 t3code 开发期遇到最多的一个问题。症状是后端新加了一个 query保存后前端useQuery依然读不到或者提示传入的参数类型不匹配。出现这个现象通常不是代码写错了而是 TypeScript 服务还缓存着旧的 router 类型。解决办法很简单重启开发服务器或者在编辑器里手动 Restart TS Server。VS Code 里可以用CtrlShiftP输入TypeScript: Restart TS Server。做完这一步前端一般就能立即识别新接口。要特别提醒的是不要用ts-expect-error强行绕过。类型错误是 t3code 最把这次错误“视而不见”后面页面里拿到字段再报错时排查成本会高出很多。4.2 Prisma migrate dev 失败Prisma 迁移命令失败的原因很多最常见的是数据库连接不上错误码P1001。本地开发时可以先确认.env里的DATABASE_URL是否填对、PostgreSQL 服务是否启动。如果中途修改过 schema 里已有的字段经常会出现提醒说 migration 冲突或者存在 drifted 状态。有一个可用的命令是prisma migrate reset它可以把数据库重置到最新 migration 并重新执行种子数据。不过这个命令会清空所有表只能在开发环境用绝不能在生产环境执行。生产环境更新 schema 的正确姿势是本地生成 migration提交到代码仓库然后在部署时执行prisma migrate deploy。我也经历过一次迁移“卡死”看起来像一直没响应。实际原因是表里有大量数据而 MySQL/PostgreSQL 在反复尝试默写困难。这时候先查看数据库有没有锁不要盲目反复执行 migrate dev。4.3 NextAuth 登录后 session 为 nullt3code 里所有受保护接口都依赖 session如果 session 为 null登录用户的请求就会被protectedProcedure拒绝。排查这个问题时我按下面这个顺序走先确认浏览器 cookie 里有没有 NextAuth 的 session cookie。再确认NEXTAUTH_SECRET和NEXTAUTH_URL是否配置正确。然后确认createTRPCContext里有没有正确调用getServerAuthSession。在本地开发时如果用了自定义端口NEXTAUTH_URL必须写成http://localhost:3000这样完整的内容否则回调地址可能不对。有一个很容易忽略的细节在 tRPC context 里调用getServerAuthSession时要记得把请求 headers 传入。如果 headers 为空NextAuth 找不到 cookiesession 自然就是 null。新版 create-t3-app 生成的代码里已经处理了这个但如果你从旧项目升级容易在这个地方踩坑。4.4 tRPC 路由 500 或 404出现这个问题的原因通常比较直接。404 大多是前端请求的路径不对tRPC 的请求路径默认是/api/trpc/xxx.xxx某个字母写错就找不到。500 则需要看服务端日志。前端报INTERNAL_SERVER_ERROR时错误信息会被刻意隐藏需要去 Vercel 的 Function Logs 里查看具体堆栈。还有一种情况是新写了一个 router但忘记在src/server/api/root.ts里注册。只要你自己把snippetRouter加到createCallerFactory或appRouter的合并里页面才会真正接管。不要只看 router 文件本身。4.5 Vercel serverless 下数据库连接数不够t3code 部署到 Vercel 后如果并发不高普通直连数据库没什么问题。但一旦请求量上来serverless 函数的多个实例同时连接数据库很容易把连接数打满。解决方案有以下几种在连接串里设置connection_limit让单个实例控制连接池大小。使用数据库服务商提供的连接池代理地址。将 Prisma 查询转移到独立的 Node 服务上但这样就失去了 serverless 的便利。对于 t3code 这种量级的项目第一种方案最轻量。改一行连接串即可不需要动代码。4.6 代码高亮和 XSS 必须同时处理t3code 存的是代码片段展示时会把代码渲染成带高亮的 HTML。这里有一个安全问题很容易被忽视用户提交的代码内容可能是从网上复制来的里面可能含有script、img onerror等危险标签。如果直接把 content 塞进dangerouslySetInnerHTML就是送上门来的存储型 XSS。我的处理方式是渲染高亮用专门的代码高亮库例如react-syntax-highlighter或shiki它们默认会转义代码内容不把它当成 HTML 执行。如果只是渲染纯文本且需要保留空格换行就用white-space: pre-wrap配合escape-html库转义。t3code 不提供代码在线运行沙箱因此不需要用 eval 或动态执行机制这本身也避开了一大批安全问题。现象最可能原因解决办法前端读不到新加的 tRPC 接口TS Server 缓存重启 dev server 或 Restart TS ServerPrisma migrate 报 P1001数据库连接串错误核对 DATABASE_URL确认服务已启动登录后 session 为 nullNEXTAUTH_SECRET / headers 传递问题检查环境变量及 createTRPCContexttRPC 返回 404请求路径或 router 未注册检查前端调用路径及 root.ts部署后经常断连serverless 连接数超限加 connection_limit 或改用连接池页面出现闪烁或 HTML 标签未转义代码内容使用 highlight 库自动转义避免 innerHTML5. 一些基于实践的补充与小建议项目做到这里我对“全链路类型安全”有了更实际的体会。类型不是万能的它能保证字段不写错但保证不了业务规则没有漏洞。比如权限控制到底该用哪种策略、哪些操作需要写审计日志、删除片段时要不要级联删除标签这些逻辑仍然要靠人工思考和测试来兜底。t3code 的做法是能用类型强约束的坚决用类型不能用类型约束的放到业务层配合少量关键测试验证流程。如果以后要继续扩展 t3code我会优先做两个方向。一是 AI 自动打标签根据片段标题和代码内容自动推荐候选标签能省掉手动分类的大量时间。二是导出与导入把本地代码时间线同步到 t3code换电脑时一键拉回来。这两个功能都不会破坏 T3 Stack 的类型安全体系也符合我当初做这个项目的初衷。最后一个实用小技巧在新建页面里用 tab 键快速把焦点从标题切换到代码编辑器实际用起来很顺手。前端写起来就是多监听一个 keydown 事件但能让日常使用体验提升不少。t3code 作为个人工具最重要的不是功能堆叠而是让每次打开它都觉得顺手。