ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从零搭建 t3code:Next.js + tRPC + Prisma 的全栈类型安全实践

2026/10/7 18:02:27 拓冰建站 浏览量
从零搭建 t3code:Next.js + tRPC + Prisma 的全栈类型安全实践 这些年我一直在TypeScript全栈这条路上折腾从最早的传统前后端分离到后来用GraphQL再到整个工具链打通类型系统说实话每一步都有收获但每到一个新项目就得重新配一轮脚手架、理一轮类型定义重复劳动特别多。直到有一次在社区里看到一个叫t3code的示例仓库才第一次完整感受到“全栈类型安全”是什么体验。这个项目不复杂就是一个用 T3 Stack 搭起来的待办事项应用但它把 Next.js、tRPC、Prisma、Tailwind 这几个工具串成了一个闭环从前端页面到数据库查询类型是全程贯穿的基本告别了“API返回一个any前端自己猜字段”这种老戏码。t3code这个名字其实有两层意思一是它明确站在 T3 Stack 这条技术路线上二是它强调代码本身的完整性和可复现性。如果你平时写 React Node 接口但经常因为类型对不上、环境变量配置混乱、部署后数据库连不上这类问题浪费时间那么这个项目的拆解思路会很适合你。它不适合那种想用一个框架包打天下的同学更多是给你一套“每一步都明确为什么这样做”的全栈样板。1. 项目整体设计与技术栈取舍1.1 为什么选择 T3 Stack而不是自己拼一套脚手架很多团队做新项目时第一反应是“用我熟悉的组合”前端用 create-react-app 或 Vite后端用 Express 写 REST 接口数据库用 Sequelize再手动配一个 CORS 转发。这套组合不是不能用但每次都要处理几个固定的问题前后端类型不同步、字段改名后两边都要改、接口文档过期、生产环境跨域配置出错。t3code当初选的路线完全不同直接用 T3 Stack 的官方脚手架create-t3-app起步把 Next.js、tRPC、Prisma、Tailwind 这四样作为基础不做额外的架构发明。理由是这几样东西在设计上有一个共同点都是为TypeScript服务的。Next.js 提供页面渲染和路由但它的关键优势是内置了 API 路由和 Server Components这让 tRPC 可以在服务端和客户端之间无缝传递类型。Prisma 负责数据库访问层它的 schema 文件是唯一的模型真源生成出的客户端类型可以直接被 tRPC 引用。Tailwind 则负责样式它和 TypeScript 没有直接关系但省掉了写 CSS 文件的上下文切换让整个开发流程保持节奏。你可以把 T3 Stack 理解成一顿套餐单独买可乐、汉堡、薯条也能凑一顿但套餐的好处是搭配经过了验证不用自己纠结比例。1.2 各技术组件在 t3code 中扮演的角色具体到t3code这个项目每个组件的职责分得非常清楚组件在项目中的角色为什么必须用它Next.js应用框架负责 SSR、路由、API 路由只有 Next.js 能把 tRPC 的服务端逻辑和 React 页面放在同一个应用里tRPCAPI 层代替 REST 或 GraphQL它不需要写 schema直接从服务端函数推倒出客户端类型PrismaORM负责数据库读在 schema 中定义模型生成类型安全的数据库客户端Tailwind CSS样式响应式工具类快速完成界面布局不用维护 CSS 文件NextAuth.js身份认证t3code 里加登录功能时选的方案和 tRPC 配合做 session 校验这套组合的核心价值简单说就是“一个函数从数据库取完数据经过 tRPC 暴露给前端全程类型可追溯”。我在早期使用 REST 时后端返回的 JSON 结构靠 postman 里的一个历史请求记录记着前端要用 interface 重新写一遍字段一多就难免写错。tRPC 的做法是直接让前端调用一个普通函数函数的入参、出参类型自动从服务端传到客户端。这意味着如果我改了一个 Prisma 模型的字段名tRPC 的返回类型会自动变更前端代码里涉及这个字段的地方会立刻报错而不是等到运行时才发现数据变成 undefined。就这一条就足够让我抛弃自己拼脚手架的想法。2. 核心细节解析数据库层与 API 层2.1 Prisma 模型设计以及它如何决定全栈类型t3code项目的业务模型很朴素就是一个 todo 列表。但这套非常简单业务的背后却很适合展示 Prisma 的类型穿透力。项目里有两个模型User和Todo。User保存用户邮箱和登录信息Todo关联用户、保存标题和完成状态。关键点是 Prisma 的 schema 文件是类型系统的起点所有模型约束都定义在这里。例如model User { id String id default(cuid()) email String unique name String? todos Todo[] } model Todo { id String id default(cuid()) title String completed Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt ownerId String owner User relation(fields: [ownerId], references: [id]) }这里值得注意的一点是t3code没有把数据库的 id 设置成自增整数而是用了cuid()。我一开始也犹豫过要不要用 autoincrement后来在社区讨论里看到有人的理由是自增整数在并发高时容易暴露数据总量而且分库时迁移麻烦。cuid 是字符串类型但它是时间排序的对建立索引很友好。如果你做一个内部小工具也许 autoincrement 更直观但 t3code 既然定位是示例项目它选 cuid 是想告诉大家一个更通用的生产实践。Prisma 生成客户端后你可以在服务端这样写一个查询import { prisma } from ~/server/db; const todos await prisma.todo.findMany({ where: { ownerId: session.user.id }, orderBy: { createdAt: desc }, });这行代码本身没什么惊奇的强大的地方在于findMany({ where: { ownerId: ... } })的字段名、类型、可空性都是被强类型的。如果写成orderBy: { createAt: desc }编译器会在你开发时就报错而不是等查询执行后才发现语法错误。这种能力让我在重构字段时底气足了很多。2.2 tRPC Router 构建类型安全的 API 层实现数据库层是基础API 层则是把这些数据暴露出去的唯一入口。t3code里的 tRPC 代码集中在server/api/routers目录核心是定义一个todoRouter。tRPC 的特色是它不需要单独创建一个 API 文档因为你的 Router 定义本身就是文档也是类型来源。import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const todoRouter createTRPCRouter({ getAll: protectedProcedure.query(({ ctx }) { return ctx.prisma.todo.findMany({ where: { ownerId: ctx.session.user.id }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(({ ctx, input }) { return ctx.prisma.todo.create({ data: { title: input.title, ownerId: ctx.session.user.id, }, }); }), });这里有个容易被忽略的细节protectedProcedure不是随便起的名字。它意味着访问这个接口必须先通过登录校验否则直接返回错误。这个校验逻辑位于一个中间件里它检查 session 是否存在再把 user 放进 ctx。这比你在每个 handler 里手写 if (!session) 要整洁得多而且类型上也保证了ctx.session.user不会是 null。前端调用时代码也极其简单import { api } from ~/utils/api; const { data: todos, refetch } api.todo.getAll.useQuery(); const createTodo api.todo.create.useMutation();第一眼看上去这就像一个本地状态库的写法但实际上它是一个网络请求。api.todo.getAll.useQuery()的返回类型todos会自动推导为Todo[]根本不需要自己写一个 interface 去描述。更意外的是tRPC 还自带refetch方法用来在增删后重新拉取数据。这个项目里的新增和列表操作天然形成了一套完整的类型闭包。我在之前的 REST 项目中即使使用 ts-rest 这类工具也没有这么直接的全栈类型体验。3. 实操过程从零搭建 t3code3.1 使用 create-t3-app 初始化项目并配置环境我当时搭建t3code时几乎没费什么力气。一个核心原因就是官方脚手架create-t3-app很成熟它会一次性把 Next.js、React、tRPC、Prisma、NextAuth、Tailwind 全部按最佳实践配置好不用自己手工折腾几十个配置文件。比如初始化一条命令pnpm create t3-applatest t3code在交互选项中我可以选择删除一些用不到的功能比如我没有用 Tailwind 的 dark mode 复杂主题也不需要next/dynamic的复杂按需加载。但保留的核心选项是Next.js、Tailwind、tRPC、Prisma、NextAuth。之后项目目录里就会生成完整的src/server、src/pages或 app router结构并且已经配好了 TS 路径别名。接下来需要做的事是写.env文件。这是整个项目里最容易踩坑的一步。t3code根目录里原本只有一个.env.example需要自己复制为.env并填上数据库连接地址、NextAuth 的 secret 等。数据库我先用了本地 SQLite 作为开发环境等到部署时才改用 PostgreSQL。因为 SQLite 不需要额外启动服务最快的验证方式是cp .env.example .env # 编辑 .env把 DATABASE_URL 改成 sqlite 路径 DATABASE_URLfile:./db.sqlite然后运行pnpm db:push把 Prisma schema 同步到 SQLite。这一步本质上是prisma db push它只做一次同步不需要生成迁移文件。对于学习阶段很舒服但对于生产环境越早切换到prisma migrate dev越稳妥。3.2 实现一个核心业务功能登录、创建待办、删除待办其实create-t3-app生成的代码里已经包含了一个安全等、带样式的基础页面但那是骨架页面。t3code真正的核心业务功能是登录后用户可以新建待办、列出待办、删除待办。这几件事分别对应前端页面、tRPC Router、Prisma 三个层次。我逐个梳理一下。先看登录。NextAuth 的配置在server/auth.ts中。最简单的方式是用 GitHub OAuth或者用邮件验证登录。t3code里头我用的是 GitHub Provider因为这样不需要自己处理密码哈希。如果你在本地开发需要在 GitHub 上创建一个 OAuth App回调地址填http://localhost:3000/api/auth/callback/github然后把生成的 ID 和 Secret 填到.env。这里有一个常见的困惑为什么 NextAuth 回调路径是固定的因为它默认把认证路由挂载在pages/api/auth下这是 NextAuth 的实现细节也是脚手架已经配好的约定。再看业务功能。刚才在 2.2 部分已经写过一个todoRouter但在实际开发中我会把指令拆得更细把create、delete、toggle分开。每个 mutation 都需要一个输入校验。例如删除待办delete: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) { await ctx.prisma.todo.deleteMany({ where: { id: input.id, ownerId: ctx.session.user.id }, }); }),这里我特意用deleteMany而不是delete是为了保证用户只能删除自己名下的待办。如果只用delete用户随便传一个别人的 id 就能删掉这在小应用里不算大事但一旦涉及权限这就是一种很常见的越权漏洞。deleteMany配合 where 条件从数据层面锁死了归属关系。前端页面的 React 代码看起来就是普通组件。但用 tRPC 时有几个小习惯当 mutation 成功后不要手动刷新整个页面而是调用之前getAll返回的refetch。createTodo是 mutation它的onSuccess回调里我只需要refetch()。如果用 React Query 的思路理解这样会保持缓存同步界面不会闪烁。3.3 用 Tailwind 快速搭建界面并保持响应式t3code的前端页面没有引入任何组件库全部用 Tailwind 的 utility class 完成。这样做的好处是体积小而且不会因为第三方组件库的样式约定而干涉 tRPC 组件的布局。比如一个简单的输入表单可能看起来是form onSubmit{(e) { e.preventDefault(); createTodo.mutate({ title: inputValue }); }} classNamemb-4 flex gap-2 input typetext value{inputValue} onChange{(e) setInputValue(e.target.value)} classNameflex-1 rounded-md border border-gray-300 px-3 py-2 / button typesubmit classNamerounded-md bg-blue-500 px-4 py-2 text-white 添加 /button /form很多新手会有一个疑问既然 Next.js 支持 Server Components那是不是所有的组件都应该在服务端渲染其实并不需要。t3code里涉及交互的组件比如 Form、按钮点击、实时列表都必须有客户端状态所以它们都应该标注为use client。而一些纯展示型页面和数据处理逻辑可以放在服务端做。这里的一个实操心得是tRPC 在客户端组件中非常好用因为它天然是 React Query 驱动的能够处理 loading、error 状态。如果你强制在 Server Component 里调用 tRPC那反而会失去很多前端交互特性。关于响应式我用 Tailwind 的sm、md断点来控制布局。一个小经验是先做移动端适配再往宽屏扩展。因为待办应用的主要使用场景要么是手机浏览器的窄屏要么是桌面宽屏。另一件容易被忽略的事是Tailwind 的 class 是运行时扫描的如果你动态拼接类名比如bg-${color}-500Tailwind 不会生成对应的样式一定得写成完整的类名。4. 常见问题与排查技巧实录4.1 数据库连接与 Prisma 相关的坑4.1.1 环境变量加载不到找不到 DATABASE_URL如果你在启动项目时报错说找不到环境变量不要急着怀疑代码。检查点有这样几个先确认.env确实存在且书写正确再确认启动命令用的pnpm dev是否从项目根目录执行最后确认是否把.env放在了src目录里。Prisma CLI 默认是从项目根目录读取.env而不是从src读取。我曾经在一个项目里因为把.env放错了位置导致prisma generate成功但运行时却读不到变量折腾了半小时。4.1.2 默认生成的 client 引擎与运行时不同还有一个比较隐蔽的问题是Prisma 在本地开发用 SQLite在部署时换成了 PostgreSQL但生成客户端时如果本地引擎和运行时引擎不一致会报一个尴尬的错误。解决办法就是每次切换数据库后重新prisma generate并且确保.env指到了正确的数据库地址。对于部署到云平台的情况我会把prisma generate放进构建命令里而不是只在本地执行一次。这是我在t3code部署到 Vercel 后遇到的第一个问题当时的解决方案是设置构建指令为pnpm prisma generate pnpm build4.2 tRPC 类型不匹配和 HTTP 状态码 405另一个高频问题出现在调用 tRPC 时报 405 Method Not Allowed 或 404。这通常不是业务逻辑错误而是请求路径不对。tRPC 默认把所有路由挂在trpc这个 API 路由前缀下所以如果你团队里有人手动改过src/pages/api/trpc/[trpc].ts这个文件或者使用了错误的 base URL就会出现这种诡异的错误。最好从一开始就不要去改脚手架生成的路由文件保持它的约定。如果类型报错例如Mutation input type not assignable绝大多数时候是因为 zod 的校验规则和 Prisma 字段类型不一致。比如前端传undefined而 zod 期望string又比如 Prisma 字段默认可空但 tRPC 的输出类型里出现了null。这类问题最好的调试方式是把 tRPC 的.input()里 zod 的 schema 定义尽量做严格约束例如z.string().min(1)这样大部分无效入参会在验证阶段拦截而不是在数据库查询阶段报错。同时如果团队里有人尝试用any或类型断言去“修复”报错我建议立刻拒绝这种改动。t3code 之所以有价值就是因为它全程没有any。4.3 登录会话失效和 NextAuth 兼容性t3code里使用 NextAuth 作为认证库后有一个特别容易迷惑的点为什么有些接口需要登录有些不需要因为定义了protectedProcedure和publicProcedure两种访问级别。如果你在某个页面上发现明明登录了但ctx.session还是 null大概率是因为session没有被正确注入。解决方法是检查server/auth.ts里的 session 回调是否返回了需要的字段例如把user.id放进去。有些情况下NextAuth 默认的 session 类型并不包含 id但 Prisma 模型里 user id 必须是可获取的。所以我们要把 session 的类型导出到全局覆盖默认类型。这里提供一个最简单的排查技巧在页面里临时打印 sessionconsole.log(session, session);如果 session 一直为 null那么请先检查浏览器里有没有 cookie再检查 NextAuth 有没有正确读取到JWT_SECRET或NEXTAUTH_SECRET。开发环境可以随便填一个字符串但部署到生产时必须设置一个足够长、固定不变的值否则用户每次部署后都会被迫重新登录。4.4 部署时的常见问题清单t3code的部署我测试过 Vercel 和 Docker 两种方式各自有需要注意的点。Vercel 上最方便的路线是利用它的 Serverless 函数但要留意冷启动时间。如果你是免费版偶尔会影响体验。Docker 部署则建议把数据库放到同一网络里的容器或者使用托管数据库避免容器重启后数据丢失。下面是我平时整理的几个部署相关问题表问题原因解决办法数据库迁移未执行构建时只编译了代码没有跑 db migrate构建命令中加入prisma db push或prisma migrate deploy环境变量缺失Vercel 或 Docker 的环境变量没配置完整检查 DATABASE_URL、NEXTAUTH_SECRET、GITHUB_ID 等是否全部设置跨域访问被拦截没有正确设置 API 的 baseURLtRPC 自动同源除非你单独分离了 API 服务否则不需要额外配置 CORS客户端缓存数据和数据库不同步多个并发 mutation 导致 refetch 覆盖旧数据尽量在 mutation 成功后使用 React Query 的 invalidate 或 refetch 机制5. 说点实际的哪些场景最适合用 t3code 这套方案如果你问我t3code到底适合什么样的项目我会说它特别适合中小型全栈应用、内部工具、黑客松项目、快速验证产品原型的阶段。因为这类项目业务逻辑不会特别庞杂但迭代速度又很快最怕的是类型来回变、接口来回改。T3 Stack 舍弃了 REST 文档、GraphQL 结构定义等重量级约束换来的是一种“即改即用”的体验。一旦你习惯了api.todo.getAll.useQuery()这种写法就很难再回到“先写接口文档、再写前端类型”的老节奏里。当然它也有不完美的地方。如果团队的成员对 TypeScript 不够熟悉刚开始可能会被 tRPC 的泛型、类型推断绕晕。另外当服务端逻辑需要大量异步重处理或后台任务时Next.js API 路由并不总是最合适的载体可能需要额外引入队列和 Worker。所以使用t3code之前需要评估一下自己的业务到底是否适合这种“一体式”结构。在项目启动初期我会用这种组合快速完成核心功能并给团队成员提供一个可以长期维护的样板目录。等到真的出现性能瓶颈或架构边界时再按需拆分服务。也就是说t3code 的意义不是解决一切问题而是让新手和团队能少走弯路。6. 一些可以继续扩展的方向基于t3code这个项目我后来也尝试过几种方向的扩展过程都挺顺的。如果你读完上面这些内容想拿它做二次开发下面这几个扩展方向可以一试。一是加入文件上传功能。Prisma 的模型并不会限制二进制文件但一般我会把文件存到对象存储比如 S3 兼容的服务数据库里只保存文件的 URL。tRPC 的 mutation 入参里传一个文件 key前端用预签名 URL 上传这样可以避免 API 网关的直接传输压力。二是加入任务分片或分页。目前的示例里getAll一次取回所有待办数据量一大就会拖慢首次加载。用 tRPC 的时候可以给getAll增加一个take和cursor参数然后用 React Query 的useInfiniteQuery来实现无限滚动。这个模式比你想的要简单因为类型已经打通改起来非常快。三是增加数据持久化之外的缓存层。如果业务复杂可以在 tRPC 的 procedure 里增加一个 Redis 缓存中间件。需要注意缓存 key 一定包含用户身份否则容易串数据。我个人在实际操作中最大的体会是全栈类型安全真正改变的不是某个单独的技术细节而是协作方式。以前后端改字段需要在前端代码里扒一遍哪些地方用了这个字段现在编译器就是最好的检查工具。因为类型提示的存在连代码补全都准确了很多。t3code这个项目哪怕不做任何业务扩展单纯把它作为团队新成员学习全栈 TypeScript 的起点也非常值得。最后再分享一个小技巧每次写完一个 tRPC procedure先别急着写前端页面用一下 tRPC 自带的 Playground也可以装一个集成插件在浏览器里直接调用一次接口。这个习惯能省去很多来回调试的时间。