ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

T3 Stack实战:用Next.js+tRPC+Prisma打造代码片段管理工具

T3 Stack实战:用Next.js+tRPC+Prisma打造代码片段管理工具

最近用休息时间捣鼓了一个小项目,名字就叫 t3code。说直白点,就是用 T3 Stack 这套技术组合从头写一个代码片段管理工具。T3 Stack 这几年在全栈圈子里讨论度一直很高,核心就是 Next.js、TypeScript、Tailwind CSS,加上 tRPC 做端到端类型安全的接口层,再配上 Prisma 和 NextAuth 把数据库和登录体系补齐。我实际跑完整个项目后最大的感受是:类型安全真正贯穿了前后端,写接口调用跟调用本地函数一样自然,一些印象里很繁琐的联调工作直接被省掉了。这篇文章把完整过程做一个复盘,从选型思路、环境搭建、核心功能实现到部署排坑都摊开讲,适合打算系统走一遍 T3 Stack 实战的朋友参考,也欢迎对代码片段管理工具感兴趣的人一起聊聊需求设计。

1. 项目思路与选型:为什么是 t3code,为什么是 T3 Stack

1.1 这个工具到底要解决什么问题

先说说想做 t3code 的动机。我自己平时写代码有个很实际的困扰:常用的代码片段散落在本地文件、备忘录、聊天记录和各个在线工具里,真要复用的时候到处翻。市面上的代码片段工具不是不好,而是大多偏重云端服务和团队协作,本地用要注册登录、建空间、配同步,功能堆得越多,上手成本越高。我想要的其实是一个能够私有部署、目录结构清晰、顺手支持标签和代码高亮的轻量工具。

所以 t3code 这个项目定位很明确:个人向的代码片段管理。第一版不做团队权限,不做在线编辑,不做复杂工作流,只把三件事做扎实——记录片段、组织片段、快速搜索并复现片段。范围小才敢在选型上做激进尝试,也才有机会把一个技术栈的整体体验完完整整跑一遍。

1.2 端到端类型安全比想象中更值钱

T3 Stack 最吸引我的一点是它把“类型安全”这件事从编译期一路延伸到运行时接口层。过去做前后端分离,前端每调一个接口就要去翻 API 文档,或者手写 interface;后端字段改动,前端总是最后一个知道。这个情况在 tRPC 下完全变了个样:后端定义好的查询和变更方法,前端直接引入对应 hooks,返回的数据类型自动推导出来,编辑器里报错比运行时还要快。

用生活化的例子解释:普通 REST 接口像是你给远处的人寄信,写信、邮寄、拆信都要按约定好的格式来,中间任何一环理解不一致就白忙活;tRPC 则像两个人坐在一起用同一张草稿纸改东西,你改完我直接看到最新内容,不需要二次翻译。对个人项目尤其合适,因为少了后端文档维护这件事,开发节奏明显变快了。

1.3 技术组合的分工与取舍

T3 Stack 不是一个大而全的框架,而是一套有分工的组合方案。Next.js 承担页面渲染和路由;TypeScript 提供全链路类型约束;Tailwind CSS 负责样式;tRPC 解决接口层的类型安全;Prisma 管理数据库模型和迁移;NextAuth 处理主流登录方式。这套组合不是强制绑定的,但搭配在一起很顺,主要有三点考量。

第一,Next.js 的服务端组件模型适合工具类应用,列表页和详情页可以在服务端把数据拿好,配合文件系统的路由规则,代码组织非常直观。第二,Prisma 的 schema 即文档风格,改表结构后数据库迁移、类型定义一次搞定,省掉了手写 ORM 映射的心智负担。第三,tRPC 对构建内部工具非常合适,如果后续要对外开放 API,其实也可以再在 tRPC 之外补一层 REST 或 GraphQL 接口,但那是后话。

我做选型时在一个地方犹豫过:当时 Next.js 已经有 App Router 和 Pages Router 两套模式,社区不少案例还在使用 Pages Router。考虑到 App Router 是官方持续推进的方向,而且项目本身就是练手性质,最终决定直接上 App Router,哪条新路省时间就走哪条。

