最近一个月我把一个代号叫 t3code 的项目从零到一完整跑了一遍这是一个基于 T3 Stack 做的在线代码片段管理应用支持用户登录、代码片段增删改查、按语言/标签筛选整体形态就是一个典型的全栈 CRUD 加认证的中小型应用。写这篇文章的起因是我发现很多人在搜索 T3 Stack 相关资料时看到的全是零散的官方文档、仓库 README 和推特截图几乎没人把“选型 → 初始化 → 功能实现 → 部署 → 踩坑”这条完整链路串起来讲一遍。这次我正好完整走了一遍从脚手架初始化、Prisma 数据建模、tRPC 类型安全接口到 Vercel Serverless 部署中间踩了不少坑也拿到了比较真实的体感数据值得整理出来给后来人参考。T3 Stack 是什么简明说它是一套以 TypeScript 为核心的全栈开发组合当前公认的默认成员是 Next.jsWeb 框架、TypeScript类型系统、tRPC端到端类型安全 API、PrismaORM和 Tailwind CSS样式方案。T3 这个名字来自创作者 Theo原本特指 TypeScript、Tailwind、tRPC 这三个 T后来社区把 Next.js 和 Prisma 也纳入默认阵容。它要解决的核心痛点是在不引入重型后端框架的前提下让前端到数据库的类型安全做到端到端贯通。这篇文章适合这几类读者已经在用或准备用 Next.js 做全栈项目的人对 tRPC 工作原理好奇但没时间翻源码的人以及想在业务项目里提高开发效率、不想维护一堆重复类型定义的人。下面我按实际操作顺序展开尽量把每个选择的“为什么”讲透。1. 为什么最终选了 T3 Stack而不是自己拼一套技术栈1.1 从“技术选型焦虑”说起做全栈项目最耗神的往往不是写代码而是选型。我刚开始规划 t3code 时面前至少有三条路Next.js 自己拼后端API Routes 加 REST 接口、Next.js 加 GraphQLApollo 或 urql、还有社区热度很高的 T3 Stack。自己拼 REST 的优势是几乎没有学习成本但问题也很明显——前后端类型定义需要手写同步后端加一个字段前端要是忘了更新类型运行时就等着报错。GraphQL 方案类型安全确实好但为了一个中小型 CRUD 应用引入 GraphQL 的 schema 设计、缓存策略、codegen 流程总觉得弹药配不上靶子。后来我看到了 T3 Stack 的核心主张简单性优先渐进式增强。它不是要取代 REST 或 GraphQL而是想让你在“单体全栈应用”这个场景里用最直接的方式获得端到端类型安全。t3code 本质上就是一个需要登录、需要数据库、需要 CRUD 的典型全栈应用恰好落在 T3 Stack 最擅长的区间。所以我决定不再纠结直接选它。1.2 T3 Stack 到底解决了什么问题要理解 T3 Stack 的价值先要理解它解决的实际痛点。传统前后端分离架构里前端和后端之间隔着一层 API 文档或手写的类型声明。接口定义在 Express 里前端通过 fetch 调用响应数据是 any 类型接口一改前端往往要在运行时才发现问题。这不是代码能力问题而是架构本身存在信息断层。T3 Stack 的核心组件 tRPC 想做的是把这个断层消掉。tRPC 的思路很直接后端定义一个 router 和若干 procedure类似接口前端通过类型安全的 client 调用TypeScript 会自动把输入和输出类型从后端推送到前端。你可以把它理解为“远程调用函数”而不是“请求一个 URL 接口”。类比一下REST 像是去柜台办事需要填单子、拿号、等叫号tRPC 像是直接给业务员发微信说“帮我查一下订单”如果消息格式错了微信对话框里就直接标红而不是等你跑一趟柜台才知道填错了。这个“调用函数而不是拼接 URL”的开发体感用过一次就很难回去。另一个重要组件是 Prisma。它负责数据库建模和访问schema 文件里定义一个模型迁移脚本和类型定义自动生成数据库表结构变成了 TypeScript 类型的一部分。后端代码里访问数据库时的查询结果、关联关系、字段类型全部有类型提示再加上 tRPC 把数据从服务端传到客户端整条链路都是同一套类型系统在兜底。这就是 T3 Stack 说的“端到端类型安全”对中型业务工程的生产力提升非常明显。1.3 它不适合什么场景边界条件但它不是万能的。t3code 做完之后我也更清楚地看到了它的边界。首先是多客户端场景如果你的服务不仅给 Web 前端调用还要给 iOS、Android、第三方合作伙伴开放tRPC 的 HTTP 端点虽然有标准 JSON 输出但它的主要设计目标从来不是做公开 API 网关。这种情况更适合 REST 或 GraphQL因为有成熟的 API 文档体系、SDK 生成和权限管理生态。其次是大型公共服务场景如果接口数量上千、需要严格的版本管理v1/v2、需要给外部开发者提供稳定的契约tRPC 的自动类型推导反而可能显得太“透明”版本演进策略需要你自己设计。T3 Stack 官方文档里也反复强调“渐进式”和“谨慎推进”它适合多数应用型项目但不适合重生态的开放平台。最后是团队能力门槛T3 Stack 的 TypeScript 泛型使用频率很高如果团队成员 TypeScript 基础较弱遇到报错时容易卡住。这也提醒我选型不能光看技术上限还得看团队下限。t3code 是我个人项目这个门槛不存在但如果团队平均水平在“会写接口但泛型不熟”需要先评估培训成本。2. 初始化 t3code 项目的完整过程2.1 用 create-t3-app 脚手架落地项目T3 Stack 官方提供了脚手架 create-t3-app一条命令就能生成一个结构完整的项目。t3code 初始化时我跑的是pnpm create t3-applatest t3code执行之后会有交互式选项问你项目需要哪些模块。脚手架默认的可选项包括Next.js、TypeScript、Tailwind CSS、Prisma、tRPC、NextAuth。这几个模块对应关系很明确Next.js负责页面渲染和路由TypeScript负责全链路的类型系统Tailwind CSS负责样式不依赖传统 CSS 文件Prisma负责数据库建模和访问tRPC负责前后端类型安全的 API 调用NextAuth负责登录认证我在项目中全部勾选了。第一次用脚手架的人很容易在这里犹豫要不要全部勾选我的建议是如果没有特殊原因尽量全选。原因是在 T3 Stack 设计里这几个模块的整合度很高后续如果你手动加 NextAuth 或 Prisma要处理的配置细节更多。脚手架生成好之后项目名 t3code 会直接出现在代码和 package.json 里后面写代码就都用这个名字了。2.2 目录结构和核心文件说明初始化完成后src 目录结构大致是src/ env/ env.mjs pages/ api/ auth/[...nextauth].ts trpc/[trpc].ts _app.tsx index.tsx server/ auth.ts db.ts api/ routers/ post.ts root.ts trpc.ts styles/ globals.css utils/ api.ts我第一次看到这个结构时最困惑的是 server 目录和 pages 目录的分工。实际操作后才理解pages 下是 Next.js 的页面路由和 API 端点其中api/trpc/[trpc].ts是 tRPC 的统一 HTTP 入口所有 tRPC 请求都走这个端点api/auth/[...nextauth].ts是 NextAuth 的认证入口。server 目录则存放服务端逻辑包括数据库实例、认证配置、tRPC 的 router 定义和上下文创建。env.mjs是环境变量的校验文件启动时会检查必需的变量是否存在。这里我学到的一个重要原则是tRPC router 的拆分不必一步到位但建议按业务域分文件。脚手架默认生成了一个routers/post.ts示例我改成routers/snippet.ts承载与代码片段相关的所有接口。每个业务域一个 router 文件在根 router 里用mergeRouters组合后续维护时找代码很快。2.3 环境变量与数据库连接配置T3 Stack 对环境变量非常严格。t3code 必需的环境变量至少包括DATABASE_URLmysql://user:passwordlocalhost:3306/t3code NEXTAUTH_SECRETyour_generated_secret NEXTAUTH_URLhttp://localhost:3000NEXTAUTH_SECRET可以用openssl rand -base64 32生成不要用弱密钥。DATABASE_URL我选的是 MySQL也可以用 SQLite 或 PostgreSQL看部署环境。这里有个细节容易踩坑Prisma 的连接 URL 中如果密码包含特殊字符需要 URL 编码否则连接会报错。我开发环境密码里正好有个第一次启动一直连接失败后来对做了%40转义就好了。环境变量文件是.env注意它默认不会提交到 git但env.mjs会强制读取并校验格式。启动时如果缺少变量Next.js 会直接抛错而不是运行时慢慢崩。这个设计挺好避免上线后才发现某个环境变量没配。3. 核心功能实现认证、数据模型与 API 的端到端链路3.1 用 Prisma 设计数据模型t3code 的核心业务是“用户管理自己的代码片段”所以数据模型最少需要两张表用户表User和代码片段表Snippet。实际项目里我加了标签Tag和多对多关联但最核心的还是 snippet 表。用 Prisma schema 定义如下model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? snippets Snippet[] accounts Account[] sessions Session[] } model Snippet { id String id default(cuid()) title String content String db.Text language String createdAt DateTime default(now()) updatedAt DateTime updatedAt authorId String author User relation(fields: [authorId], references: [id]) }选cuid()而不是自增整数是为了避免在 URL 里暴露业务量。字符串主键在分布式部署和迁移时也更安全代价是索引略大但对这种规模的应用完全不是问题。db.Text让 content 字段可以存长内容updatedAt会自动在每次更新时刷新时间戳不用手写更新逻辑。定义完 schema 之后执行pnpm prisma migrate dev --name init它会在数据库建表同时生成 TypeScript 客户端类型。此时你会看到src/server/db.ts已经导出了一个 PrismaClient 实例。后端在查询 snippet 时能自动拿到author.email这类关联字段的类型提示字段拼错会直接编译报错不再等运行时。3.2 用 tRPC 写类型安全的接口T3 Stack 的 API 层不是用 URL 定义接口而是用 tRPC 的 router 和 procedure。t3code 里我写了一个 snippet router核心接口长这样import { z } from zod; import { protectedProcedure, publicProcedure, router } from ../trpc; export const snippetRouter router({ list: protectedProcedure .input(z.object({ language: z.string().optional() }).optional()) .query(async ({ ctx }) { return ctx.db.snippet.findMany({ where: { authorId: ctx.session.user.id }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().min(1), }) ) .mutation(async ({ ctx, input }) { return ctx.db.snippet.create({ data: { ...input, authorId: ctx.session.user.id }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { await ctx.db.snippet.deleteMany({ where: { id: input.id, authorId: ctx.session.user.id }, }); return { success: true }; }), });这里有几个关键信息。procedure 分publicProcedure和protectedProcedure这个保护逻辑在server/api/trpc.ts里定义通过判断ctx.session是否存在来决定是否放行。zod负责输入校验前端调用时传的参数如果不符合 schema请求会在后端入口直接拦截根本不进入业务代码。这等于把“参数校验”和“接口定义”合并成了一件事我后来在联调时遇到前端传错字段格式错误信息会精确到哪个字段为什么失败。deleteMany里带authorId条件的写法是一个安全细节。如果只按 id 删除理论上用户可以越权删除别人的 snippet加上 authorId 等于把资源归属查询和删除动作合在一起这是我在实际项目中养成的习惯。tRPC 本身不限制你写任何业务逻辑它只是传输层安全边界还是要自己在业务里把好关。3.3 用 NextAuth 接入登录认证认证部分我用的是 NextAuth Credentials 提供者简单起见允许用户通过邮箱和密码登录。NextAuth 在 T3 Stack 里的集成方式比较顺滑脚手架已经把[...nextauth].ts和server/auth.ts都搭好了你只需要在authOptions里配置 provider 和相关回调import Credentials from next-auth/providers/credentials; import { db } from ./db; import bcrypt from bcryptjs; export const authOptions { providers: [ 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 || !user.passwordHash) return null; const valid await bcrypt.compare(credentials!.password, user.passwordHash); return valid ? user : null; }, }), ], session: { strategy: jwt }, callbacks: { jwt({ token, user }) { if (user) token.id user.id; return token; }, session({ session, token }) { if (session.user) session.user.id token.id as string; return session; }, }, };用 Credentials provider 时有个坑默认 session 策略不能是 database必须改成 jwt因为 Credentials provider 没有数据库存储 session 的机制。如果忘了设置登录后每次刷新页面 session 都会失效排查起来很隐蔽。另外我在 User 模型里加了passwordHash字段这个字段是脚手架默认模型里没有的需要在 Prisma schema 里补上再迁移。密码加密我用 bcryptjs没有用 bcrypt 原生模块因为 Serverless 环境下原生 Node 模块的安装和兼容性麻烦一些纯 JS 实现的 bcryptjs 虽然略慢一点但对个人项目来说省心很多。前端要调用当前登录用户的信息可以用 React Hook 的 api.auth.useSession()。T3 Stack 的好处是这些 hook 也是类型安全的不需要手写类型断言。3.4 前端组件如何调用 tRPCtRPC 的前端调用体验非常接近直接调用本地函数。比如 snippet 列表页的查询const { data, isLoading, refetch } api.snippet.list.useQuery();创建新片段时const createSnippet api.snippet.create.useMutation({ onSuccess: () { refetch(); }, });这里最值得说的是useMutation的onSuccess回调用法。初次使用 tRPC 的人容易在 mutation 成功后手动改本地状态却忘记服务端数据已经变了。我在 t3code 里统一采用“mutation 成功后 refetch 对应查询”的策略虽然多一次请求但数据一致性最好。对于 snippet 列表这种数据量不大的场景完全够用。如果将来数据量大还可以用 tRPC 的setData做乐观更新但那会引入更多复杂逻辑不是首选。前端调用时会遇到一个“类型是从哪来”的疑问api对象是从utils/api.ts里导出的它包含了根 router 的所有类型信息。这里的原理是createTRPCNext创建了一个被 React Hook 包装的 tRPC clientRouter 类型会通过泛型层层传递。你不需要手动写任何请求函数或 URL类型推导自动帮你对齐了前后端契约。4. 部署上线过程中的关键配置4.1 基于 Vercel 的部署流程t3code 部署我选了 Vercel配合 Prisma 使用的数据库是 PlanetScaleMySQL 兼容。T3 Stack 对 Vercel 支持很好因为 Next.js 本来就是 Vercel 家的产品tRPC 和 NextAuth 在 Serverless 函数里都能正常工作。部署流程本身不复杂代码推到 GitHubVercel 导入仓库设置环境变量push 后自动构建。但有几个点值得特别检查。第一是NEXTAUTH_URL必须改成线上域名否则 NextAuth 生成的回调地址会一直指向 localhost。第二是.env里的DATABASE_URL要换成线上数据库连接串且务必区分开发和生产环境。第三是 Vercel 构建时默认会执行 Prisma generate但不会自动执行数据库迁移所以线上数据库的表结构需要先准备好。4.2 数据库迁移与线上环境变量管理线上数据库的表结构怎么同步我踩过一次坑后总结出固定流程。本地开发用prisma migrate dev生成迁移文件这些文件是 SQL 脚本会记录在prisma/migrations目录下。代码推上去之后在发布前手动执行prisma migrate deploy它只会执行尚未应用到线上数据库的迁移记录比直接db push安全得多。在 Vercel 上部署时可以为项目添加一个“preview”环境变量组和“production”环境变量组DATABASE_URL这类变量可以在两套环境里分别配置。开发、预览、生产三套环境的数据要严格隔离否则很容易出现线上环境连了开发数据库的尴尬情况。我 t3code 上线第一天就犯过数据环境混用的低级错误后来老老实实每个环境配独立数据库。4.3 性能优化几个容易忽略的点T3 Stack 项目本身默认启用了 Next.js 的许多优化比如自动静态优化、字体优化、图片优化但还有几个容易被忽略的细节。一个是 tRPC 查询的缓存行为。tRPC 的 React Query 集成默认有 staleTime 为 0意味着每次挂载组件都会重新请求。对于变化不频繁的数据比如代码片段的语言列表、标签列表可以显式设置staleTime: 60_000减少不必要的服务端请求。这个优化对 Serverless 环境很友好因为每次请求都会触发函数冷启动计算费用。另一个是 Prisma 在 Serverless 环境下的连接管理这部分踩坑比较深我在下一节单独讲。还有一个小细节Next.js 的next.config.mjs里可以配置images.unoptimized或者使用 Vercel 提供的图片优化服务。如果项目里有外部图片建议用next/image组件而不是原生 img它能自动做尺寸优化和 CDN 缓存。部署后我顺手做了一次压测用 k6 并发 50 个用户连续请求 snippet 列表接口P95 响应时间大约在 320ms 左右其中大部分时间花在数据库连接和查询上。这个数据对个人项目来说完全可以接受但如果并发再上一个量级就需要考虑数据库连接池和缓存策略了。5. 踩过的坑与排查思路5.1 Prisma 客户端在 Serverless 环境下的连接数问题这是整个项目里我花了最多时间排查的问题。现象是本地开发一切正常部署到 Vercel 后跑几个小时后开始出现PrismaClientInitializationError日志里的核心信息是“connection pool timeout”或“Too many connections”。排查链路是这样的我先确认数据库侧没有异常PlanetScale 的控制台里活跃连接数确实在涨但没爆。接着怀疑是代码里创建了太多 PrismaClient 实例。T3 Stack 脚手架的server/db.ts里其实已经做了全局单例处理import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;本地开发因为有热更新如果不做这个处理会不断生成新的 PrismaClient 实例。Vercel 的 Serverless 函数是冷热交替的每个函数实例都可能创建新的连接而 PrismaClient 默认连接池行为在不释放时会堆积。最终我改成了在prisma初始化时显式限制连接数并让 Prisma 连接到 PlanetScale 时走 Serverless 驱动export const db new PrismaClient({ adapter: new PrismaPlanetScale(), });如果你用的数据库是普通 MySQL 或 PostgreSQL建议查看 T3 Stack 社区关于 Serverless 适配器的方案。核心思路是不要让 PrismaClient 走传统 TCP 长连接池而要走 HTTP 或 Serverless 友好的连接模式让每个请求用完即断、复用外部连接池。5.2 tRPC 的泛型类型报错处理T3 Stack 的类型安全很强但代价是泛型报错信息有时非常难懂。我遇到最多的一类错误是 mutation 的input类型和前端调用传参不一致。比如在 snippet create 里我最初给language字段定义了z.enum([javascript, typescript, python, go])但前端 select 组件允许用户自定义输入新语言结果调用时后端抛Invalid input: Expected javascript | typescript | python | go。这类问题的排查思路很简单先看错误抛在哪一层。如果后端 z 校验报错说明调用方的参数类型不对如果前端 TS 编译报错说明定义处或调用处的类型不匹配。我的经验是前端反序列化时不要把 tRPC 返回的数据随手转成 any老老实实让 React Query 的泛型推断工作报错信息会准确很多。5.3 NextAuth 回调地址配置错误的排查过程这个坑说来十分典型。现象是本地登录成功后跳转一切正常但部署到 Vercel 后点击登录跳转到 NextAuth 的 signin 页面登录成功后页面报redirect_uri mismatch或直接 404。排查时我先打开浏览器开发者工具看 Network 里/api/auth/callback/credentials的请求地址。发现请求的 Host 是线上域名但NEXTAUTH_URL环境变量仍然是http://localhost:3000。此时 NextAuth 会生成基于 localhost 的回调 URL线上接到后自然校验失败。解决方式就是上文说的在 Vercel 生产环境变量里把NEXTAUTH_URL改成线上地址然后重新部署。这个坑给了我一个启示T3 Stack 启动时如果环境变量缺失会立刻报错但NEXTAUTH_URL这种变量即使填错了也不会立刻看出来往往在登录链路才暴露。所以上线前最好把认证链路完整走一遍包括登录、刷新页面、退出登录、浏览器直接访问受保护页面这几个动作不要只看首页能打开就以为部署成功了。5.4 数据越权与保护接口的通用检查清单除了上面几个具体问题我还在 t3code 上线前梳理了一个安全检查清单。T3 Stack 让你开发效率很高但也容易让人忽略权限边界所有查询列表接口必须按authorId过滤不能只查全表再前端过滤所有更新/删除 mutation必须在 where 条件里带上资源归属者 IDprotectedProcedure 和 publicProcedure 要区分清楚不能因为图省事全部用 publicNextAuth 的 session 里只放最小必要信息不要塞敏感字段环境变量里的密钥不要提交到 gitenv.example只写占位符这些检查项我每一条都在 t3code 代码里对应找过一遍。个人项目可能没有团队 code review 流程自己上线前一定要做这一轮检查否则等到有真实用户使用了才发现越权漏洞代价就高了。6. 个人体会这套栈值不值得长期用文章写到这里t3code 的完整链路已经摆出来了最后聊一点主观感受。我做完这个项目后对 T3 Stack 的评价是它非常适合“一个人或一个全栈小团队快速构建应用型产品”开发体验确实爽类型安全的收益是实打实的——我整个开发过程中因为改字段导致的前后端不一致 bug 几乎为零这在以前用 Express 加 fetch 的项目里是不可想象的。它的代价是需要熟悉 tRPC 的调用方式和 Prisma 的 schema 语法初次上手大概需要一两天适应期但一旦跨过这个学习曲线日常开发的效率提升非常明显。如果让我给后来者一个建议我会说不要被“类型安全”四个字吓到把它当成一个帮你省心的工具就好。T3 Stack 的设计哲学是“约定优于配置”脚手架帮你把整合工作做完了你只需要填业务代码。真正要花心思的是数据库模型设计和权限边界这两块在任何技术栈里都是核心。 t3code 这个项目现在还在我本地仓库里跑着后续我打算加一个代码片段分享功能顺便把标签系统完善一下。如果有朋友也在用 T3 Stack 做类似的项目欢迎一起交流踩坑经验。