Skip to content

API 路由

server/api/ 目录下的文件自动注册为 API 路由,URL 自动添加 /api 前缀。

为什么要有 API 路由?

前后端分离架构中,前端通过 HTTP 请求与后端通信。Nuxt 把 API 路由内置到项目中,你无需单独搭建后端服务——同一个项目既能渲染页面,又能提供 API,部署也更简单。

基本用法

ts
// server/api/hello.ts
export default defineEventHandler(() => {
  return { message: 'Hello World!' }
})

访问 GET /api/hello 返回 { "message": "Hello World!" }

为什么自动加 /api 前缀?

将 API 路由与页面路由区分开,避免命名冲突。如果你不想加前缀,把文件放在 server/routes/ 下即可(详见《服务路由》)。

HTTP 方法匹配

文件名后缀匹配 HTTP 方法,这是 Nuxt 基于 H3 的约定式设计——一个文件只做一件事,职责清晰:

ts
// server/api/users.get.ts → 仅匹配 GET
export default defineEventHandler(() => {
  return { users: [] }
})

// server/api/users.post.ts → 仅匹配 POST
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  return { created: true, id: 1 }
})

// server/api/users/[id].delete.ts → 仅匹配 DELETE
export default defineEventHandler((event) => {
  const id = getRouterParam(event, 'id')
  return { deleted: true, id }
})

不匹配的方法返回 405 Method Not Allowed。

什么时候用方法后缀?

当同一个路径需要支持多种 HTTP 方法时,用后缀拆分。如果只支持一种方法,也建议加上后缀,这样意图更明确。

动态路由参数

ts
// server/api/users/[id].ts
export default defineEventHandler((event) => {
  const id = getRouterParam(event, 'id')
  return { id, name: `User ${id}` }
})

访问 /api/users/123id'123'

INFO

️ **注意:路由参数始终是 string 类型 ** 即使 URL 中是数字 123getRouterParam 返回的也是字符串 '123'。需要数字时必须手动转换:Number(id)parseInt(id!)

通配路由

ts
// server/api/files/[...path].ts
export default defineEventHandler((event) => {
  const path = event.context.params.path  // string[]
  return { path }
})

访问 /api/files/a/b/cpath['a', 'b', 'c']

请求处理

读取请求体

ts
// server/api/users.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  // body: { name: 'Alice', email: 'alice@example.com' }
  return { created: true, user: body }
})

INFO

️ **readBody 只能调用一次 ** 重复调用会抛出错误,因为请求体是流式数据,读取后无法回退。如果多处需要请求体,在第一次读取后保存到变量中传递

读取查询参数

ts
// server/api/search.ts → /api/search?q=nuxt&page=1
export default defineEventHandler((event) => {
  const query = getQuery(event)
  // query: { q: 'nuxt', page: '1' }
  return { results: [], query }
})

INFO

️ 查询参数也是 string 类型 上面的 page'1' 而非 1

读取请求头

ts
export default defineEventHandler((event) => {
  const auth = getRequestHeader(event, 'authorization')
  return { authenticated: !!auth }
})
ts
export default defineEventHandler((event) => {
  const cookies = parseCookies(event)
  const token = getCookie(event, 'auth-token')
  return { cookies, token }
})

响应处理

返回 JSON

ts
// 直接返回对象,自动序列化为 JSON
export default defineEventHandler(() => {
  return { message: 'Hello' }
})

设置状态码

ts
export default defineEventHandler((event) => {
  setResponseStatus(event, 201)
  return { created: true }
})

设置响应头

ts
export default defineEventHandler((event) => {
  setResponseHeader(event, 'x-custom-header', 'value')
  return { message: 'Hello' }
})

错误响应

ts
export default defineEventHandler((event) => {
  const id = getRouterParam(event, 'id')

  if (!id || !/^\d+$/.test(id)) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Bad Request',
      message: 'ID 必须是数字',
    })
  }

  return { id }
})

createError vs 直接返回错误对象?

throw createError() 会被 Nitro 统一处理,自动设置 HTTP 状态码并返回标准错误格式。直接返回对象则始终是 200 状态码,前端无法通过状态码判断请求是否成功。

输入验证(Zod)

为什么要验证输入?

永远不要信任客户端发送的数据。缺乏验证可能导致:数据库写入脏数据、SQL 注入、服务端崩溃。Zod 让你用声明式的方式定义数据结构,同时提供 TypeScript 类型推导。

bash
npm install zod
ts
// server/api/users.post.ts
import { z } from 'zod'

const schema = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().min(0).max(150).optional(),
})