2. 工程搭建与初始化:从 create-t3-app 开始

2.1 初始化命令与交互式选项

初始化一个 T3 Stack 项目并不需要手工拼装依赖。官方提供的 create-t3-app 脚手架会把整套基础工程拉起来,省下大量配置时间。我用的命令是:

npm create t3-app@latest t3code

运行之后会出现一组交互式选项,这一步值得认真看,因为选择不同,生成的项目结构差别很大。可选项包括 TypeScript 是否启用严格模式(默认开启)、Tailwind CSS、tRPC、Prisma、NextAuth、ESLint 等。做 t3code 时,我把 tRPC、Prisma、NextAuth、Tailwind 全部勾选上,确保拿到一个完整的参考基准。

初始化完直接在本地跑npm run dev,一个包含基础登录态、数据库连通示例、接口调用示例的骨架项目就起来了。这个骨架最大的价值在于给你一套已经互相打通的最佳实践:NextAuth 的 session 会注入到 tRPC 的 context,Prisma 实例挂在 context 上供每个路由调用,前端组件通过trpc.react()自动获得 query 和 mutation 的能力。后面要做的只是替换成自己的业务模型,而不是自己搭积木。

2.2 生成目录里每一块在干什么

项目初始化后,核心目录结构大概是这样:

t3code/ ├── prisma/ │ └── schema.prisma # 数据模型定义 ├── src/ │ ├── pages/api/ # NextAuth 和 tRPC 的入口 │ │ ├── auth/ # NextAuth 回调 │ │ └── trpc/ # tRPC HTTP 入口 │ ├── server/ │ │ ├── auth.ts # NextAuth 配置 │ │ ├── db.ts # Prisma Client 单例 │ │ └── api/routers/ # 业务路由定义 │ ├── trpc/ │ │ ├── server.ts # tRPC 初始化、上下文定义 │ │ └── react.tsx # 前端 hooks 封装 │ ├── app/ # App Router 页面 │ │ ├── page.tsx # 首页 │ │ ├── snippets/ # 片段相关页面 │ └── styles/ └── .env # 数据库连接、鉴权密钥

这套结构的核心思路是让前后端共享同一套类型定义。server/下的逻辑是纯 Node 环境执行,trpc/react.tsx定义的前端 hooks 则天生携带从后端推导出来的类型信息。页面组件里导入的每个 hook,参数的提示、返回的数据结构,都是在服务端路由定义里写好以后自动同步过来的,不会有任何手工维护的类型副本。

2.3 环境变量与准备工作

项目跑起来前必须把环境变量配好。t3code 里最关键的有这么几个:

环境变量用途示例值
DATABASE_URLPrisma 连接数据库的地址postgresql://...
NEXTAUTH_SECRETNextAuth 加密 token 的密钥随机字符串
NEXTAUTH_URL登录回调根地址http://localhost:3000
GITHUB_CLIENT_ID如果启用 GitHub 登录在 GitHub OAuth App 里获取
GITHUB_CLIENT_SECRETGitHub 登录密钥同上

本地开发数据库我一开始图省事用了 SQLite,DATABASE_URL写成:

DATABASE_URL="file:./db.sqlite"

这样不需要额外安装数据库服务,适合开发初期快速试错。不过后来考虑到部署环境,正式版本我换成了 PostgreSQL,下文会专门说。NEXTAUTH_SECRET可以用openssl rand -base64 32生成,别用固定字符串,尤其项目要部署到公网时,密钥泄露等于登录态可以被伪造。

注意:NEXTAUTH_URL在本地写http://localhost:3000没问题,但部署时如果漏改,登录会一直跳转到本地地址。这个坑我排了很久,后面会详聊。

3. 核心功能实现:数据库、路由与页面三层落地

3.1 数据模型设计与 Prisma Schema

t3code 最核心的数据是代码片段,我给 snippet 模型设计了这样的字段:

model Snippet { id String @id @default(cuid()) title String content String @db.Text language String tags String[] viewCount Int @default(0) isPublic Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) }

