t3code是我今年做过最顺手的一个全栈项目代号,基础技术栈锁定T3 Stack——也就是Next.js + TypeScript + tRPC + Prisma这套组合。如果你还没听说过T3 Stack,它本质上是把类型安全贯穿前后端的一种聚合方案,解决的是“全栈开发里前后端各写各的、联调全靠猜”的痛点。t3code项目正好拿它做了完整落地:从数据库建模、接口定义、页面渲染到部署上线,整个链路都由TypeScript类型串起来,改一个字段,编译期就能把上下游全部查一遍。这篇文章想把这套方案的选型逻辑、搭建过程、排坑记录完整写出来,适合准备入坑全栈、或者已经用Next.js但想引入tRPC和Prisma的开发者参考。
1. 为什么是t3code:技术选型里的门道
1.1 T3 Stack到底解决了什么问题
静态博客、个人面板、内容管理后台,这类典型CRUD项目看起来简单,真正落地时很容易被“胶水代码”拖垮。传统开发方式下,前端要手写API地址、手动解析响应、再单独维护一份接口文档,后端则要写路由、校验参数、处理数据库模型,两边数据结构的变更经常不同步。t3code这个项目在立项时就决定不搞传统的前后端分离,而是用T3 Stack把所有层面统一到TypeScript类型系统内。
核心优势在于:一个数据模型在Prisma里定义后,tRPC可以直接从数据库模型推导出API的输入输出类型,前端页面调用API时,IDE里能直接看到返回数据的字段提示。字段改名、类型调整这些改动会在编译阶段就暴露,而不是上线后跑到线上环境才发现接口对不上。这套机制省掉了OpenAPI维护、手动类型定义、mock数据同步这些繁琐环节,让我可以集中精力写业务逻辑。
1.2 T3 Stack各组件的分工逻辑
T3 Stack不是某个框架,而是Theo Browne整理的一套推荐组合,它的核心思想是渐进式使用TypeScript,spanning从数据库到UI全链路。这4个组件各有分工:
- Next.js:负责应用框架、路由、渲染模式。App Router模式下,Server Component可以做服务端渲染,减少客户端JavaScript体积。
- TypeScript:所有类型信息的载体,是t3code里最核心的“契约层”。
- tRPC:用于构建端到端类型安全的API,不需要生成 REST 文档,直接在服务端定义procedure,客户端通过类型推断调用。
- Prisma:数据库ORM,schema即类型来源,迁移工具和类型生成器都齐全,不用手工维护SQL和类型定义。
这4个组件在t3code里的协作方式是:Prisma定义数据模型,tRPC在路由中调用Prisma查询,Next.js页面通过tRPC客户端获取数据,类型被自动推导到组件props。整个过程没有一次手动类型标注缺失,这是我个人觉得这套组合最有价值的地方。
1.3 t3code的项目定位与功能范围
为了把技术栈的每个环节都跑通,t3code设计成一个带bbs帖子管理、用户收藏、管理员权限控制的内容社区系统。麻雀虽小五脏俱全,它涵盖了:
用户注册登录(支持邮箱密码登录)、发帖、编辑、删除自己的帖子、浏览全部帖子列表、帖子详情、收藏/取消收藏帖子、管理员对全站帖子的管理操作。这些功能覆盖了典型社区产品的CRUD全流程,足够验证T3 Stack在实际业务中的开发效率。
2. 工程初始化与项目结构规划
2.1 使用create-t3-app快速初始化
t3code的初始化使用create-t3-app脚手架,一条命令就能生成带全套配置的Next.js + tRPC + Prisma项目。
npm create t3-app@latest t3code执行后需要选择是否启用NextAuth、Prisma、Tailwind、ESLint等模块,t3code开启了除NextAuth之外的所有默认选项,认证部分没有使用第三方OAuth,而是自己扩展NextAuth的Credentials Provider实现邮箱注册登录,后面会详细说明。
2.2 目录结构与分层思想
初始化完成后,项目的核心目录是这样组织的:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ └── routers/ │ │ │ ├── root.ts │ │ │ ├── post.ts │ │ │ ├── user.ts │ │ │ └── favorite.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── app/ │ │ ├── (auth)/ │ │ ├── (main)/ │ │ └── api/ │ ├── env.js │ └── trpc/ │ ├── server.ts │ ├── client.ts │ └── react.tsx这个分层非常清晰:src/server里放数据库实例、认证配置和tRPC路由,src/app放页面路由,src/trpc放tRPC服务端和客户端桥接。最关键的原则是——业务逻辑都放在路由或Server Component里,客户端组件只负责交互呈现。
2.3 环境变量与配置约定
t3code用env.js统一管理环境变量,所有变量使用zod做运行时校验。这里有个很实用的设计——DATABASE_URL、NEXTAUTH_SECRET这些关键配置,在启动前就会用zod校验格式,如果漏配或者格式错误,启动时会直接报错,而不是等运行过程中出现诡异错误。
实际项目中我强烈建议保留这个机制。传统Node应用环境变量管理靠运气,漏配一个变量可能让整个请求链路崩掉。zod校验等于在入口加了一道保险,把配置错误隔离在启动阶段。
3. 数据建模与Prisma Schema设计
3.1 从需求抽象出数据模型
t3code的数据模型围绕帖子、用户、收藏三个实体设计。交互逻辑是:用户发帖、管理员可管理所有帖子、用户可以把自己感兴趣的帖子加入收藏列表。这里关键的建模决策是收藏是否独立成表。
思考过程是:如果一个用户收藏了多个帖子,一个帖子也能被多个用户收藏,这是一个典型的多对多关系。虽然可以用隐式多对多表,但考虑到收藏时间、收藏排序这类附加属性,我选择显式定义Favorite模型,方便未来扩展。
3.2 schema.prisma 关键定义
t3code最终使用的数据模型核心部分是这样的:
model User { id String @id @default(cuid()) email String @unique name String? password String createdAt DateTime @default(now()) posts Post[] favorites Favorite[] } model Post { id String @id @default(cuid()) title String content String authorId String author User @relation(fields: [authorId], references: [id]) favorites Favorite[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Favorite { id String @id @default(cuid()) userId String postId String user User @relation(fields: [userId], references: [id]) post Post @relation(fields: [postId], references: [id]) @@unique([userId, postId]) }三个表的设计里,主键都用了cuid而不是自增数字。自增主键在分布式环境里有碰撞风险,而cuid在并发情况下可以完全避免冲突,生成不需要等待数据库返回。@@unique([userId, postId])这个复合唯一约束确保了用户不会对同一篇帖子收藏两次,代码里就不需要额外查重了。
3.3 迁移流程与常见坑
定义好schema后执行迁移:
npx prisma migrate dev --name init在t3code开发过程中遇到过几个Prisma相关的坑:
- 字段拉平:如果删掉某个字段后再迁移,Prisma默认会给出警告,需要
--accept-data-loss参数确认。开发阶段数据无所谓,生产环境迁移必须自己做备份。 - 多对多隐式表:如果不用显式Favorite表,Prisma会自动创建一张
_FavoriteToPost关联表,这张表没法直接加业务字段,后期加属性会麻烦。 - Prisma Client未更新:改了schema后必须重新执行
npx prisma generate,否则客户端代码里还是旧类型。这个问题在新增字段后很常见,API调用时IDE没有报错,运行时却报错,原因就是Client没有重新生成。
这些坑在部署到线上环境后尤其明显。我的建议是每次改完schema,第一时间跑prisma format && prisma migrate dev && prisma generate三连,形成肌肉记忆。
4. tRPC API设计与前后端类型打通
4.1 tRPC的Router结构规划
t3code的tRPC路由由三个各自独立的router组成:帖子、用户、收藏。实际开发中采用按业务域拆router的方式,而不是把所有procedure都塞进一个根router。每个路由的输入输出都通过zod校验表结构。
// src/server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ cursor: z.string().cuid().nullish(), limit: z.number().min(1).max(50).default(10), }) ) .query(async ({ ctx, input }) => { const posts = await ctx.db.post.findMany({ take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); let nextCursor: string | undefined; if (posts.length > input.limit) { const nextItem = posts.pop(); nextCursor = nextItem?.id; } return { posts, nextCursor }; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1).max(5000), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });这里有几个细节值得关注:list做的是游标分页,cursor参数为空时取第一页,返回结果中包含nextCursor,前端拿到这个值再作为下一次请求的cursor,这种分页方式比传统的offset分页更适合无限滚动场景,数据量大了不会产生深翻页问题。
4.2 protectedProcedure与权限控制
权限控制是社区类系统的刚需。t3code使用tRPC的middleware机制实现了protectedProcedure:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: ctx.session } }); });这段中间件检查session里是否有用户,没有就抛401错误,并且把session注入到下一步的context中。所有需要登录才能访问的操作,比如发帖、收藏、管理员操作,都使用protectedProcedure。通过middleware做权限控制的好处是统一收口,避免每个procedure里重复写判空逻辑。
管理员权限在t3code里通过session中的role字段判断,在procedure里先查用户表确认role为“ADMIN”。
4.3 客户端调用与类型自动派生
tRPC最核心的体验在于,服务端定义好router后,客户端不需要手动写任何API函数。t3code的页面组件可以直接通过api.post.list.useQuery()调用后端API,并且返回的数据类型完全自动派生。
"use client"; import { api } from "~/trpc/react"; export function PostList() { const { data, isLoading, fetchNextPage, hasNextPage } = api.post.list.useInfiniteQuery( { limit: 10 }, { getNextPageParam: (lastPage) => lastPage.nextCursor } ); if (isLoading) return <div>加载中...</div>; return ( <div> {data?.pages.map((page) => page.posts.map((post) => ( <div key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </div> )) )} </div> ); }这段代码不用写axios、不用写fetch、不用记URL路径,直接调用api.post.list,IDE自动提示入参格式和返回结构。写错字段名、传错类型会在编译期直接报错,比纯后端接口联调省心太多了。
5. 认证与状态管理实现细节
5.1 NextAuth配置与Credentials登录
t3code的认证方案使用的是NextAuth。其实T3 Stack默认推荐的是NextAuth,它可以和tRPC无缝集成,通过getServerSession在服务端获取用户session。
因为t3code面向邮箱密码登录,没有直接使用GitHub等第三方OAuth,而是配置了Credentials Provider。核心代码如下:
import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { compare } from "bcryptjs"; import { db } from "~/server/db"; export const authOptions = { adapter: PrismaAdapter(db), session: { strategy: "jwt" }, providers: [ Credentials({ name: "credentials", credentials: { email: { label: "邮箱", type: "email" }, password: { label: "密码", type: "password" }, }, async authorize(credentials) { const user = await db.user.findUnique({ where: { email: credentials?.email }, }); if (!user || !(await compare(credentials?.password ?? "", user.password))) { return null; } return { id: user.id, email: user.email, name: user.name, role: user.role }; }, }), ], callbacks: { jwt({ token, user }) { if (user) token.role = user.role; return token; }, session({ session, token }) { if (session.user) session.user.role = token.role; return session; }, }, };密码使用bcryptjs加密存储,注册时先用hash()生成密文再写入数据库。Cookie里面只存JWT,不含原始密码,每次请求通过getServerSession读取会话信息,逻辑比较安全。Credentials Provider还有个坑:它默认不能使用数据库session策略,session策略必须设成"jwt",否则登录后session查不到。这个如果不注意,登录接口会返回成功,但真正的请求又拿不到用户信息。
5.2 注册接口与密码加密
t3code的注册流程是单独实现的。注册成功后,直接把用户信息关联到当前session,这样用户注册完自动登录,不需要二次跳转。
import { hash } from "bcryptjs"; import { createTRPCRouter, publicProcedure } from "./trpc"; export const authRouter = createTRPCRouter({ register: publicProcedure .input( z.object({ email: z.string().email(), password: z.string().min(8), name: z.string().min(1).optional(), }) ) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.user.findUnique({ where: { email: input.email } }); if (existing) { throw new TRPCError({ code: "CONFLICT", message: "该邮箱已注册" }); } const hashedPassword = await hash(input.password, 12); const user = await ctx.db.user.create({ data: { email: input.email, password: hashedPassword, name: input.name }, }); return { ok: true }; }), });盐的成本因子设为12,在高配置服务器上耗时大约50到100毫秒,兼顾了安全和响应速度。如果设低到10,暴力破解的成本会低很多,如果设太高,用户等待时间又太长。12是大多数项目里的常用折中值。
5.3 Client Component与Server Component状态边界
t3code里使用了大量Server Component做数据预取,客户端组件只负责交互。这里有几个原则建议直接照抄进自己的项目:
- 页面头部和列表数据的初始加载放在Server Component里,避免CSR先发请求再渲染的等待。
- 表单和按钮交互必须用Client Component,否则无法绑定事件。
- Server Component里不要直接调用
useQuery,可以通过tRPC的server caller读取数据,或者用一个Client Component包一层。
// app/page.tsx import { api } from "~/trpc/server"; import { PostList } from "./_components/post-list"; export default async function HomePage() { const firstPage = await api.post.list.query({ limit: 10 }); return ( <div> <PostList initialData={firstPage} /> </div> ); }这样首屏内容由服务端直接渲染,用户看到内容的速度明显快于纯客户端SPA。后续的滚动加载和操作都在Client Component里进行。
6. 构建、部署与性能优化实践
6.1 构建配置与Prisma部署注意事项
t3code部署到Vercel时,有几个关键的构建细节:
首先,prisma依赖需要放在dependencies而不是devDependencies里,否则Vercel构建过程中无法使用Prisma CLI生成客户端。其次,需要在构建命令前加上prisma generate:
{ "scripts": { "build": "prisma generate && next build" } }没有做这步的话,线上环境会报PrismaClient is not initialized,因为新构建的机器上不存在生成的客户端代码。这个问题属于那种排查起来很费时间、但修改一行配置就能解决的典型问题。
6.2 部署到Vercel的完整流程
t3code的部署流程大概分5步:
- 在
.env.production里配置生产环境的DATABASE_URL,使用支持连接池的地址配置。 - 把项目推送到Git仓库,在Vercel导入项目。
- Vercel识别到Next.js框架,自动设置构建命令为
npm run build。 - 在项目设置里配置环境变量,包括
DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL。 - 点击部署,等待构建完成,查看Preview URL确认功能正常。
6.3 性能优化:数据库索引与缓存
t3code的帖子列表按createdAt倒序分页,Post表默认没有针对创建时间的索引。数据量小的时候无所谓,数据量到十万级后查询会变慢。
model Post { createdAt DateTime @default(now()) @index }加索引这步要在模型定义里先加上,再执行迁移。另外,t3code给帖子详情页加了revalidate = 60的ISR策略,在页面文件顶部导出常量:
export const revalidate = 60;这样详情页每60秒重新验证一次,用户访问时如果数据没变化则直接走Edge缓存,大幅降低数据库压力。对于社区类应用来说,帖子内容的更新频率不算高,在细节上做缓存能明显改善响应速度。
6.4 tRPC批量查询优化
在t3code的收藏页,需要根据favorite列表查出对应帖子。最初实现是在v-for循环里逐个调用api.post.getById,页面每次加载都要发送N个请求,浪费严重。后来用prisma.post.findMany({ where: { id: { in: ids } } })一次查询所有帖子,网络请求数从N降到1,页面加载时间从几百毫秒降到几十毫秒。这个优化思路很简单:能用数组查询就不用循环单查。
7. 常见问题与排查技巧实录
7.1 类型推导失效的几种情况
tRPC类型推导偶尔会失效,开发中我遇到过三类:
- 类型没有从server同步到client:大概率是
tsrpc编译器没有重新生成类型。在开发模式下通常没问题,但如果修改了router定义却没有热更新,重启dev server即可。 - 用
any破坏了类型链:某处用了any类型,后面的字段提示会全部变成松散状态。定位后把any换成明确类型。 useInfiniteQuery的返回结构与普通useQuery不一致:容易误以为类型错了,实际上数据在pages数组里,不是data里直接取。
7.2 Prisma迁移数据丢失与锁表
有一次我在t3code开发中删除了一个不再使用的字段,执行prisma migrate dev时被提示数据丢失。这时候不要直接加--accept-data-loss了事,应该想清楚这个字段是否在业务中还被引用。特别是线上环境,先备份数据库,再执行迁移。
如果迁移时发现连接池被占满,是因为连接数超过了数据库允许的上限,需要检查是否有未关闭的Prisma连接,或者使用连接池代理服务。
7.3 NextAuth登录回调地址错误
部署到线上后,最常踩的坑就是登录成功后跳转地址不对。这通常是因为NEXTAUTH_URL没有配置成真实部署域名。本地开发时没有配置它会自动用localhost,线上必须手动指定。
t3code还遇到过自定义回调地址的情况,如果使用了callbacks.redirect,记得确认返回值是合法路径。否则NextAuth会拒绝重定向,停在登录页循环。
7.4 排查技巧:开启tRPC日志
在开发阶段,我习惯在根路由创建时设置一个日志中间件:
import { loggerLink } from "@trpc/server"; export const t = initTRPC.context<typeof createTRPCContext>().create({ transformer: superjson, }); export const middlewareLogger = t.middleware(async ({ path, type, next }) => { const start = Date.now(); const result = await next(); console.log(`${type} ${path} ${Date.now() - start}ms`); return result; });每次请求都会打印耗时和路径,排查问题时一眼就能看出是哪个procedure慢了。这个日志中间件在TRPC_ENV=development时开启,生产环境自动关闭,不会带来额外开销。
7.5 线上环境Prisma Client报错汇总
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
PrismaClient is not initialized | 构建时未执行prisma generate | 构建前加上prisma generate |
Cannot find module @prisma/client | Prisma安装在devDependencies | 移到dependencies |
P2002 unique constraint failed | 插入重复唯一值 | catch错误并引导用户处理 |
P2025 record not found | 删除/更新对象不存在 | 检查id来源,处理空值 |
8. 项目复盘与个人体会
t3code跑完整套流程后,我最深的感受是:T3 Stack把之前分散在接口文档、类型定义、联调会里的精力,节省到了几乎可以忽略不计的程度。类型系统不再是“写了就行”的摆设,而是真正参与到每一次API设计、数据库变更和页面渲染中。它允许我专注于业务逻辑本身,这种体验在同规模的全栈项目里不多见。
当然,没有一套技术栈是银弹。T3 Stack的代价是它的“入坑成本”——如果你不熟悉Prisma的schema规范、不熟悉Next.js的App Router约定、不熟悉tRPC的procedure设计,一开始会有一段学习曲线。但一旦跑通第一个最小链路,后面再添加功能就是复制粘贴加微调。
最后分享两个小提示:如果你也想搭一个类似t3code的项目,建议先别急着加功能,先把一个最小CRUD链路(数据库 → tRPC → 页面展示)完整跑通。类型系统的“爽感”必须亲身体验一次才知道值不值;另外,多数线上问题都出在环境变量和构建步骤,先检查DATABASE_URL和prisma generate,再怀疑业务代码,排查效率会高很多。