资讯动态

T3 Stack实战:用Next.js、tRPC与Prisma构建类型安全的全栈应用

发布时间:2026/10/26 8:52:46 来源:尧图企业网站定制
1. 为什么是 T3 Stackt3code 这个名字背后的选型逻辑t3code 是我最近把业余时间几乎全砸进去的一个全栈项目定位是个人代码片段和开发笔记管理工具。名字很好拆T3 指 T3 Stack后面的 code 是我给这个项目定的核心场景——把平时散落在本地文件、聊天记录、各种临时仓库里的代码片段统一收进一个带标签、可搜索、有登录权限的地方。选这套技术组合不是因为追新而是我手头正好有这么一个真实需求与其继续在找不到自己写过代码这件事上浪费时间不如直接做一个工具顺便把 T3 Stack 这条链路完整跑一遍。这篇文章适合两类人。第一类是听过 T3 Stack 但还没动手试过的全栈开发者想看看这套组合在真实项目里到底顺不顺手第二类是已经在用 Next.js但前后端类型边界一直靠手写接口文档或复制粘贴类型定义来维持的开发者想换个更省事的思路。我会把 t3code 从初始化、建模、写接口、做登录、写页面到部署上线的完整过程摊开讲重点说清楚每一步为什么这么选、实际操作中踩了哪些坑以及哪些地方和官方文档里写的不太一样。1.1 T3 Stack 到底是什么先给还不清楚概念的读者补个底。T3 Stack 不是一门新语言也不是某个单独框架它是一套以 Next.js 为主体的全栈 TypeScript 组合方案核心成员是 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma按需搭配 NextAuth现在叫 Auth.js。这套方案由 t3.gg 社区推动核心理念概括成一句话就是typesafe by default——尽量让类型错误在编译期就暴露而不是等到线上接口返回一个结构对不上的 JSON 才知道出了问题。T3 Stack 里每个成员干的事很明确Next.js 负责页面渲染和路由TypeScript 负责全链路类型约束Prisma 负责数据库建模和查询tRPC 负责前后端接口的类型安全调用Tailwind 负责写样式NextAuth 负责登录认证。这个组合最打动我的地方在于它把前端定义一个类型、后端又手写一份同样的类型、中间再维护一份文档这种重复劳动直接干掉了。你只需要在服务端定义一次路由和输入输出结构前端拿到的是完全推导出来的类型改一处另一端跟着变。我这几年做项目的体感是大部分全栈项目的时间不是花在业务逻辑上而是花在前端不知道后端返回什么结构的沟通成本上。t3code 选 T3 Stack本质上就是想把这部分成本压到最低。1.2 create-t3-app 初始化时的选项怎么勾初始化 T3 Stack 项目很简单一条命令npm create t3-applatest命令跑起来之后会有几个交互选项项目名、包管理器、是否使用 Tailwind CSS、Prisma、tRPC、NextAuth以及选择 App Router 还是 Pages Router。我第一次看到这个界面的时候愣了一下心想这也太简洁了和以前搭建 React 项目时手动配 webpack、babel、eslint 的体验完全两个世界。t3code 的选项我是这样勾的选项我的选择理由TypeScript强制启用没有 TypeScript 就没有 T3 的意义Tailwind CSS启用标签排版、列表布局用工具类非常顺手Prisma启用我需要关系型数据建模多对多的标签关系靠它省事tRPC启用本项目的核心选型前后端类型打通NextAuth启用登录权限是刚需内部工具也要区分用户App Router选择 App Router新版默认方向Server Components 对列表页性能有帮助这里要提醒一句ESLint 选项建议直接启用。create-t3-app 内置的 ESLint 配置里有一条专门针对 TypeScript 的规则会把任何any类型标红。开发的时候觉得烦但事后回头看正是这条规则逼着我把所有接口边界都写清楚了。顺带一提初始化完成后它生成的.env里会有空的数据库地址和认证密钥不补全这些变量项目是跑不起来的。1.3 生成的目录结构里哪些文件是主角create-t3-app 生成的项目目录比大多数脚手架都要克制我列几个真正的主角文件src/app/api/trpc/[trpc]/route.tstRPC 和 Next.js 桥接的入口接收所有 API 请求并转发给 tRPC 的 appRouter 处理。src/server/api/root.ts汇总所有子路由导出唯一的 appRouter。src/server/api/trpc.tstRPC 实例和上下文context创建逻辑所在。src/server/auth.tsNextAuth 的完整配置。src/server/db.tsPrisma Client 实例里面用了全局缓存技巧防止服务端热更新时连接泄漏。prisma/schema.prisma数据库建模的唯一事实来源。src/trpc/server.ts和src/trpc/react.ts分别封装了服务端调用和客户端调用的 API 入口。我第一次看完这套结构的感觉是每个文件职责都很单一几乎没有多余的抽象层。和以前那种service 层、dao 层、controller 层层层套娃的工程相比它更接近按需求直接落代码的直觉。这个结构对我这种一个人维护全栈项目的情况非常友好因为代码量少出问题的时候定位特别快。2. 数据先行Prisma 建模与数据库表结构的设计过程2.1 先建模还是先写页面我的顺序很多个人项目开发者的习惯是先把页面画出来再倒推数据长什么样。t3code 我反过来了先花了一个晚上把数据模型定死再开始写页面。原因很简单在 T3 Stack 这条链路里数据模型是所有类型推导的源头。Prisma schema 定了什么字段数据库就是什么结构tRPC 接口的输入输出就围绕这些字段转前端的表单和列表也顺着这个结构来。如果先写页面页面改一版数据模型跟着改一版迁移文件堆成山还容易在某个步骤里漏掉一处导致类型对不上。我知道有人会觉得先写页面更有成就感但在这个技术组合里先建模的边际成本其实很低。Prisma 的迁移工具足够成熟后面加字段、改关系都是常规操作可一旦业务代码已经写了不少再回头改模型牵动的东西就多了。2.2 需求拆解t3code 需要哪些表和关系t3code 的核心需求拆下来就四件事用户能创建代码片段片段要有标题、代码内容、语言类型和备注片段可以打多个标签标签可以复用首页支持按标签筛选。数据量不大但关系是典型的多对多——一个片段可以有多个标签一个标签可以挂在多个片段上。对应到 Prisma 模型处理多对多关系有两种方案一种是 Prisma 的隐式多对多写两行snippets Tag[]就能自动生成中间表另一种是显式建一个中间模型可以放额外的业务字段。我选了显式中间表理由很实际我的场景里未来很可能需要记录这个标签是谁在什么时候加到这个片段上的中间表TagOnSnippet里可以随时补createdAt、userId这类字段而隐式关系表加不了。prisma/schema.prisma里关键的模型长这样model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] sessions Session[] snippets Snippet[] } model Snippet { id String id default(cuid()) title String code String db.Text language String default(typescript) description String? db.Text createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) tags TagOnSnippet[] } model Tag { id String id default(cuid()) name String slug String unique snippets TagOnSnippet[] } model TagOnSnippet { snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag relation(fields: [tagId], references: [id], onDelete: Cascade) snippetId String tagId String id([snippetId, tagId]) }有几个细节值得展开说。Snippet.userId和User.snippets是一对多关系我在onDelete上选了Cascade用户删账号的时候他的片段会被一并清掉对个人工具来说是合理行为。Tag.slug设了唯一约束因为标签名可能重复但 URL 里的标识必须唯一。language字段我用了字符串而非枚举因为代码语言种类太多枚举的维护成本高于收益这也是一个面向实际的取舍。2.3 迁移命令与第一次跑通模型写好之后跑迁移npx prisma migrate dev --name init这个命令做三件事把 schema 和当前数据库做 diff生成一个带时间的迁移文件把迁移应用到真实数据库重新生成 Prisma Client让 TypeScript 类型和当前 schema 完全同步。第一次跑的时候如果数据库连接串没配好会直接报错这反而是好事因为问题暴露得越早排查范围越小。迁移完成后可以用npx prisma studio打开一个本地可视化界面直接看到新表长什么样。我在这个阶段花了十几分钟把几个测试片段塞进了数据库确认标签关联表的数据能正常写入。这一步很有必要——tRPC 接口还没写之前先用 studio 验证数据层没问题等后面接口报错的时候就能把问题归因到逻辑层而不是数据层。3. tRPC 接入实录类型安全的 API 层如何一步步跑通3.1 Router、Procedure 与 Context 的关系tRPC 是 T3 Stack 里最容易让新人困惑的部分因为它的概念和 REST 风格接口差别很大。我用自己的话梳理一下Router 是接口的集合一个子路由对应一类资源比如snippetRouter管所有片段相关的接口Procedure 是单个接口分query和mutation两种query 拿数据mutation 改数据Context 是每次请求的上下文环境里面装着数据库实例和当前登录用户信息所有 procedure 都能通过ctx访问。t3code 的上下文创建逻辑在src/server/api/trpc.ts里核心代码很短export const createTRPCContext async (opts: { headers: Headers; }) { const session await getServerSession(authOptions); return { db, session, ...opts, }; };这段代码每次有请求进来先拿当前会话再把 Prisma 实例挂上去。这样后面的每个 procedure 里我直接ctx.db查库、ctx.session?.user拿当前用户不需要在每个接口里重复做认证。Procedure 又分为公共和受保护的两种。publicProcedure任何人能调protectedProcedure会先检查 session没登录直接抛错。这个区分是 tRPC 在设计上的一个小巧思把权限判断前置到 procedure 定义层而不是散落在每个接口的业务逻辑里。3.2 用 Zod 给接口加输入校验tRPC 的输入校验默认用 Zod这也是我觉得它比 REST 接口顺手的核心原因之一。t3code 里创建片段的接口大概是这样的import { z } from zod; const createSnippetSchema z.object({ title: z.string().min(1, 标题不能为空).max(80), code: z.string().min(1), language: z.string().default(typescript), description: z.string().max(500).optional(), tags: z.array(z.string()).default([]), }); export const snippetRouter createTRPCRouter({ create: protectedProcedure .input(createSnippetSchema) .mutation(async ({ ctx, input }) { const snippet await ctx.db.snippet.create({ data: { title: input.title, code: input.code, language: input.language, description: input.description, userId: ctx.session.user.id, }, }); if (input.tags.length 0) { await connectTagsToSnippet(snippet.id, input.tags); } return snippet; }), });Zod 的好处一眼就能看出来所有字段的校验规则全在一个 schema 里定义前端表单可以直接复用同一个 schema 做校验后端也不用再手写一坨if (!title) return 400之类的代码。更关键的是这个 input 类型会被自动推导到前端表单里如果传了一个不在 schema 里的字段编译直接报错根本跑不到发请求那一步。我第一次用的时候还担心过一点Zod 会不会成为性能瓶颈实测下来对 t3code 这种规模的输入对象校验开销可以忽略不计。真要处理超大入参的场景那是另一个优化话题了至少个人项目完全不用担心。3.3 前端调用的类型推导与 React Query 集成tRPC 在前端的使用方式对用过 React Query 的人来说非常熟悉它本质上就是把 React Query 的useQuery和useMutation包了一层但参数和返回值都有完整的类型推导。t3code 的片段列表页里一段典型的调用use client; import { api } from ~/trpc/react; export function SnippetList() { const { data, isLoading } api.snippet.getAll.useQuery({ tag: selectedTag, }); const utils api.useUtils(); const createSnippet api.snippet.create.useMutation({ onSuccess: async () { await utils.snippet.getAll.invalidate(); }, }); // ...渲染逻辑 }这里有几个细节我要特别说明。api.snippet.getAll.useQuery的入参{ tag: selectedTag }会被类型检查严格约束这个接口只认识tag这一个参数传多了传少了都会在编辑器里标红。返回的data也已经是服务端定义好的类型不需要手动标注泛型。onSuccess里调用invalidate会让列表数据自动重新拉取相当于一个免手写的刷新机制。tRPC 还支持在 Server Component 里直接调用接口用的是api.snippet.getAll()这种 server caller 形式能拿到和客户端完全一样的数据但不需要经过 HTTP 网络层。t3code 的首页详情页就是这么做的——首屏直接由服务端把数据渲染成 HTML 返回客户端不需要再发一次请求。不过要注意server caller 拿到的数据默认不会被缓存如果同一个数据要在多个服务端组件里用要考虑缓存策略不能无脑调用。4. 登录与权限NextAuth 接入过程中踩过的坑4.1 第三方 OAuth 还是账号密码登录方案我一开始纠结了很久。图省事的话用 NextAuth 的 CredentialsProvider 配合邮箱密码登录五分钟就能配好。但有一个隐藏的问题CredentialsProvider 登录后拿不到数据库里的 session 记录用户信息全部塞在 JWT 里这意味着我无法方便地在数据库层面查谁登录过哪个用户的 session 还有效而这正是一个带多用户的工具迟早要面对的需求。另一个问题更实际自建账号密码体系意味着要自己处理密码哈希、找回密码、验证邮件这一整套流程对个人项目来说成本太高。所以 t3code 最后用了 GitHub OAuth——用户用 GitHub 授权登录NextAuth 负责整个 OAuth 流程用户资料存到自己的 User 表里。对一个小众开发工具来说目标用户几乎都是开发者人人都有 GitHub 账号这个选择在体验上一点也不违和。src/server/auth.ts里的核心配置export const authOptions: NextAuthOptions { session: { strategy: database, }, providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID ?? , clientSecret: process.env.GITHUB_CLIENT_SECRET ?? , }), ], callbacks: { session: ({ session, user }) ({ ...session, user: { ...session.user, id: user.id, }, }), }, };4.2 Session 类型扩展是必做的一步接 NextAuth 几乎必然要踩的一个坑是类型问题。session.user默认只有 name、email、image 这些字段没有数据库里的id。但我在 tRPC 里要拿ctx.session.user.id去创建 snippet这行代码在 TypeScript 眼里就是非法的。解法是在项目里声明扩展类型import { DefaultSession } from next-auth; declare module next-auth { interface Session { user: { id: string; } DefaultSession[user]; } }这个文件路径通常放在src/types/next-auth.d.ts。别跳过这一步否则后面每个需要用户 id 的接口都会被类型错误卡住而解法就是这五六行代码。很多人觉得 NextAuth 配起来麻烦其实一多半的麻烦都来自这个类型扩展解决了就顺畅了。4.3 保护路由与实际遇到的坑页面级的权限控制我在 t3code 里用了两层。第一层是服务端组件里直接判断const session await getServerSession(authOptions); if (!session) { redirect(/login); }第二层是中间件保护用next-auth/middleware的withAuth包裹在src/middleware.ts里导出export { default } from next-auth/middleware; export const config { matcher: [/dashboard/:path*, /snippets/:path*], };这里有一个非常容易踩的坑next-auth/middleware默认只对匹配到的路径生效如果你忘了写config.matcher你会发现中间件根本没起作用受保护页面直接裸奔。而且中间件里拿到的 session 和getServerSession拿到的不是同一套体系中间件适合做粗粒度的路径拦截精细的业务权限判断还是要回到服务端组件或 tRPC 的 protectedProcedure 里做。部署阶段还有两个和认证相关的坑值得专门说。一是NEXTAUTH_SECRET必须在一开始就设置好如果不设置NextAuth 会在开发环境生成一个临时密钥每次重启就换一个表现为昨天还能登录今天全掉线。二是 OAuth 回调地址本地开发用http://localhost:3000部署到生产环境后必须去 GitHub OAuth App 设置里把生产域名加进回调地址列表否则用户点登录会卡在 redirect_uri 相关的错误上。这两个坑我都踩过都是那种搜索引擎一搜就能看到很多人问但官方文档里不容易注意到的类型。5. Tailwind 与页面渲染前端样式实现中的取舍5.1 use client 指令与组件边界的把握写完接口和权限接下来就是页面。t3code 里最该讲清楚的一个经验是use client指令的边界。Next.js App Router 下所有组件默认是服务端组件不能使用 state、事件和浏览器 API。一开始我图省事把所有功能组件都加了use client结果列表页的渲染逻辑也被拖进了客户端组件服务端渲染的优化基本没吃到。后来我把组件层拆成了两层。列表页的壳是一个服务端组件直接通过 tRPC 的 server caller 拿数据只有真正需要交互的部分——创建按钮、标签筛选下拉框、搜索输入框——才单独拆成客户端组件。这样做的效果很明显首屏 HTML 里已经包含了完整的列表内容搜索引擎和直接打开链接的访客都能立刻看到内容客户端只负责接管后续的交互。一个实用的判断标准如果你这个组件里没有任何useState、useEffect、onClick那它大概率不应该加use client。老老实实当服务端组件数据在服务端拿渲染在服务端做性能自然不会差。5.2 Tailwind 里用得最顺的功能t3code 的界面不复杂核心组件是代码列表、标签筛选、创建表单和代码展示。Tailwind 在这种场景下效率很高几个具体用法标签 chip 用rounded-full bg-blue-50 px-3 py-1 text-sm text-blue-700一行搞定不需要单独写 CSS 类。响应式布局用grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3手机一列、平板两列、桌面三列不用写媒体查询。交互状态用disabled:cursor-not-allowed disabled:opacity-50按钮的禁用态不用额外写逻辑。暗色模式用 Tailwind 的 class 策略配合next-themes在根组件挂一个 ThemeProvider所有颜色用dark:前缀的类切换主题时 Tailwind 自动切换。老实说Tailwind 的缺点我也体会到了页面写多了之后className 越来越长可读性会下降。我的处理办法是把重复出现的组合抽成 React 组件比如TagChip、Card而不是去抽成 CSS 类。这样既保留了 Tailwind 的灵活性又没让模板变得不可维护。5.3 代码高亮与第三方库的整合t3code 的核心是展示代码高亮组件绕不开。我试了几种方案最终选了 Shiki。它和 Prism 这类传统高亮库的区别在于Shiki 基于 TextMate 语法解析高亮结果是一段带 class 的 HTML不需要浏览器端做词法分析性能更好而且支持 TextMate 主题样式非常接近 VS Code 的观感。在 Next.js 里接入 Shiki 要注意包体积。Shiki 的体积不小直接 import 进服务端组件会拖慢构建。我用了next/dynamic做按需加载并且关闭了 SSRconst CodeViewer dynamic( () import(~/components/code-viewer), { ssr: false, loading: () div加载代码预览.../div }, );这一步让首页的 JS 体积下降了不少。个人项目没人给你做性能预算表但自己打开 DevTools 看到加载时间的时候这个优化值不值得做心里自然有数。6. 部署上线从本地调试到 Vercel 生产环境的完整链路6.1 环境变量整理与首次部署本地跑通之后部署到生产环境我选的是 Vercel理由很直接Next.js 项目在 Vercel 上的部署体验是最顺滑的推送 GitHub 仓库、配好环境变量、点一下部署就行不需要自己管服务器和 Nginx。t3code 需要配置的环境变量有五个变量名用途本地值Vercel 值DATABASE_URLPrisma 连接数据库本地 Postgres托管 Postgres 连接串NEXTAUTH_URL认证回调地址http://localhost:3000https://你的域名NEXTAUTH_SECRET会话加密密钥任意随机串与本地不同的随机串GITHUB_CLIENT_IDGitHub OAuth 应用 ID已创建同一个GITHUB_CLIENT_SECRETGitHub OAuth 密钥已创建同一个首次部署最常见的失败现象是页面能打开但一访问依赖登录的页面就报错或 500。九成原因是NEXTAUTH_URL和NEXTAUTH_SECRET没配对或者数据库连接串指向的是本地数据库。排查思路很简单把生产环境的错误日志打开看是认证报错还是数据库连接超时方向就清楚了。6.2 Prisma 在 Serverless 下的连接管理Vercel 的无服务器函数有一个特性每个请求都可能由一个全新启动的实例来处理用完即弃。在这种环境下如果每个请求都new PrismaClient()数据库连接数会在并发稍高的时候瞬间打满。create-t3-app 生成的src/server/db.ts已经内置了全局缓存方案import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }这段代码的作用是在开发环境的热更新中复用同一个 Prisma 实例避免每次修改代码都建立一堆数据库连接。生产环境同理函数实例被复用期间连接也复用。这个模式是社区公认的正解不要自己改成每个模块新建实例的写法。我实际还遇到过一次连接数超限的报错原因是免费数据库的并发连接上限很低而 t3code 的列表页有几个查询会同时跑。解法是把数据库换成了带连接池能力的托管服务并且在连接串上加了连接池参数让 Prisma 尽量复用连接而不是每查询都新建。这里我不点名具体服务商但你选托管 Postgres 的时候一定要确认它提供连接池否则 Serverless 环境下这个问题迟早会出现。6.3 构建阶段容易忽略的 Prisma 步骤Vercel 构建 Next.js 项目时prisma generate默认不会自动执行除非你在构建命令里显式加上。很多人的部署失败都是栽在这里本地跑得好好的一部署就报PrismaClient is not configured to run in this environment。解决办法是在 Vercel 的构建命令里配置prisma generate next build如果你在生产环境还要执行数据库迁移那就把迁移命令也加进去。不过我更建议迁移在一个独立的步骤里手动执行而不是塞进构建命令——构建随时可能回滚但数据库迁移一旦执行了回滚是件麻烦事。把代码构建和数据迁移解耦是部署流程里值得坚持的原则。除了 Prisma构建阶段的另一个教训是依赖体积。t3code 的代码高亮库被next/dynamic分包后首页构建产物明显变小。如果你发现某个第三方库让构建时间飙升先怀疑它是不是被整个打进了主包用next/dynamic拆出去通常立竿见影。7. 写在最后t3code 开发中的真实体验与后续计划7.1 三个让我改变认知的细节整个项目做下来有几点和我最初预想的不太一样。第一类型安全不是银弹。tRPC 能保证前后端数据结构一致但保证不了业务逻辑的正确性。我在 tRPC 接口里写过一次重复插入标签的逻辑类型检查全部通过但数据就是不对。类型系统解决的是接口契约错位这一类问题业务 bug 该排查还是得排查。第二create-t3-app 生成的默认配置质量很高。我一开始出于熟悉一下内部实现的心态动过几处默认代码后来发现改回原样才是对的。比如src/server/api/trpc.ts里对 session 的判断逻辑官方在异常处理上已经考虑得很周全自作主张简化反而引入了边界问题。对脚手架生成的核心代码我的建议是先不动跑通了再改。第三开发时间的分布和想象中不一样。我原以为类型安全的工程会节省大量联调时间实际也确实省了但省下来的时间基本都又花在了 UI 细节上——标签的间距、暗色下的对比度、加载状态的闪烁。类型安全解决的是前后端沟通的痛点它不会替你解决用户体验的痛点。7.2 后续计划t3code 还可以怎么扩展t3code 第一版上线后我给自己列了三个待办。一是公开分享页把某些片段设置为公开生成一个只读链接给朋友看解决想给别人展示一段代码却要先发截图的尴尬二是全文搜索目前只支持按标题和标签筛选加一个基于关键词的全文检索并不难三是从这里扩展出一套标签推荐逻辑根据已有标签推断新片段适合挂哪些标签。这三件事的共同特征是都是在现有数据模型和 tRPC 接口上做增量而不是推倒重来。这也是当初选 T3 Stack 的隐含收益——数据层、接口层、页面层的边界清晰后续加功能不需要动手术式的重构。老实说t3code 这个项目让我对一个人维护全栈项目这件事有了更具体的感知。过去那种前端写一版接口文档、后端照文档实现、出了问题两边互相猜的流程在 T3 Stack 下几乎不存在了。接口改一个字段编辑器立刻在另一个文件里告诉你哪行代码需要同步改。这种体验一旦习惯了再回到手写类型定义和接口文档的项目里会觉得效率落差非常明显。最后分享一个小技巧如果你也打算用 T3 Stack 做个人项目别在初始阶段追求完美——先跑通一条完整的链路哪怕只有一个列表页和一个创建表单。把登录、数据库、部署这些基础能力真正串联起来之后后面每加一个功能边际成本都会越来越低。这也正是 t3code 一路走过来最真实的路径从一个能用的骨架慢慢长成一个称手的工具。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