API 路由
server/api/ 目录下的文件自动注册为 API 路由,URL 自动添加 /api 前缀。
为什么要有 API 路由?
前后端分离架构中,前端通过 HTTP 请求与后端通信。Nuxt 把 API 路由内置到项目中,你无需单独搭建后端服务——同一个项目既能渲染页面,又能提供 API,部署也更简单。
基本用法
// 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 的约定式设计——一个文件只做一件事,职责清晰:
// 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 方法时,用后缀拆分。如果只支持一种方法,也建议加上后缀,这样意图更明确。
动态路由参数
// server/api/users/[id].ts
export default defineEventHandler((event) => {
const id = getRouterParam(event, 'id')
return { id, name: `User ${id}` }
})访问 /api/users/123,id 为 '123'。
INFO
️ **注意:路由参数始终是 string 类型 ** 即使 URL 中是数字 123,getRouterParam 返回的也是字符串 '123'。需要数字时必须手动转换:Number(id) 或 parseInt(id!)
通配路由
// server/api/files/[...path].ts
export default defineEventHandler((event) => {
const path = event.context.params.path // string[]
return { path }
})访问 /api/files/a/b/c,path 为 ['a', 'b', 'c']。
请求处理
读取请求体
// 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 只能调用一次 ** 重复调用会抛出错误,因为请求体是流式数据,读取后无法回退。如果多处需要请求体,在第一次读取后保存到变量中传递
读取查询参数
// 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
读取请求头
export default defineEventHandler((event) => {
const auth = getRequestHeader(event, 'authorization')
return { authenticated: !!auth }
})读取 Cookie
export default defineEventHandler((event) => {
const cookies = parseCookies(event)
const token = getCookie(event, 'auth-token')
return { cookies, token }
})响应处理
返回 JSON
// 直接返回对象,自动序列化为 JSON
export default defineEventHandler(() => {
return { message: 'Hello' }
})设置状态码
export default defineEventHandler((event) => {
setResponseStatus(event, 201)
return { created: true }
})设置响应头
export default defineEventHandler((event) => {
setResponseHeader(event, 'x-custom-header', 'value')
return { message: 'Hello' }
})错误响应
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 类型推导。
npm install zod// 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
文件上传
// 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)而非本地文件系统,避免单点故障和扩容问题
流式响应
适用于大文件下载、实时日志等场景——不需要等所有数据准备好再返回,可以边生成边发送:
// server/api/stream.ts
import { createReadStream } from 'fs'
export default defineEventHandler((event) => {
return sendStream(event, createReadStream('/path/to/file'))
})重定向
export default defineEventHandler(async (event) => {
await sendRedirect(event, '/new-url', 301)
})301 vs 302?
301 是永久重定向(浏览器会缓存),302 是临时重定向。大多数场景用 302,只有确定旧 URL 永久废弃时才用 301。
后台任务
什么时候需要后台任务?
当你需要在返回响应后继续执行耗时操作时使用。例如:发送邮件通知、更新搜索索引、记录分析日志。如果不使用 waitUntil,服务器可能在任务完成前就关闭了连接。
export default defineEventHandler((event) => {
// 响应先返回,后台任务在服务器空闲时执行
event.waitUntil(
sendWelcomeEmail(userEmail)
)
return { status: 'ok' }
})路由组织
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.dirname 或 createResolver 获取绝对路径 |
| 405 Method Not Allowed | 请求方法与文件后缀不匹配 | 检查文件名后缀是否与请求方法一致 |
| API 返回 404 | 文件位置不对或命名错误 | 确认文件在 server/api/ 下,且不含拼写错误 |
知识脉络
API 路由 → 请求处理(readBody/getQuery/getCookie)
→ 响应处理(JSON/状态码/错误)
→ 输入验证(Zod)
→ 文件上传(readMultipartFormData)
→ 路由组织(目录结构)
→ 服务中间件(鉴权/日志) ← 详见下一章
→ 服务插件(全局逻辑) ← 详见后续章节