T3 Stack全栈实战:构建类型安全的代码片段管理应用 如果你正在找一份能直接上手、不用绕弯子的全栈项目教程那么基于 T3 Stack 打造的t3code应该能对上你的胃口。这个项目本质上是一个代码片段分享与管理系统开发者可以注册登录把自己常用的代码块按语言分类保存支持标签检索、收藏夹和公开主页。整个项目最大的特点不是功能多而是类型安全做到极致——从数据库到后端接口再到前端组件所有数据类型都在一整套 TypeScript 体系里流动改一个字段编译器会帮你在所有用的地方标红。适合刚接触 Next.js 全栈、想了解 tRPC 和 Prisma 怎么配合的人也适合想找一个真实项目模板直接改造成自己产品的中级开发者。t3code这个名字看着像随手起的实际上它代表了一整套技术选型T3 是指由 TypeScript 和 Next.js 为核心Tailwind CSS 负责样式tRPC 打通前后端 APIPrisma 操作数据库再加上 NextAuth 做鉴权。下面我会从项目拆解到初始化、核心代码实现、部署排查一步步讲清楚包括我在实操中踩过的坑和优化的细节。1. 项目整体设计与技术选型思路1.1 核心需求拆解开始写代码之前先把t3code要做的功能列清楚。我当初设想的 MVP 版本并不是又大又全而是围绕“代码片段”这个核心对象展开。最终需求定为四个方面用户系统注册、登录、退出使用 NextAuth 支持 GitHub OAuth 和邮箱密码两种方式。片段管理创建、编辑、删除代码片段每个片段包含标题、语言类型、标签、代码内容、说明等字段。展示与搜索首页展示公开片段支持按语言筛选、按标签搜索、按关键词模糊搜索。个人空间用户可以看到自己发布的片段列表也可以把别人的片段加入收藏。这里面“收藏”是个容易被忽略的点。收藏关系看起来就是多对多但实际操作上需要处理好联合唯一约束避免同一个人重复收藏同一个片段。此外公开/私有状态也需要在设计表结构时提前考虑否则后期改数据库结构会很痛。1.2 为什么选择 T3 Stack 而不是传统 REST API 方案在动手之前我其实犹豫过要不要用经典的 Next.js Express REST API 结构。后来还是选了 T3 Stack核心原因是“类型安全”带来的开发效率提升。传统的前后端分离项目前端调用接口时通常需要手写fetch然后手动定义接口返回的 TypeScript 接口。后端改了字段名前端很容易漏改运行到那一步才发现。T3 Stack 里 tRPC 直接让前端调后端函数就像调本地函数一样而 Prisma 的 client 返回类型是自动生成的等于说数据从数据库出来是什么类型到前端组件里就是什么类型中间所有层级的类型都是连通状态。类似“服务员把你的菜单直接递给后厨后厨做完菜再原路端回来中间不经过任何手写纸条”的感觉虽然不是完全自动但比之前清晰太多。更重要的是T3 Stack 是“约定大于配置”的思路。create-t3-app 脚手架已经帮你把 Next.js 项目、Tailwind 配置、Prisma client、NextAuth 集成、tRPC 初始化全部搭好。你不用再为tsconfig的路径别名、CORS 配置、eslint和 Prettier 的统一花费时间。对于一个人做小项目或者两三个人做产品原型这套组合能让精力集中在业务代码上而不是基础设施。2. 环境准备与项目初始化2.1 使用 create-t3-app 搭建脚手架这一步是最省心的环节。新版的 create-t3-app 支持交互式命令行选择需要的模块建议把 TypeScript、Tailwind、tRPC、Prisma、NextAuth 都勾上因为在前面的需求里这些都会用到。创建命令如下npm create t3-applatest t3code执行后会看到一系列选择项。我建议这样设置是否使用 TypeScript必须选是这是整个项目的地基。是否使用 Tailwind CSS选是。如果你打算用 shadcn/ui 或自己写样式Tailwind 几乎是标配。是否使用 tRPC选是。这是核心后面所有后端逻辑都会通过 tRPC 暴露。是否使用 Prisma选是。数据模型和查询全依赖 Prisma。是否使用 NextAuth选是。是否使用 Expo选否那是移动端场景。创建完成之后项目结构大概是这样的apps/ # 多应用场景下才有单应用不生成 prisma/ # 数据库 schema 和 seed 脚本 public/ src/ pages/ # Next.js 页面路由 server/ # tRPC 服务端相关代码 api/ # 数据库查询和业务逻辑 auth.ts # NextAuth 配置 db.ts # Prisma client 实例 styles/ utils/ # 工具函数和 API helper2.2 数据库选型与 Prisma 配置开发环境我直接用 SQLite。原因很简单零配置文件就在本地npx prisma migrate dev秒级完成不需要额外起服务。但是生产环境我建议换成 PostgreSQL因为 Vercel 部署时 SQLite 是临时文件系统数据会丢而且 PostgreSQL 在并发读写和备份方案上都更成熟。Prisma 的schema.prisma里最基础的内容是这样generator client { provider prisma-client-js } datasource db { provider sqlite url env(DATABASE_URL) } model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] snippets Snippet[] favorites Favorite[] }如果你想改成 PostgreSQL只需要把provider改成postgresql再把.env里的DATABASE_URL改成 PostgreSQL 连接串。这里有个容易踩的坑SQLite 对default(cuid())没有任何兼容问题但如果你有些字段用了default(dbgenerated())换库之后生成语句会完全不一样。好在这个项目不太涉及。2.3 Tailwind 配置与 UI 组件选择create-t3-app会顺手把 Tailwind 的tailwind.config.ts生成好。你不需要额外设置content路径因为脚手架已经自动扫描app或pages目录下的文件。样式方面我后来把 shadcn/ui 加进来了。它不是一个独立依赖库而是给你一堆直接复制到项目里的组件源码然后依赖 Tailwind 和 Radix UI。好处是组件可以被完全定制不会像别的 UI 库那样改样式还要打补丁。初始化命令如下npx shadcn-uilatest init如果你只要几个基础组件建议先只添加button、input、card、badge、textarea这几个。别一开始就全量安装很多的包最终用不上反而让node_modules很膨胀。3. 核心功能实现与代码拆解3.1 认证模块的接入与配置认证模块我用的是 NextAuth 的 credentials provider 和 GitHub provider 同时启用。GitHub OAuth 在社交登录上很顺滑但本地开发时总觉得在浏览器里调回调地址有点绕。后来我加上了 credentials provider配合bcrypt做密码加密这样更贴近国内开发者习惯的邮箱注册方式。next-auth 的配置集中在src/server/auth.ts。关键部分大概是下面这样import { NextAuthOptions, getServerSession } from next-auth; import GitHubProvider from next-auth/providers/github; import CredentialsProvider from next-auth/providers/credentials; import { compare } from bcryptjs; import { db } from ./db; export const authOptions: NextAuthOptions { providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), CredentialsProvider({ name: credentials, credentials: { email: { label: Email, type: email }, password: { label: Password, type: password }, }, async authorize(credentials) { const user await db.user.findUnique({ where: { email: credentials?.email }, }); if (!user?.passwordHash) return null; const valid await compare(credentials!.password, user.passwordHash); return valid ? user : null; }, }), ], session: { strategy: jwt }, callbacks: { session({ session, token }) { if (session.user) { session.user.id token.sub; } return session; }, }, }; export const getServerAuthSession () getServerSession(authOptions);大家看这段代码注意一点session回调里把token.sub赋给session.user.id非常重要。不然前端拿到的 session 里根本没有 userId后面所有需要鉴权的 API 都找不到操作者。我在这一步浪费了半小时开始在 tRPC 里session.user.id一直是 undefined排查了很久才发现是回调漏写。3.2 Prisma 数据模型设计与迁移这块是整个项目的地基。我把 Snippet、Tag、User、Favorite 四个模型设计好并用关系连接。model Snippet { id String id default(cuid()) title String content String language String description String? isPublic Boolean default(true) createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id], onDelete: Cascade) tags SnippetTag[] favorites Favorite[] } 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 Favorite { userId String snippetId String user User relation(fields: [userId], references: [id], onDelete: Cascade) snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) createdAt DateTime default(now()) id([userId, snippetId]) }这里分享一个设计心得标签和多对多关系我用了显式的SnippetTag中间表而不是 Prisma 自动生成的隐式多对多。原因是我之后想在中间表上扩展“标签创建时间”这类字段如果一开始就用隐式多对多再迁移就会比较麻烦。同理Favorite表也使用复合主键id([userId, snippetId])这直接在数据库层保证了不会重复收藏。写完 schema 后执行迁移npx prisma migrate dev --name init如果用的是 SQLite迁移已经完成。如果你发现prisma client报错说找不到模型可以顺手执行npx prisma generate3.3 tRPC 路由与服务端逻辑实现tRPC 是连接前后端的“胶水层”。你可以把它理解成一种没有GET/POST字符串路径的函数调用机制。在 T3 Stack 里所有服务端逻辑都会写在src/server/api/routers目录下然后通过根路由汇总前端的api对象才能正式使用。我先实现了最常用的 snippet router包括创建片段、列表查询、详情查询和删除。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(200), content: z.string().min(1), language: z.string().min(1), description: z.string().optional(), isPublic: z.boolean().default(true), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { const authorId ctx.session.user.id; return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, description: input.description, isPublic: input.isPublic, authorId, tags: input.tags?.length ? { create: input.tags.map((name) ({ tag: { connectOrCreate: { where: { name }, create: { name }, }, }, })), } : undefined, }, }); }), list: publicProcedure .input( z.object({ language: z.string().optional(), search: z.string().optional(), cursor: z.string().optional(), limit: z.number().min(1).max(50).default(10), }) ) .query(async ({ ctx, input }) { const snippets await ctx.db.snippet.findMany({ where: { isPublic: true, ...(input.language ? { language: input.language } : {}), ...(input.search ? { OR: [ { title: { contains: input.search } }, { description: { contains: input.search } }, { content: { contains: input.search } }, ], } : {}), }, include: { author: { select: { name: true, image: true } }, tags: true }, orderBy: { createdAt: desc }, take: input.limit 1, // 多取一条用于判断是否有下一页 }); let nextCursor: typeof input.cursor | null null; if (snippets.length input.limit) { const nextItem snippets.pop(); nextCursor nextItem!.id; } return { snippets, nextCursor }; }), detail: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { const snippet await ctx.db.snippet.findUnique({ where: { id: input.id }, include: { author: { select: { name: true, image: true } }, tags: { include: { tag: true } }, }, }); if (!snippet || !snippet.isPublic) return null; return snippet; }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const snippet await ctx.db.snippet.findUnique({ where: { id: input.id }, }); if (!snippet) throw new Error(片段不存在); if (snippet.authorId ! ctx.session.user.id) { throw new Error(只能删除自己的片段); } await ctx.db.snippet.delete({ where: { id: input.id } }); return { success: true }; }), });这段代码里隐藏了几个容易出错的地方。第一个是tags的connectOrCreate写法它是在中间表SnippetTag里创建一条带 tag 关联的关系。如果你只想让 Snippet 带上标签字符串而不是建独立 Tag 表那可以用简单数组字段但那样筛选标签就很吃力。我这里选择了更表结构的方案。第二个是光标的翻页方式。我用了take: input.limit 1这种“多取一条”做法判断是否还有下一页然后通过cursor记录最后一条的 id。这个方案比较可持续比skip偏移量在面对新增/删除数据时更稳定。第三个是关于受保护路由的权限检查。很多人只知道用protectedProcedure保护“必须登录”却忽略“只能操作自己的数据”这种归属检查。上面的delete里就显式比较了authorId不然任何已登录用户都可以传一个别人的片段 id 把它删掉。3.4 前端页面与 tRPC 消费前端消费方式在 T3 Stack 里非常直接。我们不用写任何axios或fetch调用直接用生成的 react-query hooks。比如首页获取公开片段列表import { api } from ~/utils/api; export default function HomePage() { const { data, hasNextPage, fetchNextPage, isFetching } api.snippet.list.useInfiniteQuery( { limit: 12, search: searchTerm, language: languageFilter, }, { getNextPageParam: (lastPage) lastPage.nextCursor } ); return ( div {data?.pages.flatMap((page) page.snippets.map((snippet) ( SnippetCard key{snippet.id} snippet{snippet} / )) )} {hasNextPage button onClick{() fetchNextPage()}加载更多/button} /div ); }注意这里的api.snippet.list.useInfiniteQuery名字不是手写的而是 tRPC 自动生成的。你在后端定义了一个list查询前端就自动拥有api.snippet.list.useQuery或useInfiniteQuery。这大大减少了文件之间来回切换的成本。另外创建片段的表单可以用 react-hook-form 管理提交时调api.snippet.create.useMutation。用完mutation后还需要让列表刷新这里可以utils.snippet.list.invalidate()来让旧缓存失效。简单说这是 tRPC 提供的一个内置 “重拉数据”机制省得自己写事件总线。4. 部署、测试与常见问题排查4.1 构建与部署要点生产部署我用的是 Vercel。除了代码要能构建最大的变量是数据库和环境变量。如果本地开发用的是 SQLite那么部署之前必须先切换到 PostgreSQL否则 Vercel 的 serverless 函数每次运行时文件系统可能都会被重置数据无法持久化。部署前检查清单大概如下在.env中配置DATABASE_URL为生产 PostgreSQL 连接串。配置NEXTAUTH_SECRET长度建议 32 位以上不要和开发环境共用同一个。在.env里配置NEXTAUTH_URL为 Vercel 生成的域名。如果用了 GitHub OAuth需要把回调地址正确设置为https://你的域名/api/auth/callback/github。执行npx prisma migrate deploy注意这个命令不会重新生成 client但会在生产数据库上执行迁移文件。如果需要填充初始数据可以运行npx prisma db seed。我在部署时遇到最典型的坑是本地一切正常线上却报PrismaClientInitializationError。原因是没有在vercel.json或构建流程里执行prisma generate。后来我通过 Vercel 环境变量界面添加了PRISMA_GENERATE_DATAPROXY之类的配置也没有解决问题最后在 package.json 的build脚本前加上了prisma generate next build才稳定下来。4.2 常见问题速查表为了让你少踩重复的坑我把开发过程中实际遇到的问题整理成一张表遇到对应报错可以直接照着处理。问题现象根本原因解决方式执行prisma migrate dev提示 “✘ Error: P1001”数据库连接串填错或数据库服务未启动检查.env中DATABASE_URL本地 PostgreSQL 确认端口和用户名密码登录后获取不到session.user.idNextAuth 的session回调没有把token.sub映射给前端 session在auth.ts里补上session.user.id token.sub的赋值tRPC 调用一直报UNAUTHORIZED前端使用了publicProcedure调用受保护接口或 cookie 未携带确认远程调用使用protectedProcedure对应 hook检查withCredentials相关配置修改 Prisma schema 后 tRPC 类型没变Prisma client 没有重新生成执行npx prisma generate并重启 Next.js dev server部署到 Vercel 后网络请求返回 500环境变量缺失或数据库连接串未传入函数在 Vercel 项目配置中添加全部环境变量并在构建脚本中执行prisma generate搜索中文片段返回为空SQLite 对 contains 的大小写和模糊匹配支持有限本地验证可以考虑升级 PostgreSQL 后测试也可以使用 ILIKE 类操作或全文检索库表单提交 tags 时关联失败connectOrCreate的嵌套路径写错确认tags字段的创建方式是tags.create然后内部的tag.connectOrCreate字段路径正确4.3 个人避坑经验如果让我重新写一次这个项目我会把下面几点从第一天就做好。第一字段类型和校验要提前统一。比如语言类型字段如果只是下拉框最好在前端就固定为几个枚举值后端接收时也用z.enum而不是z.string()不然将来会有各种写错的内容混进来。我在t3code里一开始用z.string()后来不得不做数据清洗和迁移很麻烦。第二tRPC 的错误处理不要裸奔。默认的 tRPC error 会抛给前端一个很长的堆栈信息生产环境可读性差。建议在createTRPCContext之后配置一个统一的errorFormatter把数据库错误和业务错误映射成有明确 code 的对象。这样前端可以根据 code 给出“已登录”、“已关联”等明确提示。第三Prisma 的中间表设计一定要多想一想。我在最初版本里让 Snippet 直接包含tags: String[]用起来很爽但后来做标签筛选和热门标签统计时发现没有独立表就很难检索。建议所有看起来会扩展关系的字段尽早拆成关联表哪怕重写一部分 CRUD 也比后期迁库轻松。第四不要把secret和api key写进代码里。t3code 的 NextAuth 配置、GitHub OAuth 的 ID 和 Secret 都放在.env文件里同时.env必须被.gitignore排除。很多人创建 Vercel 项目之后不小心把.env提交到 Git 仓库里这是非常严重的安全隐患。第五前端分页加载时不要滥用useInfiniteQuery。如果数据量不大直接useQuery加载全部或者分页加载即可。useInfiniteQuery的缓存结构比普通查询复杂很多出现页面闪烁和旧缓存污染会更难排查。我最后只在首页用了无限滚动个人中心列表就换回了普通分页。5. 扩展方向与结尾建议写到这里核心功能已经完成部署也跑通了。但代码片段管理项目其实还有很多可以扩展的地方比如把代码内容做成语法高亮编辑器把代码块一键复制做成更顺滑的交互或者加上 Follow 关注用户的功能。和 AI 相关的方向也可以考虑比如把片段的描述自动生成摘要或者根据搜索关键词给出推荐片段。这些都建立在现有数据模型之上扩展起来不会太费劲。关于t3code这个项目我自己的体会是技术上并没什么高深莫测的东西但通过把认证、数据库、API、前端页面连成一个完整链路你会对“全栈应用”的每一个环节约了什么、踩了什么坑有了更具体的感知。尤其是 tRPC 的类型安全写完一个 router 之后前端立刻能获得完整的类型提示那个体验会改变你对传统前后端接口协作方式的看法。最后分享一个我实际使用中的小习惯在本地开发时我会用npx prisma studio打开数据库的可视化管理界面边写代码边观察数据的变动。这样能很快确认操作是否落库、外键关系是否正确。如果你也准备用 T3 Stack 做一个自己的产品从这个迷你项目开始是一个比较扎实的路径。