T3 Stack实战:用Next.js、Prisma、tRPC构建类型安全全栈应用
t3code 这个名字我盯了很久,前阵子终于抽时间把它从想法落到了代码上。简单说,它是我用 T3 Stack 这一整套现代全栈方案,从零搭建并跑通的一个实战项目,核心栈包括 Next.js、TypeScript、Tailwind CSS、Prisma、tRPC 和 NextAuth.js。整套组合突出的就一件事:端到端类型安全,前后端共享一套类型定义,改一个字段,编辑器里所有引用点立刻给你标红,再也不用靠猜字段名过日子。如果你正处在“想做个完整全栈项目但又厌烦 API 层繁琐重复”的阶段,这篇内容值得你花十分钟读完,我会把从初始化、数据建模、接口设计到部署落地的完整过程,包括那些官方文档不会写的坑,一次性讲清楚。
1. 整体设计与思路拆解
在真正敲键盘之前,我更想先聊聊为什么偏偏是这套组合。很多人一提到全栈项目,第一反应是 React + Express + MySQL 这种老三样,接口自己写、联调用 Postman、类型定义靠人肉同步。这种模式不是不能用,而是当项目规模过了三五个人、十几个接口的阶段,前后端类型断裂带来的心智负担会成倍上升。t3code 选择 T3 Stack 的原因也正在这里,它把类型安全从设计层面贯穿到了数据访问、接口调用和页面渲染的每一个环节。
1.1 t3code 到底解决什么问题
t3code 本身不是一个所谓的“新框架”,而是把我日常项目里最高频的一类业务场景——包含用户登录、结构化数据管理、列表筛选和实时更新的典型 Web 应用——用 T3 Stack 重新实现了一遍。它的核心诉求是:我能不能在尽量不写重复代码的前提下,让前端直接获得后端接口的完整类型提示?答案是能,而且体验非常顺滑。
传统 REST 方案里,后端写完接口,前端要拿着接口文档或者 Postman 的导出结果去复制类型定义。一旦后端把某个字段从status改成state,前端编译期完全看不出来,直到线上报错才被发现。t3code 用 tRPC 这件事把“远程调用”变成了“导入一个函数”,前端直接调用后端定义好的 procedure,参数和返回值类型全部自动推导。数据库里的字段经过 Prisma 成了 TypeScript 类型,再经过 tRPC 传到前端,全程没有任何手工维护的类型或接口描述文件。
1.2 为什么选择 T3 Stack 而不是其他方案
选型时我也想过要不要用 NestJS 这类更重型的后端框架,或者直接用 GraphQL。后来都否掉了。NestJS 功能强,但对我来说接入成本和配置复杂度都太高,一个待办事项级别的小项目根本用不着依赖注入、守卫、管道这套体系;GraphQL 的 schema 定义、resolver、缓存和客户端代码生成也是一条陡峭的学习曲线。
T3 Stack 里最打动我的是它的“约定式”结构。create-t3-app脚手架生成的项目目录非常干净,app目录交给 Next.js,prisma目录管数据库,server目录集中放 tRPC 和鉴权逻辑,src/trpc处理调用链路。每个文件落在哪、职责是什么,基本一眼就看明白,不需要花时间建立复杂的架构认知。等于把过去需要架构师拍板的问题,用一套社区验证过的默认值替你解决了。
这类组合还有一个特别实际的好处:渐进式学习。你不必一开始就精通所有六项技术。我最初只把 Next.js 当作 React 框架用,后面才一步步接入 Prisma、tRPC。T3 Stack 的各个模块边界清晰,拆开用也不影响,想加进来的时候再打开对应开关就行。这种渐进的平滑感,对新手或者想把手头项目慢慢改造的团队来说,友好度远高于一次到位的大换血方案。
1.3 t3code 的项目模块规划
动手之前,我先在脑子里给 t3code 画好了模块边界。整个项目被我拆成四个核心部分:认证与用户模块、数据模型层、业务 API 层、前端展示层。认证交给 NextAuth.js,它和 tRPC 的上下文机制天然能配合,登录用户的信息可以直接塞进每个 API 请求的上下文中;数据模型层用 Prisma 定义,包括用户表和任务表,外键关系也在这里体现;业务 API 层则通过 tRPC router 暴露给前端,比如listTasks、createTask、toggleTask这几个 procedure;前端页面按 Next.js App Router 的约定组织,服务端组件负责初始数据的读取,客户端组件负责交互和实时更新。
这种模块划分的优点是,每个部分都可以单独替换。未来如果不想用 NextAuth 了,只需要修改server/auth.ts这一个文件,然后把 tRPC 上下文里的 session 来源换掉,前端一行都不用动。同样,如果想把 Prisma 换成 Drizzle,只要保持对外暴露的数据方法签名不变,上层的 tRPC 和前端依然能正常工作。模块之间的松耦合,是我在规划阶段刻意追求的目标,事实证明后面调试起来确实省了不少心。
2. 核心细节解析与实操要点
规划完架构,就要把方案落实到具体代码里。这一节我用 t3code 里比较关键的几个环节做例子,把每一步的操作细节和背后的为什么讲清楚。
2.1 初始化脚手架的关键选择
T3 Stack 提供了官方的脚手架命令行工具,用npx create-t3-app@latest就可以启动初始化流程。t3code 建立时,我遇到一排交互问题,其中几个选择直接决定了项目形态。
比较关键的是“App Router 还是 Pages Router”。我选了 App Router,因为它对应 Next.js 未来主推的方向,服务端组件默认减少了不少客户端 JavaScript 的加载量;而且在 RSC 模式下,页面首屏可以直接在服务端调用数据库和 tRPC,用户看到的 HTML 就已经包含完整数据,体验比传统客户端渲染好一截。代价是调试时要注意哪些地方能跑客户端 hook、哪些地方不行,后面我会把这个问题展开细说。
另一个选择是包管理器。脚手架会询问 pnpm、npm 和 yarn,我推荐 pnpm。它的硬链接机制在存储多个项目时优势明显,安装速度也快,至少在 t3code 里安装依赖的时间比 npm 快了不少。项目构建时 pnpm 默认的依赖隔离也更严格,对我们这种想保持环境纯净的场景是加分项。
还有数据库选型。脚手架默认给的是 SQLite,方便本地快速跑起来;t3code 在 SQLite 上开发了一段时间,后面部署时切到了 PostgreSQL。如果你的项目一开始就知道将来要部署到生产环境,建议直接在初始化时选 PostgreSQL,避免后面因为方言差异调整 schema。SQLite 和 PostgreSQL 之间的大多数基础类型是兼容的,但例如Json、DateTime之类字段的处理略有不同,越早切换沟通成本越低。这些选择其实没有一个绝对正确,关键是你至少要知道每个选项意味着什么。
2.2 Prisma 数据建模的核心原则
数据建模是每个全栈应用逃不掉的一环。t3code 的核心业务是任务管理,所以我的prisma/schema.prisma里定义了User和Task两个模型。Task通过createdById这个外键关联到User,代表“每个任务都属于某个用户”。字段命名上我坚持用“动词过去式 + 名词”的语义,比如createdAt、updatedAt,这样在业务代码里读起来跟自然语言一样顺。它还支持createdBy这个关系字段,Name 和 BelongsTo 关系一目了然。
建模时有个细节容易被忽略:索引。很多新手只关注主键和关系,却忘了查询频率高的字段要加索引。在 t3code 里,Task模型我会给status字段加一个普通索引,因为列表页经常按状态筛选。如果数据量涨到几十万条,没有索引的WHERE status = 'PENDING'可能瞬间拖垮数据库。加索引的代价只是写入时多一些开销,但对读多写少的业务场景完全值得。
定义好 schema 之后,执行npx prisma migrate dev --name init就能创建并应用第一次迁移。注意,这条命令同时会执行prisma generate,把数据库类型映射成 TypeScript 类型。如果你改了 schema 之后忘记跑生成脚本,编辑器里引用 Prisma Client 的地方就会大面积报错。这个习惯我在 t3code 踩过一次坑,后面已经变成肌肉记忆了:任何 schema 变更之后,先跑 migrate,再检查类型是否更新。
2.3 tRPC API 设计的类型安全逻辑
tRPC 是 t3code 里最让我觉得“值回票价”的部分。它不是一个框架,而是一个基于 TypeScript 的 RPC 调用层。核心概念是 router 和 procedure:一个 router 可以包含多个 procedure,每个 procedure 要么是 query,要么是 mutation。
在 t3code 里,任务列表的 API 长这样:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const taskRouter = createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) => { return ctx.db.task.findMany({ where: { createdById: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title, status: "PENDING", createdById: ctx.session.user.id, }, }); }), });这段代码最大的特点是没有单独的 API 路由文件、没有请求方法定义、没有响应的类型定义文件。list和create的入参和出参都被 TypeScript 自动推导出来,前端调用时直接use trpc.task.list.useQuery(),编辑器里能看到完整的返回类型。zod在这里负责运行时校验,z.string().min(1).max(100)能拦住空字符串和超长内容,前端输入非法数据时后端会直接返回校验错误,不需要自己写if (!title) return error之类的胶水代码。
protectedProcedure是个值得关注的点。普通 procedure 允许匿名用户调用,而 protectedProcedure 在调用前会检查上下文中是否存在登录用户。实现方式并不复杂,本质就是一个高阶函数,在真正执行查询逻辑之前先验证 session,如果没登录就抛出一个类似“UNAUTHORIZED”的错误。这样既省掉了每个业务接口里重复的权限判断,也让路由定义一眼就能看出哪些接口需要登录,安全性边界非常清晰。
2.4 前端消费 API 的最佳实践
t3code 的前端代码集中在app目录下。我在列表页里用了use trpc.task.list.useQuery()来读取任务数据,然后通过const utils = trpc.useUtils()拿到更新缓存的方法。创建任务时执行await utils.task.list.invalidate(),这一句的作用是让 tRPC 自动重新拉取列表数据,等效于把“手动刷新页面”这个动作自动化了。用这个模式写 CRUD 应用,前端代码量能减少非常多,也不需要 Redux 或状态管理库来维护服务端数据。
在 App Router 模式下还有一个常见场景:服务端组件里也想读数据库。比如页面首屏想直接展示任务数量。这时不能直接使用useQuery,因为它是个 React hook,只能在客户端组件里跑。正确的做法是使用 tRPC 的server caller,在服务端直接调用一个 procedure。t3code 里我写了一个trpcServer工具,专门用来在 Server Component 里发起调用:
import { createCaller } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; async function getTaskStats() { const caller = createCaller(await createTRPCContext({ headers: await headers(), })); return caller.task.stats(); }这里每次请求都会创建一次 caller,开销可以接受,因为 Next.js 的 Server Component 本身也是按请求执行的,不会造成跨请求数据污染。如果项目里同一请求内多次使用 caller,可以考虑用React.cache()做请求级缓存。至于页面上的交互组件——比如“已完成/未完成”切换按钮——则必须在客户端组件里通过trpc.task.toggle.useMutation()执行。这种“服务端取数、客户端交互”的分工,是我在实际开发里体会到的最顺滑的 App Router 玩法。
3. 实操过程与核心环节实现
这一节我把 t3code 从空白目录到部署上线的完整流程录成一份可以直接参照的“抄作业指南”。你不需要有基础的 T3 使用经验,只要照着步骤走,几乎每一步都能落下能用的东西。
3.1 完整落地步骤一览
从零到一的基本操作顺序如下:
- 运行
npx create-t3-app@latest t3code,进入交互模式。 - 选择 TypeScript、Tailwind CSS、Prisma、NextAuth.js、tRPC 全部启用。
- 选择 App Router 作为路由策略,其余按喜好选 pnpm 和 ESLint。
- 配置
.env文件,本地开发时DATABASE_URL指向本地 PostgreSQL 或 SQLite。 - 编辑
prisma/schema.prisma,加入Task模型。 - 执行
npx prisma migrate dev生成迁移文件并应用到数据库。 - 在
src/server/api/routers下新建任务相关的 router 文件,并挂载到根 router。 - 在
app/page.tsx里搭建列表页,客户端组件接入 tRPC 的useQuery和useMutation。 - 配置 NextAuth.js 的 provider 和 prisma adapter。
- 本地
pnpm dev跑起来,从登录到增删改查完整走一遍。 - 部署到 Vercel,配置环境变量,跑
prisma migrate deploy和prisma generate。
这套流程里的每一步我都在 t3code 上实际执行过。下面挑几个容易出问题的环节单独拆开讲。
3.2 数据库模型与迁移的完整操作
我建了一个比最小待办事项稍微丰富一点的模型,让例子更贴近真实场景。设计思路是:一个用户能创建多个任务,每个任务有标题、描述、状态和优先级。对应 schema 如下:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] tasks Task[] } model Task { id String @id @default(cuid()) title String description String? status TaskStatus @default(PENDING) priority TaskPriority @default(MEDIUM) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt createdById String createdBy User @relation(fields: [createdById], references: [id]) @@index([status]) @@index([createdById]) } enum TaskStatus { PENDING IN_PROGRESS COMPLETED } enum TaskPriority { LOW MEDIUM HIGH }这里用@default(cuid())生成主键,而不是用自增整数,是因为 cuid 在分布式部署时不需要依赖数据库生成序列,对未来的水平扩展更友好。TaskStatus和TaskPriority是枚举,数据库里存储的就是可读性的字符串,例如PENDING、COMPLETED,调试时直接看库里的记录就能明白业务状态,不用通过中间表去查外键。枚举类型在 TypeScript 和 Prisma 里都能得到正确的类型推导,前后端用起来都非常舒服。
定义好 schema 后,执行:
npx prisma migrate dev --name add_task_modelmigrate dev这条命令很智能:先自动比较 schema 和当前数据库的差异,生成迁移 SQL,然后应用它,接着再执行prisma generate。迁移文件生成在prisma/migrations目录下,对应每个历史节点都能回滚。我要提醒的一点是,尽量不要手改迁移 SQL 来“修数据”,应该始终回到 schema 文件上改模型,再让 migrate 帮你生成新迁移。手改迁移文件很容易让本地和生产环境的状态不一致,到时候排查问题会非常头大。
3.3 NextAuth.js 接入与鉴权流程
t3code 的认证部分我选的是邮箱 magic link 配合第三方 OAuth。这里用 NextAuth.js 的 Credentials Provider 和 OAuth Provider 都可以,但为了贴近真实环境,我建议用 OAuth 或者邮箱验证码这类“无密码”方案,免得还要自己维护密码加密和重置逻辑。脚手架默认生成的server/auth.ts已经包含了一个标准的 NextAuth 配置框架,你只需要把 provider 装上去,并处理好 Prisma adapter。
使用 Prisma Adapter 的目的是让 session、account、user 这些认证相关数据能落到数据库里。在 NextAuth 配置中加一行adapter: PrismaAdapter(prisma),然后确保AUTH_SECRET环境变量存在。这个变量非常重要,它负责给 session 加密签名,本地开发和部署时必须保持一致,一旦丢失,所有用户的登录态都会失效。更稳妥的做法是,把AUTH_SECRET设置成一个随机生成的长字符串,不要用AUTH_SECRET=secret这种大家都能猜到的值。
鉴权落地到 tRPC 时,需要把 NextAuth 的 session 注入到 tRPC 的上下文里。脚手架生成的文件里其实已经把这一步做完了,核心逻辑在src/server/api/trpc.ts中:
export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { db: prisma, session, ...opts, }; };有了 session 在手,protectedProcedure 就能判断当前请求是否来自合法登录用户。这样的配置让业务代码非常干净:写接口的时候只要关心数据本身,不需要在每个 procedure 里手动查数据库确认当前用户是谁。
3.4 前端任务列表页面的串联实现
拿到任务数据之后,怎么把它展示成可用界面?t3code 的首页我拆成了两部分:一个服务端组件负责渲染页面骨架,一个客户端组件负责交互。骨架部分用 Tailwind 直接写样式,重点是不需要引入组件库,也能保持视觉效果干净。代码大致是:
export default async function Home() { const taskCount = await getTaskCount(); return ( <main className="container mx-auto max-w-2xl p-4"> <h1 className="text-2xl font-bold">t3code 任务面板</h1> <p>当前共有 {taskCount} 个任务</p> <TaskList /> </main> ); }TaskList是客户端组件,负责真正展示任务列表和触发新增、切换状态、删除等操作。它内部使用trpc.task.list.useQuery()获取数据,再通过 mutation 更新数据。注意,客户端组件的文件头里要写上"use client",否则 Next.js 会默认按 Server Component 处理,hooks 直接报错。这算是我自学 App Router 时踩过的坑,现在写任何带交互的组件之前第一反应都是先想清楚它在哪个环境运行。
状态切换按钮这部分,我用了一个乐观更新的技巧。任务状态从“未完成”切到“已完成”时,如果等接口返回再更新 UI,用户会明显感觉到延迟。tRPC 的 mutation 支持onMutate回调,可以在网络请求还没完成时先改本地 UI,后面再通过onSettled触发重新拉取校准数据。这种细节上的打磨对体验提升非常明显,尤其是网络抖动严重的场景,用户会觉得应用“跟手”。
3.5 部署到生产环境的注意事项
部署部分,t3code 最终放到了 Vercel 上。整个流程很简单:推代码到仓库、在 Vercel 控制台导入项目、配置环境变量,再等构建。真正容易翻车的是三个地方。
第一,环境变量。DATABASE_URL、AUTH_SECRET这些必须在 Vercel 的控制台里设置,而不是只放在本地的.env文件里。构建阶段如果读不到这些变量,Prisma Client 初始化就会失败,整个部署直接红屏。
第二,Prisma 的构建流程。Vercel 构建过程中默认不会跑prisma migrate,所以生产数据库的表结构必须手动执行prisma migrate deploy来同步。如果跳过这步,部署成功后接口一访问就会报数据表不存在。
第三,Prisma Client 的生成。用 pnpm 部署时,因为依赖会被提升或隔离,有时候构建阶段没有正确执行prisma generate,导致PrismaClient引用错误。解决办法是在package.json的 postinstall 脚本里加上"postinstall": "prisma generate",确保安装依赖后自动生成客户端。这些坑单个看都不大,但连在一起很容易让第一次部署的人卡住半小时以上。
4. 常见问题与排查技巧实录
整个 t3code 开发过程中,我记录了不少让自己“拍大腿”的瞬间,这里挑几个最有代表性的整理成速查表,当然也会把排查思路一并讲清楚。熟读这些内容,你至少能少踩我踩过的一半坑。
4.1 Prisma 报错:找不到环境变量或客户端未生成
现象:本地明明配置了DATABASE_URL,数据库也能连,但npx prisma migrate或运行时总是提示Environment variable not found: DATABASE_URL。
原因可能有两个。一是.env文件位置不对。prisma默认从项目根目录读取.env,如果你把它放到了src里面,它根本找不到。二是你改了.env之后没有重启进程。Next.js 的 dev server 有缓存,很多环境变量只在启动时读取一次。除此之外,跑完prisma generate后也要重启 dev server,让 Prisma Client 的生成代码被重新加载。遇到这个问题,首先检查.env的路径和命名是否精确匹配DATABASE_URL,然后停掉 dev server 重新启动,九成情况都能解决。
4.2 服务端组件里无法调用 hook:Invalid hook call
在 App Router 模式下,页面文件app/page.tsx默认是 Server Component。如果你直接在页面里写const utils = trpc.useUtils(),很快就看到Invalid hook call报错。React 的 hooks 只能在客户端组件里运行。
排查方法是看文件头部是否有"use client"标记。如果这个页面需要同时具备“服务端取数”和“客户端交互”两种能力,正确的结构是拆分成两个组件:外层服务端组件负责读取初始数据,内层客户端组件负责交互。有些新手会把整个页面都标成"use client",这样能解决问题,但也意味着整页的 JS 都会打进客户端 bundle,失去 App Router 的优化优势。建议尽量保持“外壳服务端、核心交互客户端”的分工结构。
4.3 NextAuth Session 类型与 Prisma User 模型不一致
这个问题的出现场景是:Prisma 的User模型里没有某个字段,但代码里通过session.user.xxx直接访问,TypeScript 竟然没有报错,运行时却是undefined。这是因为 NextAuth 默认的 session 类型定义比较宽松,或者你手动给类型“补”了不只存在于数据库的字段。
解决方案很简单:打开src/types/next-auth.d.ts这类类型增强文件,把Session['user']的类型明确指向 Prisma 生成的User模型的部分字段,例如id和name。这样 TypeScript 就会严格检查 session 里所有字段,杜绝运行时才发现属性不存在的尴尬。另一个常见坑是:NextAuth 在未登录状态下,session.user本身可能为null,所以要养成先判断 session 再访问 user 的习惯,或者在 protectedProcedure 里做完鉴权后,让上下文里的 session 类型变成“非空”类型。
4.4 tRPC mutation 后页面数据不更新
我在 t3code 刚写完新增任务的功能时,遇到一个问题:接口调用成功,数据库里多了一条记录,但页面的任务列表就是没变化。原因是我在 mutation 成功之后,没有让相关查询失效。
正确做法是调用utils.task.list.invalidate(),告诉 tRPC“这个查询的缓存数据已过期,请重新拉取”。如果你的页面里还有别的关联查询,比如任务统计,记住也要一起失效。更精细的做法是使用setData做手动缓存写入,直接在成功后把新任务塞进现有列表里,这样列表能瞬间更新,不需要等待网络请求。但注意,手动更新缓存时你要确保新数据的结构与查询返回值完全一致,否则后面排序或筛选时可能出错。
4.5 部署后报 500 错误:数据库还没有迁移
本地开发时数据库表是手动跑 migrate 创建的,部署之后很多人会忘记生产数据库也要同步。如果 Vercel 环境变量里的DATABASE_URL是正确的,但接口请求返回 500,登录后台日志大概率能看到 “relation does not exist” 之类的报错。这就是生产数据库没有建表。
解决办法是在部署环境里执行一次npx prisma migrate deploy。Vercel 项目根目录如果配置了 postinstall 脚本,可以在构建时顺便跑掉:"postinstall": "prisma generate && prisma migrate deploy"。但这有个风险:如果生产数据库服务在构建阶段不可用,构建就会失败,所以我更倾向于在部署平台上单独配置一条命令,或者在确认数据库可连接后手动执行一次迁移,而构建阶段只保留prisma generate。
4.6 常见问题速查表
| 问题现象 | 可能原因 | 快速解决方案 |
|---|---|---|
| 环境变量找不到 | .env文件位置不对或 dev server 未重启 | 确认路径、重启进程 |
| 表或列不存在 | 未执行迁移 | 运行prisma migrate deploy |
| PrismaClient 报错 | 未执行 generate | 添加 postinstall 脚本prisma generate |
| 页面 Hook 调用报错 | 在 Server Component 中使用 hooks | 拆分组件并加"use client" |
| Session 字段访问 undefined | 类型定义过宽 | 使用 NextAuth 类型扩展严格约束字段 |
| 操作后列表不刷新 | 未失效查询缓存 | 调用utils.task.list.invalidate() |
| 前端与后端类型不一致 | 手工复制类型 | 移除重复定义,统一用 tRPC 推导 |
我最终跑完 t3code 这遍流程后,最大的感受是:这套组合其实不像网上说的那么“炫技”,它就是一套非常务实、把类型安全和工程结构做到极致的工作流。前端写代码时像在调本地函数,后端加接口不用重复造轮子,数据库字段改动也敢直接下手,因为出错的地方编译器会立刻告诉你。如果你正好也有计划做一个中小型的全栈项目,我建议直接照着这套方案试一次,哪怕只是把 t3code 跑通,你也会对“类型安全”这四个字有完全不一样的理解。后面我还会继续往这个项目里扩展更多业务模块,比如任务评论、数据导出这些,到时候再来分享新的实操记录。