资讯动态

T3 Stack全栈实战:从零构建代码片段分享平台

发布时间:2026/10/9 18:25:10 来源:尧图企业网站定制
1. t3code 是什么我为什么把整套服务押在 T3 上1.1 这个项目到底解决了什么问题t3code 是我用大半个月时间做出来的一个代码片段分享与整理平台。名字里的 t3 有两层意思一是整套技术栈顺着 T3 Stack 的思路来搭二是核心围绕着 TypeScript、tRPC、Tailwind CSS 这三个 T 字头的东西展开。做它的初衷很朴素——我自己的代码散落在各种地方本地文件夹、临时粘贴的 gist、聊天记录、笔记软件里的代码块真要找一段两个月前写过的算法片段时往往要翻好几个地方最后还要猜当初写它到底是为了什么。t3code 想解决的就是这个代码收纳与分享的问题每条片段可以写标题、描述、打标签可以设置为公开或私有公开的片段能拿到独立详情页分享给同事其他人还能在下面评论讨论。功能规模不算大但它把一个全栈应用该有的环节全走了一遍账号登录、数据建模、增删改查、列表分页、动态路由、分享页面、评论互动。对我来说它同时也是个试验场——我想验证一个老问题用一套 TypeScript 技术栈把前后端整个串起来到底能不能做到省心。如果你正处于想做一个中小型产品但又不想同时维护前端和后端两个工程的阶段这篇文章比较适合你。后面我会按真实开发顺序来讲从选型、初始化、建表、写接口、渲染页面到部署上线和踩坑记录你照着做基本能完整复现。1.2 技术选型为什么选择 T3 全家桶动工之前我认真对比过几条路线。传统方案是 React 前端加 Express 后端走 REST 接口。这套很成熟但类型没法共享前端一套接口定义、后端一套类型声明改一个字段经常要动两处代码还记得你上次因为忘了同步类型导致线上报错是什么时候吗GraphQL 方案类型共享体验不错可光是 schema、resolver、codegen 那套配置对个人项目来说明显偏重。真正让我定下来用 T3 的理由只有一个tRPC 能在前端直接调用后端函数的同时把输入输出的类型完整复用不需要手写接口文档也不需要单独维护一套类型对接层。打个比方以前前后端交互像两个部门靠邮件沟通每个接口都要写清楚参数格式用 tRPC 之后相当于两边共用同一本通讯录函数名和参数类型在编辑器里直接能看到少掉一整层沟通成本。我最终的选型清单层技术承担职责框架Next.js 14App Router页面渲染加服务端能力语言TypeScript全链路类型约束接口层tRPC函数调用与类型传输数据层Prisma SQLite数据库建模与访问认证NextAuth.js登录与会话管理样式Tailwind CSS页面样式部署Vercel托管与构建这套组合最大的特点是少选择、多约定。对个人项目和中小型产品来说选择少反而是优势你能把精力集中在业务上而不是三天两头纠结某个中间件到底用谁。2. 从零初始化项目脚手架、数据建模与环境变量2.1 create-t3-app 脚手架搭建初始化我用的是官方脚手架包管理器选 pnpmpnpm create t3-applatest t3code交互式引导会让你勾选需要的模块。我勾了 NextAuth.js、Prisma、Tailwind CSS、tRPCESLint 也保留。T3 脚手架虽然上手快但务必重视它生成的目录结构后面很多类型报错都跟文件放错位置有关。生成之后的核心结构t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/api/auth/[...nextauth]/route.ts # NextAuth 入口 │ ├── server/ │ │ ├── api/routers/ # tRPC 路由 │ │ ├── api/trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ └── trpc/ │ └── server.ts # 服务端调用器t3code 用的新版脚手架默认走 App Router但核心逻辑都收敛在 server 目录里页面只管调用所以就算你用的是旧版脚手架迁移成本也不高。2.2 Prisma 数据模型设计t3code 的主数据模型规划了四张表用户、代码片段、标签、评论。先看 schemamodel User { id String id default(cuid()) name String? email String? unique image String? snippets Snippet[] comments Comment[] } model Snippet { id String id default(cuid()) title String description String? code String language String default(plaintext) visibility String default(public) createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) tags SnippetTag[] comments Comment[] } 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], onDelete: Cascade) tag Tag relation(fields: [tagId], references: [id], onDelete: Cascade) id([snippetId, tagId]) } model Comment { id String id default(cuid()) content String createdAt DateTime default(now()) authorId String snippetId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) }几个设计决策直接说结论。主键用 cuid 而不是自增 ID因为公开详情页的 URL 会暴露给所有人自增 ID 容易被遍历抓取cuid 虽然不等于加密但足够让 URL 不可猜测也顺手避免掉了别人顺着 id 把公开数据全扒走的低级问题。标签用多对多关联表而不是字符串数组。刚开始我想偷懒在 Snippet 上放一个tags String[]结果要实现哪些代码用了这个标签就得全表扫描做模糊匹配数据量稍微上来就卡。拆成关联表之后按标签筛选就是一次普通 join代价只是多写一点嵌套代码。所有外键都加了onDelete: Cascade意思是用户删账号、删片段时关联的评论、标签记录一并清理不会留下孤儿数据。生产环境大表上 Cascade 要谨慎但这个项目规模下完全合理。2.3 环境变量与数据库初始化本地开发我用 SQLite 文件起步连接串长这样DATABASE_URLfile:./db.sqlite先用 SQLite 而不是直接上 Postgres是因为本地验证业务逻辑完全够用Prisma 也支持得很顺利。等要上生产再切 Postgres改一下连接串重新 migrate 就行。同步表结构我用的命令npx prisma db push开发阶段用 db push 最省事它会直接比对 schema 和数据库并把差异同步上去。等模型稳定了再切到正式的迁移流程。NextAuth 需要两个环境变量NEXTAUTH_SECRET一串随机字符串 NEXTAUTH_URLhttp://localhost:3000密钥生成我推荐直接用系统命令openssl rand -base64 32这里有个非常典型的翻车点本地环境变量配置齐全部署后 NEXTAUTH_URL 忘了改成正式域名登录回调就会一直失败页面报 500 你还不知道错在哪。这东西建议在项目第一天就写进 README。3. 核心功能实战认证、接口和页面的完整串联3.1 NextAuth 会话如何进入 tRPC 上下文登录部分我用的 GitHub OAuth。NextAuth 配置起来很省事在src/server/auth.ts里导出 authOptions再在 API 路由里暴露给 NextAuth// src/app/api/auth/[...nextauth]/route.ts import NextAuth from next-auth; import { authOptions } from /server/auth; const handler NextAuth(authOptions); export { handler as GET, handler as POST };关键的一步是把 session 塞进 tRPC 的 context这样每个接口都能知道自己是谁在调用// src/server/api/trpc.ts import { initTRPC, TRPCError } from trpc/server; import type { CreateNextContextOptions } from trpc/server/adapters/next; import { getServerSession } from next-auth; import { authOptions } from ../auth; import { db } from ../db; export const createContext async (opts: CreateNextContextOptions) { const session await getServerSession(authOptions); return { db, session }; }; const t initTRPC.contexttypeof createContext().create(); export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, session } }); });这个protectedProcedure是我在 T3 栈里最喜欢的抽象。它把必须登录才能调用封装成一个中间件创建片段、发评论这些操作直接声明用 protectedProcedure 就可以鉴权逻辑不会散落在每个路由里重复写。如果会话不存在统一抛出UNAUTHORIZED前端拿到这个错误码就能跳转登录页非常干净。3.2 tRPC 路由片段的创建、列表与详情t3code 的核心路由大概长这样// src/server/api/routers/snippet.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; export const snippetRouter createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(60), code: z.string().min(1), language: z.string().default(plaintext), visibility: z.enum([public, private]).default(public), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { const snippet await ctx.db.snippet.create({ data: { title: input.title, code: input.code, language: input.language, visibility: input.visibility, authorId: ctx.session.user.id, tags: { create: input.tags?.map((name) ({ tag: { connectOrCreate: { where: { name }, create: { name }, }, }, })), }, }, }); return snippet; }), list: publicProcedure .input( z.object({ cursor: z.string().nullish(), limit: z.number().min(1).max(50).default(10), tag: z.string().optional(), }) ) .query(async ({ ctx, input }) { const items await ctx.db.snippet.findMany({ where: { visibility: public, ...(input.tag ? { tags: { some: { tag: { name: input.tag } } } } : {}), }, take: input.limit 1, skip: input.cursor ? 1 : 0, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: desc }, include: { author: true, tags: { include: { tag: true } } }, }); let nextCursor: string | undefined; if (items.length input.limit) { const nextItem items.pop(); nextCursor nextItem?.id; } return { items, nextCursor }; }), byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { const snippet await ctx.db.snippet.findUnique({ where: { id: input.id }, include: { author: true, tags: { include: { tag: true } }, comments: { include: { author: true } }, }, }); if (!snippet) throw new TRPCError({ code: NOT_FOUND }); return snippet; }), });这段代码里有三个容易出错的地方挨个说。connectOrCreate是标签去重的关键。没有它重复提交同名标签会创建大量脏数据还会触发唯一约束报错。用了它之后同名的标签会直接复用已有记录这是多对多标签系统里必须记住的写法。列表查询用了基于游标的分页而不是传统页数分页。原因很实际代码片段列表是持续变动的页数分页在数据插入时会出现重复或漏读。游标分页用上一页最后一条的 id作为下一页的起点数据再怎么变都不会乱。至于take: limit 1这是判断还有没有下一页的常用技巧如果实际查出的是 limit1 条说明后面还有数据把第 limit 条的 id 作为 nextCursor 返回给前端。nextCursor 为空即到达末尾。3.3 页面渲染服务端初值加客户端接管列表页我用 App Router 的服务端组件拿首屏数据因为搜索引擎和直接访问的用户需要看到即时内容// src/app/page.tsx import { HydrateClient } from /trpc/server; import { api } from /trpc/server; export default async function HomePage() { const initialData await api.snippet.list({ limit: 10 }); return ( HydrateClient SnippetList initialData{initialData} / /HydrateClient ); }这里的api.snippet.list是脚手架提供的服务端调用器它会在服务端直接执行 tRPC 路由并序列化结果到客户端。好处是首屏没有 loading 状态也不会因为浏览器端再发一次请求造成数据不一致。客户端组件里再用 useQuery 接管后续交互use client; import { api } from /trpc/react; export function SnippetList({ initialData }) { const { data } api.snippet.list.useQuery( { limit: 10 }, { initialData } ); // 渲染列表... }注意一个原则不要同时用服务端 caller 和客户端 query 去请求同一份数据否则 React 会报 hydration 不匹配。正确姿势是服务端把初值塞进缓存客户端 query 只在缓存 miss 时才发请求之后的刷新全部交给客户端管理。这个配合关系我第一次用的时候没搞明白导致首页每次刷新都闪一下 loading排查半天才反应过来是重复请求导致缓存 key 对不上。4. 踩坑实录类型报错、迁移失败和线上白屏4.1 tRPC 类型推断的翻车现场开发第一周踩得最多的坑都集中在类型上。症状一改了后端返回结构前端组件依然用旧字段编译器全程没反应直到运行时报 undefined。原因多半是某个 query 的返回值里显式写了any把 tRPC 的类型保护整个关掉了。这里有个铁律tRPC 的输入输出类型尽量让 TypeScript 自己推断不要在路由层写as any更不要把返回类型显式标成 any。一旦出现 any前面做的所有类型安全工作全白费。症状二报错Type instantiation is excessively deep。原因是路由 input 对象存在过度嵌套。我那个片段的 tags 字段一开始直接引用了完整的 Tag 类型zod 校验也没收口导致类型展开过深。解决办法是 input 里只接收字符串数组数据库查询结果交给 Prisma 推断绝不手动引用模型类型当入参。症状三前端调用某个 mutation 后返回值在编辑器里显示never。这通常是因为输入参数的 zod 校验和数据库 create 的 data 字段对不上TypeScript 推断出永远不可能成立的联合类型。遇到这种问题先删掉 input 里的 optional chain 看能不能复现再逐步把字段加上基本能定位。排查 tRPC 类型问题我有一个固定流程先pnpm tsc --noEmit看有没有全局类型错误再用编辑器点进路由函数返回值确认推断是否正确最后在前端组件里 hover 一下data字段看类型是否完整。三步做完大部分类型断裂问题都能找到原因。4.2 Prisma 迁移与 SQLite 的边界Prisma 迁移流程本身不复杂但有几个坑值得记录。迁移文件只加不改。我不止一次想把历史迁移里的某个字段直接改掉结果prisma migrate dev立刻报 drift detected警告数据库状态和迁移历史不一致。正确的做法是写错的字段就再新增一条迁移去改历史迁移文件当只读资料不要手动编辑。SQLite 对ALTER TABLE支持有限。比如你给某个字段改了类型Prisma 会发现 SQLite 做不了原地变更会提示你重置数据库。开发阶段重置无所谓但如果已经有线上数据就要非常谨慎。我现在的习惯是模型稳定之前用prisma db push快速同步模型稳定之后立刻切换到正式迁移每一条迁移都用--name起清晰的名字方便追溯npx prisma migrate dev --name add_snippet_comments还有一个连接问题开发时代码里直接new PrismaClient()没问题但部署到无服务器平台后每个函数实例都会尝试创建数据库连接很容易把连接池打爆。解决方案是 Prisma 客户端做单例缓存放在全局变量上只在首次初始化时创建// src/server/db.ts import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;4.3 部署后白屏与 500 的排查思路t3code 第一次部署到 Vercel 后登录功能正常但首页一直 500。排查半天发现是环境变量没配齐本地 .env 里有的变量线上没加进去尤其是 NEXTAUTH_SECRET本地有默认值导致代码怎么跑都正常部署完就露馅。第二个更隐蔽的问题App Router 的服务端组件里调用 tRPC server caller 时如果某个 query 因为数据库连接失败抛异常整个页面都会挂掉而且生产模式下的错误堆栈会被吞掉只给你一个泛泛的 500。我当时的排查套路是本地先执行一遍pnpm build确认没有类型和编译错误。打开 Vercel 的 Functions 日志把关键字捞出来像prisma、unauthorized、DATABASE_URL都值得看一眼。检查环境变量面板里的键名是否和.env完全一致注意大小写和下划线。部署类问题我整理成了一张速查表症状可能原因排查动作登录回调 500NEXTAUTH_URL 没换正式域名检查线上环境变量值首页 500 但本地正常线上缺少 DATABASE_URL 或 SECRET逐一核对键名接口偶发超时数据库连接未复用检查 Prisma 单例缓存构建失败类型未通过或 env 缺失本地pnpm build复现生产日志不显示堆栈错误被框架吞掉在 context 加日志兜底生产日志这块我后来给 createContext 加了一层轻量容错如果 session 查询失败返回空 session 而不是直接崩溃至少让公开页面能正常渲染登录用户的功能单独报错。5. 体验优化分页缓存、暗色模式和高亮渲染5.1 无限滚动与查询缓存列表页改用无限滚动后又碰上一个典型问题从详情页返回列表页时已经加载过的数据会重新请求一遍。原因在于 tRPC 的查询缓存 key 是序列化后的完整输入参数只要参数不同就是两个独立缓存条目。理论上没问题但对列表这种频繁变参的场景我建议直接用useInfiniteQuery接管翻页逻辑const { data, fetchNextPage, hasNextPage } api.snippet.list.useInfiniteQuery( { limit: 10, tag: activeTag }, { getNextPageParam: (lastPage) lastPage.nextCursor ?? null, initialData: propInitialData ? { pages: [propInitialData], pageParams: [null] } : undefined, } );这里有两个细节。第一initialData的结构必须是{ pages: [], pageParams: [] }我第一次直接传了个数组进去页面直接渲染崩溃。第二路由里要返回{ items, nextCursor }这样的结构getNextPageParam才能拿到正确的下一页游标。另外tRPC 的缓存是全局的同一条 query 在不同组件里共享。这意味着列表页滚动到底部加载了五页数据后切到另一个 tab 再切回来缓存还在不会白白浪费请求。这是 tRPC 相比手写 fetch 的优势但前提是你不要在组件里随便关闭缓存。5.2 暗色模式与代码高亮t3code 的阅读场景大多是深夜写代码时查片段暗色模式不是装饰是刚需。我用的 next-themes 接入// src/app/providers.tsx use client; import { ThemeProvider } from next-themes; export function Providers({ children }: { children: React.ReactNode }) { return ( ThemeProvider attributeclass defaultThemesystem enableSystem {children} /ThemeProvider ); }Tailwind 侧在tailwind.config.ts里配好darkMode: class组件里就可以写dark:bg-gray-900这类类名很顺手。代码高亮这块我用的prism-react-renderer但这个组件有个隐藏问题它依赖浏览器 DOM如果直接在服务端组件里渲染会报 hydration 错误。我的做法是把它单独封装成 client component语言类型做兜底处理use client; import { Highlight, themes } from prism-react-renderer; export function CodeBlock({ code, language }: { code: string; language: string }) { return ( Highlight theme{themes.nightOwl} code{code.trim()} language{language || plaintext} {({ className, style, tokens, getLineProps, getTokenProps }) ( pre className{className} style{style} {tokens.map((line, i) ( div key{i} {...getLineProps({ line })} {line.map((token, key) ( span key{key} {...getTokenProps({ token })} / ))} /div ))} /pre )} /Highlight ); }语言类型一定要兜底遇到未知语言直接按 plaintext 处理不然 prism 的 tokenizer 会抛异常生产环境又是难排查的白屏。另外我给详情页加了一键复制按钮用navigator.clipboard.writeText实现简单直接代码片段工具最核心的体验就是看一眼 拷走用。6. 回看 t3code一些真实的开发体会项目收尾后我最大的体会不是这套栈真好用而是约束的力量。create-t3-app 把目录结构、认证方式、接口放置位置都定死了我反而省掉了一大批今天用 A 库还是 B 库的决策成本。中小型全栈项目最怕的不是功能复杂而是选择太多导致时间被磨掉。T3 给我的是一种近乎固执的约定绑定它的同时也把注意力解放给了真正的业务逻辑。另一个很实在的经验是环境变量和数据库迁移这两件事务必要在项目第一天整理清楚。很多项目后期花大力气补环境变量文档、规范化迁移流程代价远高于一开始就写好那一页 README。我自己就在 t3code 上因为 NEXTAUTH_URL 线上漏配折腾了两个小时这种时间本不该花。最后分享一个小技巧算是我的习惯每次新增或修改一个 tRPC 路由、改动 Prisma schema 之后先在终端跑一遍pnpm tsc --noEmit再打开浏览器把核心流程点一遍。这比写一堆测试更能早期暴露类型断裂问题尤其对单人全栈项目来说这是成本最低、收益最直接的验证方式。t3code 目前还在迭代我接下来打算加两个功能一个是基于全文搜索的片段检索目前列表页只能按标签过滤等片段数量起来之后关键词搜索就是刚需另一个是在线运行片段的能力简单场景下可以直接在页面里执行 JavaScript 片段避免用户为了验证一段代码再开一个编辑器。这两个功能做完之后我再单独写一篇分享。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