☰
T3全栈开发实录:Next.js、tRPC与Prisma打造类型安全的代码片段管理平台
2026/10/26 8:24:35 网站建设 项目流程

1. t3code在做什么:一个代码片段管理工具背后的技术选型故事

1.1 项目形态:t3code到底是什么

t3code是我最近完整落地的一个全栈Web项目,简单说就是一个面向开发者群体的代码片段管理平台。登录之后可以创建、编辑、分类整理自己的代码片段,支持标签检索、语法高亮显示、公开分享给其他人,也能在社区里浏览别人公开的片段并一键复制。数据模型不复杂,但涉及完整的用户认证、CRUD操作、搜索过滤、权限控制,非常适合用来验证一套前端+后端的技术栈能不能撑起真实业务。

做这个项目之前,我刚经历了一段"全栈焦虑期"。选型这件事听起来简单,实际铺开就是无数个纠结:前端框架用React还是Vue,后端要不要单独起一个Node服务,API走REST还是GraphQL,数据库怎么连,类型定义怎么维持前后端一致……每一项单看都有成熟方案,但拼在一起经常出现"前端一个类型、后端另一个类型、联调的时候靠手写接口文档硬撑"的局面。t3code就是在这种背景下被逼出来的:我想要一个能从头到尾贯穿强类型的完整应用,而不是再攒一堆各管一段的孤岛技术。

1.2 为什么偏偏是这套组合

T3这个词指代一组技术:Next.js、TypeScript、Tailwind CSS、tRPC,再配上Prisma和NextAuth。其中Next.js是应用框架,TypeScript贯穿全局,Tailwind负责样式,tRPC负责API层,Prisma管数据库访问,NextAuth管认证。核心逻辑是"尽可能少地引入概念,让类型从头到尾一路贯穿"。

我用一个类比来解释这个组合的妙处:传统前后端分离就像两个人在两个房间各自记一份笔记,中间靠快递传纸条。纸条上的内容写没写清楚,全靠两个人的自觉。REST API就是这种模式,接口文档、类型定义、参数校验全靠约定,只要有一方改了字段,另一方必须人肉同步,漏一处就出Bug。而tRPC的思路是取消"两个房间"的物理隔离——前端代码可以直接"调用"后端函数,类型推断自动带着走。前端写的输入参数、后端返回的响应类型,在编译期就已经被绑死,改了什么立刻在编辑器里报错。

这个组合还有一个隐性优势:整条链只需要一种语言(TypeScript),一套依赖管理(npm),一份代码仓库。对个人开发者或者中小型团队来说,维护成本被压得很低。

1.3 这套选型适合谁、不适合谁

以我跑完t3code全流程的经验来说,这套选型最适合这三种场景:一是想做MVP快速验证的创业项目,二是内部工具类应用,三是从零学习全栈开发的个人项目。因为它省掉了大量联调沟通成本,类型安全帮你挡掉很多低级错误。

不太适合的场景也有:如果团队里有严格的后端边界,后端必须独立部署并且要对接大量存量服务,tRPC的"前后端同构"反而会成为束缚;如果项目有复杂的权限模型、事件驱动架构,或者需要对接非TypeScript生态,那REST/GraphQL的生态通用性还是更强。另外,极度追求SEO的传统内容站,Next.js虽然能SSR,但纯静态站的复杂度更低的方案也多的是。

2. 初始化与脚手架:t3code项目骨架的搭建细节

2.1 create-t3-app的选项决策

创建项目我直接用了官方脚手架 create-t3-app,命令是:

npm create t3-app@latest t3code

安装过程中会依次问你要不要接入哪些模块。这里我把我的选择和建议说说:

  • Next.js:选App Router。新版create-t3-app默认就是App Router,Server Component的模型配合tRPC其实更顺畅,后面会细说。
  • TypeScript:必选,这是整个T3的基石。
  • Tailwind CSS:必选。项目里大量代码片段展示界面,Tailwind对快速搭建UI非常有帮助。
  • tRPC:必选,这是核心。
  • Prisma:必选,数据库建模全靠它。
  • NextAuth:必选,要接登录。
  • ESLint:选上,保持代码风格。

