6.1 Nitro 架构与 API 端点 第 3 章把服务器当黑盒用了,本节打开它:Nitro 是什么、server 目录怎么变成接口、一个像样的 REST 端点怎么写。从此你的项目自带后端——第 4 章数据层调的接口,将全部由自己实现。 Nitro 在架构里的位置 图 6-1:Nitro 分层架构 图 6-1:Nitro 分层架构 读图要点:页面代码跑在"应用层",server 目录代码跑在"引擎层"——它们不会进同一个 bundle,前端代码无法 import 服务端模块(构建器直接报错),这从物理上保证了密钥不泄漏。部署抽象层的含义:写一份 server 代码,构建时选 preset 决定它编译成 Node 服务、Serverless 函数还是边缘 worker。
第 3 章把服务器当黑盒用了,本节打开它:Nitro 是什么、server 目录怎么变成接口、一个像样的 REST 端点怎么写。从此你的项目自带后端——第 4 章数据层调的接口,将全部由自己实现。

读图要点:页面代码跑在"应用层",server 目录代码跑在"引擎层"——它们不会进同一个 bundle,前端代码无法 import 服务端模块(构建器直接报错),这从物理上保证了密钥不泄漏。部署抽象层的含义:写一份 server 代码,构建时选 preset 决定它编译成 Node 服务、Serverless 函数还是边缘 worker。
以商品接口为例,覆盖增删改查。文件路径即接口路径,方法用文件名后缀区分:
// server/api/products/index.get.ts // GET /api/products —— 列表查询,支持分页 export default defineEventHandler(async (event) => { const query = getQuery(event) // 读查询参数 const page = Number(query.page) || 1 const size = Math.min(Number(query.size) || 20, 100) // 上限防滥用 const list = await db.products.findMany({ skip: (page - 1) * size, take: size, }) const total = await db.products.count() return { items: list, total, page, size } // 返回值自动 JSON 序列化 })
// server/api/products/[id].get.ts // GET /api/products/123 —— 详情 export default defineEventHandler(async (event) => { const id = getRouterParam(event, 'id') // 读路径参数 const product = await db.products.findUnique({ where: { id: Number(id) } }) if (!product) { // 资源不存在:抛出带状态码的错误,响应即 404 throw createError({ statusCode: 404, statusMessage: '商品不存在' }) } return product })
// server/api/products/index.post.ts // POST /api/products —— 新建(body 读入 + 基本校验) export default defineEventHandler(async (event) => { const body = await readBody(event) if (!body?.name || typeof body.price !== 'number') { throw createError({ statusCode: 400, statusMessage: 'name 与 price 必填' }) } const created = await db.products.create({ data: body }) setResponseStatus(event, 201) // 创建成功给 201 return created })
方法后缀(get/post/put/delete)同名共存时,同名路径不同方法各归各的文件;没有后缀的文件(如 products.ts)响应所有方法。请求侧工具箱速记:
| 工具 | 用途 |
|---|---|
| getQuery / readBody | 读查询串 / 读请求体 |
| getRouterParam | 读路径动态段 |
| getHeader / setHeader | 读写请求响应头 |
| setResponseStatus | 设置响应状态码 |
| createError | 抛出带状态码的错误 |
| defineEventHandler | 一切端点的包装器 |
前后端同仓的红利是类型直达。定义共享类型放一个公共位置,两端 import 同一个类型:
// shared/types/product.ts(Nuxt 3.14+ 支持 shared 目录;旧版放 types/ 并配置别名) export interface ProductDTO { id: number name: string priceCents: number }
服务端返回它、客户端 useFetch 泛型标注它,接口改字段时两端同时编译报错——这比任何文档都可靠。服务端内部复用另一个端点的逻辑,用 $fetch 会绕网络;正确姿势是抽普通函数互调,或用 eventHandler 之外的服务端工具 callEvent 类机制直接调用,避免自己请求自己造成的端口占用与死锁(尤其在测试环境)。
⚠️ 常见坑:在端点里读 window 或 Vue 组件上下文。server 代码运行在 Nitro(无浏览器、无 Vue 应用),useRoute、useState 一概不可用;可用的只有 event、runtimeConfig、storage 这些服务端工具。
💡 关键直觉:server 目录的每个文件都是一个"微控制器"——路径是地址、后缀是方法、导出函数是处理体。写接口的心智从"配置路由表"换成"放对文件名"。
端点抛错不只是"返回非 200"。createError 携带的状态码与消息有协议语义,值得按 REST 约定用对:400 给参数错误(客户端改了重试有意义)、401 给未登录(引导登录)、403 给无权限(别暴露资源存在与否的细节)、404 给资源不存在、409 给冲突(并发修改)、5xx 留给服务端自己的故障。前端 4.3 节的错误文案分支(按状态码给不同提示)之所以能成立,前提就是服务端把状态码用准了。两端的错误语义是一份契约:服务端乱给 500,客户端的一切优雅降级都无从谈起。