export default defineEventHandler(async (event) => {
  const result = await readValidatedBody(event, schema.safeParse)

  if (!result.success) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Bad Request',
      message: result.error.message,
    })
  }

  return { created: true, user: result.data }
})

TIP

使用 readValidatedBody 而非手动 readBody + schema.parse 因为前者在验证失败时自动抛出 400 错误,且不会重复读取请求体。同理,查询参数验证使用 getValidatedQuery

文件上传

ts
// server/api/upload.post.ts
import { writeFile } from 'fs/promises'
import { join } from 'path'

export default defineEventHandler(async (event) => {
  const files = await readMultipartFormData(event)

  if (!files?.length) {
    throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '请上传文件' })
  }

  const allowed = ['image/jpeg', 'image/png', 'image/webp']
  const maxSize = 5 * 1024 * 1024 // 5MB

  for (const file of files) {
    // 验证文件类型
    if (!allowed.includes(file.type!)) {
      throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: `不支持的文件类型: ${file.type}` })
    }
    // 验证文件大小
    if (file.data.length > maxSize) {
      throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '文件大小不能超过 5MB' })
    }

    // 使用安全文件名,防止路径遍历攻击
    const safeName = file.filename!.replace(/[^a-zA-Z0-9._-]/g, '_')
    const filePath = join('./uploads', safeName)
    await writeFile(filePath, file.data)
  }

  return { uploaded: files.length }
})

INFO

文件上传安全要点:

  • 验证文件类型:不能只检查扩展名,要检查 MIME type(file.type),否则攻击者可以上传 .jpg 扩展名的恶意脚本
  • 限制文件大小:防止大文件耗尽服务器内存
  • 安全文件名file.filename 来自客户端,可能包含 ../ 等路径遍历字符,必须清理
  • 生产环境建议:使用云存储(如 S3、OSS)而非本地文件系统,避免单点故障和扩容问题

流式响应

适用于大文件下载、实时日志等场景——不需要等所有数据准备好再返回,可以边生成边发送:

ts
// server/api/stream.ts
import { createReadStream } from 'fs'

export default defineEventHandler((event) => {
  return sendStream(event, createReadStream('/path/to/file'))
})

重定向

ts
export default defineEventHandler(async (event) => {
  await sendRedirect(event, '/new-url', 301)
})

301 vs 302?

301 是永久重定向(浏览器会缓存),302 是临时重定向。大多数场景用 302,只有确定旧 URL 永久废弃时才用 301。

后台任务

什么时候需要后台任务?

当你需要在返回响应后继续执行耗时操作时使用。例如:发送邮件通知、更新搜索索引、记录分析日志。如果不使用 waitUntil,服务器可能在任务完成前就关闭了连接。

ts
export default defineEventHandler((event) => {
  // 响应先返回,后台任务在服务器空闲时执行
  event.waitUntil(
    sendWelcomeEmail(userEmail)
  )

  return { status: 'ok' }
})

路由组织

text
server/api/
├── users/
│   ├── index.get.ts          → GET /api/users
│   ├── index.post.ts         → POST /api/users
│   ├── [id].get.ts           → GET /api/users/:id
│   ├── [id].put.ts           → PUT /api/users/:id
│   └── [id].delete.ts        → DELETE /api/users/:id
├── auth/
│   ├── login.post.ts         → POST /api/auth/login
│   ├── register.post.ts      → POST /api/auth/register
│   └── refresh.post.ts       → POST /api/auth/refresh
└── health.get.ts              → GET /api/health

组织建议

按业务领域分目录(users、auth、orders…),每个路由文件保持简短。如果一个路由的逻辑超过 50 行,考虑将业务逻辑抽到 server/utils/ 中作为工具函数复用。

常见问题

问题原因解决方案
readBody 报错 "Body already consumed"readBody 被调用了两次只调用一次,将结果存到变量中传递
路由参数是 undefined动态路由参数可能为空始终做空值检查,使用 Zod 验证
文件上传后找不到文件相对路径指向了工作目录以外使用 import.meta.dirnamecreateResolver 获取绝对路径
405 Method Not Allowed请求方法与文件后缀不匹配检查文件名后缀是否与请求方法一致
API 返回 404文件位置不对或命名错误确认文件在 server/api/ 下,且不含拼写错误

知识脉络

text
API 路由 → 请求处理(readBody/getQuery/getCookie)
        → 响应处理(JSON/状态码/错误)
        → 输入验证(Zod)
        → 文件上传(readMultipartFormData)
        → 路由组织(目录结构)
        → 服务中间件(鉴权/日志) ← 详见下一章
        → 服务插件(全局逻辑)   ← 详见后续章节

基于 Nuxt 4 官方文档整理编写