
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整理的一套推荐组合它的核心思想是渐进式使用TypeScriptspanning从数据库到UI全链路。这4个组件各有分工Next.js负责应用框架、路由、渲染模式。App Router模式下Server Component可以做服务端渲染减少客户端JavaScript体积。TypeScript所有类型信息的载体是t3code里最核心的“契约层”。tRPC用于构建端到端类型安全的API不需要生成 REST 文档直接在服务端定义procedure客户端通过类型推断调用。Prisma数据库ORMschema即类型来源迁移工具和类型生成器都齐全不用手工维护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-applatest 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机制实现了protectedProcedureexport 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.listIDE自动提示入参格式和返回结构。写错字段名、传错类型会在编译期直接报错比纯后端接口联调省心太多了。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.contexttypeof 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_ENVdevelopment时开启生产环境自动关闭不会带来额外开销。7.5 线上环境Prisma Client报错汇总报错信息原因解决方案PrismaClient is not initialized构建时未执行prisma generate构建前加上prisma generateCannot find module prisma/clientPrisma安装在devDependencies移到dependenciesP2002 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再怀疑业务代码排查效率会高很多。