模型设计上我刻意做了减法。tags用字符串数组而不是单独的关联表,因为第一版不需要按标签做复杂的筛选统计,只要求列表页能展示标签即可。content用 Text 类型存原始代码,前端展示时才做高亮渲染,避免把渲染格式和存储内容耦合在一起。isPublic字段为将来可能的公开分享功能留了一个口子,方便后续扩展。

设计用户模型时直接用 Prisma 的next-auth官方模板:

model User { id String @id @default(cuid()) name String? email String? @unique image String? snippets Snippet[] }

定义好模型后用两条命令同步数据库并生成类型:

npx prisma db push npx prisma generate

开发初期我直接用db push省事,不生成迁移文件;等模型稳定后再切到prisma migrate dev做版本化管理。这里给我的一个教训是:模型还在频繁调整时,没必要每次改动都留下一堆 migration,先把db push用顺,逻辑稳定后一次性把正式迁移文件整理出来。

3.2 tRPC 路由拆分与 Zod 校验

tRPC 路由的组织方式直接影响到项目的可维护性。我没有把所有代码堆在一个 router 里,而是按业务域拆成多个文件,再用单个根 router 汇总:

// server/api/root.ts import { createTRPCRouter } from "~/trpc/server"; import { snippetRouter } from "./routers/snippet"; import { userRouter } from "./routers/user"; export const appRouter = createTRPCRouter({ snippet: snippetRouter, user: userRouter, });

snippet 路由里我最常用的是下面几个 procedure:

// server/api/routers/snippet.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/trpc/server"; const snippetSchema = z.object({ title: z.string().min(1, "标题不能为空").max(100), content: z.string().min(1, "代码内容不能为空"), language: z.string().min(1), tags: z.array(z.string()).optional().default([]), }); export const snippetRouter = createTRPCRouter({ create: protectedProcedure .input(snippetSchema) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, tags: input.tags, authorId: ctx.session.user.id, }, }); }), list: publicProcedure.query(async ({ ctx }) => { return ctx.db.snippet.findMany({ where: { isPublic: true }, orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); }), 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 } } }, }); if (!snippet) throw new Error("片段不存在"); return snippet; }), });

这里简单解释一下protectedProcedure的含义。tRPC 初始化时可以定义多个不同权限等级的 procedure,比如公开调用的publicProcedure和需要登录的protectedProcedure。保护逻辑发生在中间件层,请求进来先检查 session,没登录直接抛错,路由函数里就只管业务,不用再做登录判断。实现了代码全流程的类型安全与校验。zod schema 拿到的input在编译期已经是精确类型,手误传错字段编辑器立刻提示,不用等到接口返 500。

3.3 前端页面与 tRPC hooks 的配合

前端调用方式非常直白。以首页代码片段列表为例:

import { api } from "~/trpc/react"; export function SnippetList() { const { data, isLoading } = api.snippet.list.useQuery(); if (isLoading) return <div>加载中...</div>; return ( <ul> {data?.map((item) => ( <li key={item.id}> <h3>{item.title}</h3> <span>{item.language}</span> </li> ))} </ul> ); }

这背后实际上走了 React Query 的缓存体系,useQuery返回的data有着和数据库字段完全一致的结构。编写表单时 mutation 的写法是:

const createMutation = api.snippet.create.useMutation({ onSuccess: () => { // 让列表自动刷新 utils.snippet.list.invalidate(); }, });

invalidate这个操作很顺手。它等价于告诉 React Query 说“缓存失效了,重新拉一次”。在传统的 REST 架构里,提交完数据之后要手动更新列表状态、或者重新 fetch,代码零零散散写好几处;在 tRPC 的写法里一个调用就能完成。

前端区域我用到了代码高亮组件。一开始考虑过react-syntax-highlighter,实测下来在大列表页挺消耗资源的,后来换成了shiki,在服务端组件里直接生成带语法高亮的 HTML 输出,页面体积和首屏速度都有明显改善。这里我也遇到一个重要经验:不要在一页里同时高亮几百个完整代码块,最好是列表页只显示片段标题和语言标签,详情页再完整渲染高亮代码,性能差好几倍。

3.4 登录体系与用户数据的关联

t3code 的登录设计为两个层级。公开片段任何人都能看,但创建和删除操作一律要求登录。我选用的是 GitHub OAuth,配置很标准:

// server/auth.ts import { NextAuthOptions } from "next-auth"; import GitHubProvider from "next-auth/providers/github"; export const authOptions: NextAuthOptions = { providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], callbacks: { async session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }, }, };

创建 snippet 时ctx.session.user.id就能直接拿到当前登录用户。不少人会在这一步踩坑:默认 NextAuth 的 session 对象里并没有 userId,需要靠上面那个 callback 手动注入。折腾过以后我才理解,这不是框架设计缺陷,而是一个安全友好机制——框架不知道你的用户 ID 命名规则,让开发者显式声明更合理。

前端判断用户是否登录,我使用 NextAuth 的 React hooks:

import { useSession } from "next-auth/react"; const { data: session } = useSession(); if (session?.user) { // 登录态 }

4. 部署实战与常见问题排查

4.1 从 SQLite 切换到 PostgreSQL 的迁移

开发环境用 SQLite 非常舒服,但部署到正式服务器时还得换成 PostgreSQL。我先把.env里的DATABASE_URL改成生产数据库的连接地址,然后跑迁移。切换数据库时的关键点是要把之前用db push同步的模型正式生成迁移文件:

npx prisma migrate dev --name init

这一步会基于 Prisma schema 生成一个 SQL 迁移文件,提交到代码仓库后,生产环境再执行:

npx prisma migrate deploy

我用的数据源是一家提供 Postgres 托管服务的平台,连接串里包含了用户名、密码、数据库名和连接池参数。生产环境建议开启连接池,因为 Serverless 部署环境下每次请求都可能新建连接,没有连接池很容易把数据库打满。Prisma 在 URL 上支持?pgbouncer=true&connection_limit=5这类配置,具体参数要看平台文档,我在 t3code 里就把连接数限制在个位数,实测高峰期没有出现连接耗尽问题。

4.2 部署时最容易翻车的几个点

部署方式我选择的是 Vercel,因为对 Next.js 应用部署最无脑。但无脑不等于零配置,第一次部署就遇到了问题。我把排查过的典型问题整理成一个速查表,不管是上 Vercel 还是别的平台都有参考价值。

问题现象根因解决方案
登录回调跳回 localhostNEXTAUTH_URL没有更新为生产环境域名在部署平台的环境变量里改成正式域名
API 返回 401 或 session 为空NextAuth secret 和回调 URL 配置不一致确保NEXTAUTH_SECRET是固定值且所有环境一致
数据库查询失败或超时生产数据库连接串未切换或连接池未配置用生产 Postgres 的DATABASE_URL,按需加连接参数
Prisma 模型和数据库不同步忘记执行prisma migrate deploy构建流程中加上生成客户端和迁移命令
tRPC 类型报错但页面能跑后端改了代码但没有重启 dev server,或者tsc没刷新恢复类型推断最稳妥的办法是重启 dev server 并确认postinstall里有 prisma generate

这里要特别提醒两个坑。第一个是 Vercel 构建时会自动安装依赖,但不会自动执行 Prisma 的生成和迁移。我在package.json的postinstall脚本里加了一行:

{ "scripts": { "postinstall": "prisma generate" } }

这样每次部署安装依赖后,Prisma Client 类型会自动生成,不需要手动 SSH 上去执行命令。第二个坑和 NextAuth 的 secret 有关。本地开发时平台会自动生成临时密钥,但生产环境如果不在环境变量里固定NEXTAUTH_SECRET,每重新部署一次,旧的登录 session 就全部失效,用户第二天回来发现要重新登录,体验很差。

4.3 tRPC 类型不同步与开发期排错

开发过程中遇见最多的类型报错是同时改了后端 procedure 和前端调用,保存后编辑器依然用旧类型。这个问题的核心是 tRPC 的类型信息是在tsc编译过程中由服务端代码推导给前端的,不像普通 JS 那样动态生效。遇到这个情况最直接有效的操作是重启 dev server,必要时再执行一次npx prisma generate让 Prisma 客户端类型保持最新。由此带来的经验是:不要在前端文件里手动维护任何接口数据结构,一旦手工写一个字段名,你就在和自动推导的类型较劲,最后一定会出偏差。

另外排查接口异常时有个小技巧:tRPC 抛出的错误带有清晰的 code 字段,比如UNAUTHORIZED、NOT_FOUND、BAD_REQUEST。前端可以在onError回调里先打印错误对象:

api.snippet.list.useQuery(undefined, { onError: (error) => console.log(error.message, error.data?.code), });

看到 code 再对照文档,定位速度比盲猜快得多。我最开始遇到 401 时,第一反应是查 CORS、查 token,折腾半天才发现只是 context 里 session 没拿到 userId。所以提醒自己,先看看错误 code 是什么,再动手查代码,顺序反了很容易浪费时间。

5. 提升体验的细节打磨与后续扩展思路

5.1 列表页的加载体验与骨架屏

工具类应用对首屏速度非常敏感。代码片段列表如果没有加载占位,用户会接连点击,以为页面卡住。我用 Tailwind 写了一个简单的骨架屏:

export function SkeletonCard() { return ( <div className="animate-pulse rounded-lg border p-4"> <div className="h-4 w-1/3 rounded bg-gray-200" /> <div className="mt-2 h-3 w-1/4 rounded bg-gray-200" /> </div> ); }

列表页在isLoading阶段渲染一排骨架卡片,体验立刻顺滑很多。配合 React Query 默认的缓存策略,用户第二次进入页面时直接先展示旧数据,后台再悄然刷新,体感上几乎没有加载过程。

5.2 全文搜索的轻量方案

最后系统搜索一直靠数据库的where模糊匹配,对于祖师爷级别的体量来说完全够用。但如果后续片段数量上了几万条,模糊匹配就会吃力,届时有两条路线:一条是引入 Postgres 内置的全文检索能力,不额外增加基础设施;另一条是接入轻量搜索引擎。从 t3code 这种小项目出发,我个人更建议先用数据库的力量把 so 过滤做扎实,索引命中了一切都好说。真到了要上全文搜索那天,再评估引擎也不迟。

5.3 后续值得扩展的几个方向

这次做完 t3code 之后,有几个功能我是明确想继续补的。一个是将代码片段生成公开分享链接,配合isPublic字段做只读分享页;二是增加简单的访问统计和标签云,让组织维度更丰富;三是可以做成浏览器插件的形式,选中页面里的代码直接右键收藏,把存取路径变得更短。技术层面这些扩展都不难过,反而是产品边界要拿捏得好——个人工具如果功能越加越重,就偏离了当初做轻量工具的初衷,这个分寸感比写代码更值得反复思考。

写在最后的经验总结

项目收尾后回头再看,t3code 对我来说最大的收获其实不是“学会了一套新技术”,而是确实验证了一个判断:当类型系统把前后端接口层完全串起来之后,个人开发全栈应用的复杂度会有肉眼可见的下降。过去写完接口再写前端,总有一个“两边对照文档”的中间环节;在 T3 Stack 的项目里,这个环节被压缩到几乎没有。这种感觉在真正写完整项目之前很难体会,光是看文档很难理解它带来的节奏变化。

如果给我再来一次的机会,我会在生产初始化阶段就直接用 PostgreSQL,而不是从 SQLite 迁过去。虽然迁移过程不复杂,但没必要为省这一步多做一次部署前后的环境切换。另外,NextAuth 的各个回调域名配置应该在第一次部署前就想清楚,而不是等用户反馈登录不了再回头排查。

最后再分享一个小习惯:T3 项目跑起来以后,我每次在server/api/routers里加完新路由,都会立刻去前端写一个对应的调用代码,哪怕只是一个临时页面,也要让类型提示先跑通。运行时的报错能帮你发现 bug,但编译期的报错能帮你在写下去的瞬间就纠正设计方向。这个习惯保持下来,全栈开发的“顺畅感”会越来越明显。t3code 到现在还活在我的日常使用列表里,如果你也在找一套顺手的前后端一体技术方案,T3 Stack 绝对值得完整试一次。

返回列表