如果你是个对每一个选项都想要"标准答案"的人,我的建议是:除了Docker(需要的话)之外,官方提供的这些选项你全选就是最均衡的配置。不要把tRPC、Prisma这些先去掉然后又手动装,绕一圈回来还是它。

2.2 目录结构与数据流约定

脚手架生成之后,核心代码集中在src目录。我的t3code项目最终落成了这样的结构:

src/ app/ # Next.js App Router 页面 api/ # NextAuth 的 API 路由 (main)/ # 需要登录后的页面组 snippets/ # 片段列表页 new/ # 新建片段页 server/ api/ # tRPC 路由器和上下文 db/ # Prisma 客户端实例 auth/ # NextAuth 配置 trpc/ server.ts # tRPC 服务端初始化 client.ts # 服务端组件用的客户端 react.tsx # React 前端 hook 封装 styles/

这个目录划分值得留意的一点是:server和trpc层尽量只放业务逻辑和协议定义,页面组件的职责是拿到数据然后渲染,不要在页面文件里直接写一堆数据库查询。数据流是"页面组件 → tRPC客户端 → 服务端路由 → Prisma → 数据库",每一层职责清晰,出了问题排查路线非常明确。

2.3 环境变量与配置管理的坑

t3code项目第一次启动时,我卡在了环境变量上。NextAuth要求NEXTAUTH_SECRET、NEXTAUTH_URL,Prisma要求DATABASE_URL,这些在.env文件里都要配好。其中最大的坑是:Prisma的DATABASE_URL必须写连接串全格式,比如:

DATABASE_URL="mysql://username:password@localhost:3306/t3code"

如果你本地用的是SQLite来做开发,连接串就是:

DATABASE_URL="file:./dev.db"

这个差异会导致一个问题:在生产环境里用PostgreSQL,本地开发却用SQLite,两个库的数据模型虽然一样,但Prisma的迁移文件在某些字段语法上会有细微差别(比如SQLite不支持枚举类型)。我的建议是:开发和生产统一用一个数据库类型,本地起一个Docker的PostgreSQL实例,省掉一堆"本地好好的,一部署就报错"的诡异问题。

还有NextAuth的NEXTAUTH_URL,本地开发建议直接配http://localhost:3000,不要省。之前有次我漏了这个变量,OAuth回调一直报callback URL mismatch,折腾了半小时才发现是环境变量被忽视了。

3. 类型安全的灵魂:tRPC路由体系设计与前后端联动

3.1 路由器拆分:从单体到模块化

tRPC的核心概念是router和procedure。一个router就是一组相关接口的集合,procedure则是具体的接口定义。项目体量小的时候,把所有的接口都塞进一个root router里没问题,但t3code随着功能增长(片段CRUD、标签查询、用户公开页、点赞收藏),我很快就把单体router拆成了多个子模块:

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

每个子router内部用createTRPCRouter包一层,定义自己的查询和变更:

// src/server/api/routers/snippet.ts export const snippetRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ tag: z.string().optional(), q: z.string().optional() })) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), ...(input.q ? { title: { contains: input.q } } : {}), }, include: { tags: true, author: { select: { name: true } } }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1) })) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id }, }); }), });

这种模块化方式的好处是:每个子模块自己管自己的输入输出定义,root router只是一个聚合出口。等片段模块越来越复杂,可以继续把snippetRouter拆成snippetTagRouter、snippetLikeRouter,不需要动任何前端代码。

3.2 中间件与受保护路由

t3code需要区分"任何人可看的片段"和"只有登录用户才能操作的数据"。tRPC提供了中间件机制,写起来很直观:

// src/server/api/trpc.ts const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: ctx.session } }); }); export const protectedProcedure = t.procedure.use(isAuthed);

这里有个细节容易被忽略:ctx.session的类型。ctx是从createTRPCContext传进来的,里面包含了NextAuth的session对象。中间件里判断了session.user存在后,后面的procedure代码里ctx.session就被收窄为"必定登录"的类型,TypeScript会强制你处理这个情况,不会出现"用了可能为null的user"这种隐患。这个设计让我在写"获取当前用户自己的片段"这类接口时非常放心:

mySnippets: protectedProcedure.query(async ({ ctx }) => { return ctx.db.snippet.findMany({ where: { authorId: ctx.session.user.id }, }); }),

3.3 React Query v5与tRPC的配合

前端调用这些接口,用的是@tanstack/react-query(React Query)v5的封装。tRPC自动帮你把query和mutation映射成React Query的hook:

const utils = api.useUtils(); const { data: snippets, isLoading } = api.snippet.list.useQuery({ tag: "react" }); const createSnippet = api.snippet.create.useMutation({ onSuccess: () => { utils.snippet.list.invalidate(); }, });

这里的核心是数据失效机制。传统开发里,你提交一个表单后要手动刷新列表,或者写一堆全局状态管理来同步。React Query的invalidate会自动把所有相关的查询标为过期并重新拉取。t3code的"新建片段后列表自动出现最新数据"就是靠这一行invalidate实现的,完全不用写额外的事件总线。

还有一点:服务端组件(RSC)里也可以直接调用tRPC客户端,这在t3code的公开页面里用到。比如首页展示热门片段列表,不需要客户端JavaScript参与,直接在Server Component里调api.snippet.list即可,渲染结果直接HTML输出,对SEO体验很友好。

3.4 类型安全带来的实际开发体验变化

很多不熟悉tRPC的人会问:这不就是把API变成了函数调用嘛,有啥了不起?我真实体验下来的区别是非常深刻的。

最直观的改变是改数据结构时的连锁反应。以前做REST接口,改一个后端字段类型,前端不一定立刻知道,等跑起来或者打开Network面板才发现。tRPC则是在编译期就把前后端拴在一起——你改了Prisma模型后重新生成类型,前端调用这个查询的地方该报错立马报错,光标指到具体那一行,直接改完收工。

第二个改变是文档需求几乎消失。tRPC的输入输出都是代码本身,类型即文档。代码审查时看到procedure签名就知道它接收什么、返回什么,不需要再去翻接口文档。这对我这种一个人维护一个项目的人来说,省下来的时间真的太可观了。

4. Prisma建模与数据迁移:t3code的数据库实战

4.1 Schema设计:用户、片段、标签的关系

t3code的数据库模型并不复杂,但设计时还是要仔细想关系。最终schema里核心是三张表:User(用户)、Snippet(片段)、Tag(标签),中间通过TagOnSnippet做多对多关联。Prisma schema写出来大概是:

model User { id String @id @default(cuid()) name String? email String? @unique image String? snippets Snippet[] likes Like[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Snippet { id String @id @default(cuid()) title String content String @db.Text language String @default("plaintext") visibility Visibility @default("public") author User @relation(fields: [authorId], references: [id]) authorId String tags TagOnSnippet[] likes Like[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique color String? snippets TagOnSnippet[] } model TagOnSnippet { snippet Snippet @relation(fields: [snippetId], references: [id]) snippetId String tag Tag @relation(fields: [tagId], references: [id]) tagId String @@id([snippetId, tagId]) } model Like { user User @relation(fields: [userId], references: [id]) userId String snippet Snippet @relation(fields: [snippetId], references: [id]) snippetId String @@id([userId, snippetId]) } enum Visibility { public private unlisted }

这里面有几个设计决定值得分享。第一,所有主键都用cuid而不是自增整数,原因很实际:公开分享场景下不想让别人通过ID猜测数据量,也不想要自增ID泄露"又是第几个注册的"。第二,多对多关联表用复合主键(@@id([snippetId, tagId])),防止同一条片段重复打同一个标签。第三,点赞表用联合唯一约束,用户不能对同一条片段点两次。

4.2 迁移流程与种子数据

Prisma的迁移流程是:

npx prisma migrate dev --name init

这条命令会对比schema和数据库状态,生成迁移SQL并执行。开发过程中改字段了,比如给Snippet加一个language字段,就是改schema然后再次跑migrate dev。这里有个实用建议:每次migrate之前先看生成的SQL,用npx prisma migrate dev --create-only先生成不执行,检查SQL没问题再npx prisma migrate dev真正执行。因为Prisma自动生成的SQL有时会把一张表重建一遍(比如改了字段类型),数据库大的话这个操作会影响线上,提前检查能避免很多事故。

种子数据我写在了prisma/seed.ts里,用npx prisma db seed跑。种子数据对t3code这种方式比较重要:没有数据,前端列表页一片空白,调试样式和交互很难受。我的种子脚本里造了10个用户、50条片段、20个标签,还随机建立了点赞关系,这样一启动开发环境就有相对真实的界面可以看。

4.3 踩过的数据库坑

t3code开发过程中我踩过的数据库相关的坑有好几个:

第一个是SQLite转PostgreSQL的字段类型问题。开发初期我用SQLite,后来换到PostgreSQL,发现schema里某些字段(比如布尔值的默认设置、Text类型的长度限制)在两种数据库下行为不一致。项目中有个字段存的是大段代码内容,SQLite的TEXT类型理论上无长度限制,PostgreSQL虽然有TEXT类型也需要显式声明@db.Text才避免走varchar的默认长度。这个问题在部署阶段才暴露,排查成本不低。

第二个坑是级联删除。删掉一个用户时,他创建的片段、点赞记录需要一并处理。Prisma里如果relation没有配置onDelete: Cascade,删除用户会报外键约束错误。我最后在User和Snippet、Like的relation上都加了onDelete: Cascade,并且删用户用了事务包裹,确保不会留下孤儿数据。

第三个坑是查询性能。t3code的片段列表页一开始直接用include把tags和author一起查出来,列表一多N+1问题就来了。Prisma的solution是使用include条件的嵌套查询,让SQL一次join取数据,配合findMany的分页参数(skip/take)控制单次返回量。实测下来,500条片段的列表分页查询响应时间从两秒多降到了一百毫秒以内,这个优化是必做的。

5. NextAuth集成实录:登录系统从零到闭环

5.1 为什么选NextAuth而不是手写

t3code需要支持邮箱密码登录、GitHub登录、Google登录三种方式。手写一套认证系统不是不行,但涉及会话管理、密码哈希、OAuth回调、token刷新、安全防护,工程量不小且容易出错。NextAuth(现在叫Auth.js)把这块抽象得很好,尤其是它跟Prisma和tRPC的配合在T3生态里属于标准配置,用起来非常顺手。

关键点是NextAuth的session策略。t3code用的是JWT会话(默认策略),服务端通过jwt回调把用户ID写进token,然后在session回调里把用户信息挂到session对象上。这样tRPC的中间件就能直接在ctx.session.user上拿到当前用户,不需要每次请求都查一遍数据库。如果你的项目需要实时同步用户资料变更,也可以改成database session策略,但代价是每个请求都要查库,性能上会有取舍。

5.2 Prisma Adapter配置细节

NextAuth接入Prisma Adapter的配置是这样的:

// src/server/auth.ts import { PrismaAdapter } from "@next-auth/prisma-adapter"; import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import Google from "next-auth/providers/google"; import Credentials from "next-auth/providers/credentials"; export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(prisma), session: { strategy: "jwt" }, providers: [ GitHub, Google, Credentials({ name: "password", credentials: { email: { label: "邮箱", type: "email" }, password: { label: "密码", type: "password" }, }, async authorize(credentials) { const user = await prisma.user.findUnique({ where: { email: credentials.email } }); if (!user) return null; const valid = await bcrypt.compare(credentials.password, user.password); if (!valid) return null; return user; }, }), ], callbacks: { session({ session, token }) { if (session.user) { session.user.id = token.sub; } return session; }, }, });

这里有个容易踩的坑:密码登录方式(Credentials provider)的用户不会自动写入数据库。OAuth登录时Adapter会自动创建用户记录,但Credentials登录只会在authorize里校验,之后session里没有user记录。我的处理是在注册接口里先手动创建User记录,authorize里再去查库验证bcrypt哈希。另外,PrismaAdapter默认期望User模型里有几个字段(emailVerified等),schema里要提前定义,否则启动时会报模型不匹配的错误。

5.3 服务端组件里的会话处理

NextAuth v5(Auth.js)的API和旧版不太一样。新版暴露了auth()函数,可以在服务端组件或路由处理函数里直接拿session:

// src/app/(main)/layout.tsx import { auth } from "~/server/auth"; import { redirect } from "next/navigation"; export default async function ProtectedLayout({ children }: { children: React.ReactNode }) { const session = await auth(); if (!session?.user) { redirect("/api/auth/signin"); } return <>{children}</>; }

这个模式在t3code里被大量使用。受保护页面只用在layout里做一次session判断,如果没登录就跳转到登录页,比在每个页面里重复判断要干净得多。配合tRPC中间层那层的UNAUTHORIZED错误,实际形成了双重保障:即使有人绕过前端直接调用API,后端的路由也会拦截。

还要提一个细节:登录后的回调地址处理。NextAuth的signIn可以指定callbackUrl,我建议在登录页把用户原本要访问的地址拼进去,这样登录完能直接回到刚才的页面,而不是每次都被丢回首页。这个小交互对用户体验的提升非常明显。

6. 部署、性能与真实体验总结

6.1 部署时的三个必修课

t3code部署到生产环境时,我处理了三个必修问题。

第一个是数据库连接串和迁移。生产库我用的PostgreSQL,部署前必须跑npx prisma migrate deploy应用所有迁移,再用npx prisma generate重新生成Client。这个顺序不能反,否则新代码里引用了新字段但数据库还没建好,一启动就500。

第二个是NEXTAUTH_SECRET。生产环境必须设置一个足够随机的secret,并且不要放在代码仓库里。我是在部署平台的env配置里单独设置的。还有NEXTAUTH_URL要改成正式域名,OAuth回调地址也必须在GitHub/Google的开发者控制台里提前配置好,否则第三方登录会一直报redirect_uri不匹配。

第三个是Build时的类型检查。Next.js build默认会做TypeScript类型检查和ESLint检查,tRPC的类型错误在build阶段就会全部暴露。这个特性帮我挡下了至少两处"改了个字段类型,前端还在用旧类型"的问题,虽然代价是构建时间变长,但非常值得。

6.2 前端性能调优的实测数据

t3code的首屏优化主要靠App Router的Server Component。公开的代码片段列表页完全在服务端渲染,用户收到的是已经拼接好的HTML,首屏TTI基本等于HTML下载时间。实测在正常网络环境下,首屏LCP大约1.2秒左右,对于一个以内容展示为主的应用这个数字已经很健康。

进一步优化的话,我对代码高亮做了动态加载处理。代码高亮库(用Shiki或者Prism)体积都不小,如果全部打进主包会让首屏变慢。我的做法是把高亮组件用next/dynamic动态引入,只有真正展示代码片段时才加载相关依赖。这个优化让主包的gzip体积减少了大约180KB,LCP又往下走了大约两三百毫秒。

6.3 跑了一段时间后的真实体会

最后说说我做了这么多工作和踩了这么多坑之后,对T3这套玩意的整体评价。

如果你问我会不会再用它做下一个项目,我的答案是:看场景。只要是TypeScript生态、业务以CRUD为主、团队规模不大、追求快速迭代的全栈应用,T3的组合拳依然是我首推的方案。类型安全带来的收益不是抽象的"代码更好维护",而是实打实减少了联调bug和线上事故。但如果你做的是极度复杂的分布式系统、需要独立的API服务给多端消费、或者团队里后端只是Java/C#/Go开发,那这套东西就不合适,别硬套。

现在T3生态也在快速迭代,Next.js的App Router越来越成熟,tRPC v11、Tailwind v4都在陆续发布。t3code这个项目本身也会继续迭代下去,下一步我计划给它加上片段分享链接的过期控制、收藏夹的文件夹分组,还有基于标签的推荐。如果你正在纠结全栈选型,或者在痛苦的REST联调中挣扎,我建议你花一个周末用T3搭个带认证和CRUD的小项目感受一下,亲身体会那种类型从数据库一路奔到按钮点击处的顺畅感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询