Nuxt 服务端路由解析:Nitro 如何构建你的 API
译注:本文翻译自 dev.to 作者 parsajiravand 的技术文章,原文基于 Nuxt 4.x(v4.5 发布线,2026 年 8 月验证;Nuxt 3 已于 2026 年 7 月 31 日停止维护)。原文链接见文末。
打开任意一个 Nuxt 项目,很可能已经有一个 server/api 文件夹躺在里面——这里一个 hello.ts,那里一个 login.post.ts。问大多数人这是什么,答案通常是「API 路由」。但若追问:究竟是什么在运行这些文件、以什么顺序运行、它们能否像应用其他部分一样使用 useState 或 useRoute 这类组合式函数?答案就变得含糊了。而有趣的 bug 恰恰藏在这个认知缺口里:一个中间件悄悄跑在了它本该跟随的那个中间件之前;一个事件处理器莫名其妙地因 useState is not defined 而崩溃;一个 readBody() 返回空值。
问题:它看起来像应用的一部分,其实不是
假设你想要一个小的 /api/profile 端点返回当前用户,而应用其他地方已经有一个保存登录用户的 useState('user'),于是很自然地想在这里复用它:
// server/api/profile.get.ts — 看起来合理,其实不然
export default defineEventHandler((event) => {
const user = useState('user') // ❌ 运行时抛错
return { user: user.value }
})运行后,你得到的不是 JSON,而是 500 错误:useState is not defined。这明明是你每个 .vue 文件里都在用的组合式函数,看起来像是漏了导入——但并不是。server/ 有自己的自动导入(h3 辅助函数、Nitro 工具、server/utils),Vue 组合式函数不在其中;若从 #app 显式导入,构建会直接拒绝,提示「Vue app aliases are not allowed in server runtime」。问题在于它被调用的位置。useState、useRoute、useFetch——整个 Nuxt 组合式函数家族——都依赖于存在一个当前 Nuxt 应用实例可供挂载。而 server/api 文件没有这样的实例。它根本不属于 Vue 应用,而是一个由 Nitro 直接调用的普通请求处理器,周围没有任何 Vue 形态的东西。
这正是「Nuxt 只是带路由的 Vue」这一心智模型设下的陷阱。server/api 看起来和页面属于同一个应用,因为它和页面在同一个仓库、同一次部署中发布,甚至共享同一个 nuxt dev 进程——但它是一个拥有不同生命周期的不同运行时,让组合式函数生效的规则在那里并不适用。
心智模型:一个项目,两个运行时
一个 Nuxt 项目实际上是在构建时粘合在一起的两个独立请求处理世界:
Vue/应用世界。 页面、组件、布局和组合式函数。每个页面请求都会启动一个 Nuxt 应用实例(先服务端,再客户端水合),useState、useRoute 等就挂载在这个实例上。
Nitro/h3 世界。 server/api、server/routes 和 server/middleware。Nitro 是 Nuxt 所构建于其上的服务端引擎——它启动进程、决定哪个文件处理哪个 URL,并把每个匹配到的文件作为普通函数运行,该函数接收一个 H3Event(来自 h3,Nitro 所基于的微型 HTTP 工具包)并返回一个值。这里没有组件树,没有「当前实例」,没有任何可供 Vue 组合式函数挂载的东西。
两个世界确实会通信,但只通过一条明确的边界:页面调用 useFetch('/api/profile') 或 $fetch('/api/profile'),发出一个请求,Nitro 将其路由到你的 server/api/profile.get.ts 处理器,就像路由来自 curl 或浏览器标签页的请求一样。响应以纯可序列化数据的形式返回——绝不是活对象、共享引用或 ref。
关键概念: 如果你无法指出某段代码运行在哪个 .vue 文件或组件 setup() 中,它就不在 Vue 世界里——而 server/api、server/routes 或 server/middleware 文件永远不在。
逐阶段构建服务端路由
阶段 1——文件名即路由
Nitro 通过约定把 server/api 和 server/routes 变成路由器,无需手动注册:
server/api/hello.ts→ 匹配/api/hello的任意方法server/api/hello.get.ts→ 仅匹配GET /api/hello;hello.post.ts仅匹配POSTserver/api/users/[id].ts→ 动态段,用getRouterParam(event, 'id')读取server/api/files/[...slug].ts→ 捕获所有,/files/之后的一切进入getRouterParam(event, 'slug')(未命名的捕获所有[...].ts则进入event.context.params._)server/routes/robots.txt.ts→ 规则相同,但没有自动的/api前缀——适合robots.txt、sitemap.xml或第三方期望固定路径的 webhook URL
阶段 2——读取输入,返回输出
每个处理器都用 defineEventHandler 包裹,并拿到一个 H3Event:
// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const { includeOrders } = getQuery(event) // ?includeOrders=true
const user = await findUser(id)
if (!user) {
throw createError({ status: 404, statusText: 'User not found' })
}
return { user, includeOrders: includeOrders === 'true' }
})你 return 的任何内容——对象、数组、字符串——都会被自动序列化为正确的响应(对象和数组为 JSON;字符串原样发送,除非你设置,否则内容类型为 text/html)。createError 是正确的失败方式:它设置真实的 HTTP 状态,并给客户端结构化错误体,而不是未捕获抛出导致的通用 500。对于 POST/PUT 请求体,readBody(event) 会根据请求的内容类型解析——JSON、表单编码或纯文本。
阶段 3——中间件在一切上运行,顺序不由你选
server/middleware/*.ts 文件在 Nitro 处理的每个请求之前运行——不只是 /api/*,页面请求也一样,因为页面请求同样由 Nitro 路由。中间件不返回响应(要提前结束请求,应抛出 createError 而非返回);它检查或修改请求并让其继续,通常通过写入 event.context 供后续处理器读取:
// server/middleware/auth.ts
export default defineEventHandler((event) => {
const token = getHeader(event, 'authorization')
event.context.user = token ? verifyToken(token) : null
// 无返回——请求继续到匹配的路由
})它们的运行顺序是按文件名字符串排序的字母顺序——不是你创建它们的顺序,也不是数字顺序。"10.rate-limit.ts" 排在 "2.legacy.ts" 之前,因为字符串比较在看到 '2' 之前先看到了 '1'。若需要显式顺序,请补零:01.、02.、03.——绝不要用裸的 1.、2.、10.。
阶段 4——SSR 桥接,没有你预期的网络跳转
当页面在服务端渲染时调用 useFetch('/api/profile')(或它基于的 $fetch),Nitro 不会向自己打开真实的 HTTP 连接。它识别出请求指向自己的某个路由,并在同一进程内直接调用匹配的函数——这是有文档记载的、有意的行为,而非你碰巧依赖的实现细节。水合之后从浏览器发出的同一调用则确实走真实 HTTP,因为那时没有可短路进入的服务端进程。useFetch 还会把服务端结果写入页面 payload,因此客户端在水合时不会重新获取。
边界情况与坑
数字前缀排序陷阱不限于 server/middleware。 全局路由中间件(以 .global.ts 结尾的文件,运行在 Vue/应用世界而非 Nitro 中)遵循完全相同的字母字符串规则。如果你给一个补了零而另一个没有,同一个项目里就有了两个不同且难以察觉的顺序 bug。
event.context.params 的类型可能被标为可能 undefined,即使在动态段保证其存在的路由上,因为类型来自通用 Nitro 类型而非你的具体路由。优先使用 getRouterParam(event, 'id') 而非直接访问 event.context.params。
defineCachedEventHandler 和 readBody 目前配合不佳——缓存处理器的事件类型刻意省略了 body,且缓存键由 URL(加上任何 varies 头)构建,从不包含 body——因此两个不同的 POST body 会共享同一个缓存响应。不要缓存输出依赖请求体的路由。
页面从未直接调用的 server/api 路由仍然是公开的。 「我内部使用的路由」和「任何人都能访问的路由」之间没有隐式鉴权边界——server/api 下的每个文件一经发布就是真实可达的 HTTP 端点。
最佳实践
- 绝不在服务端路由中使用 Vue 组合式函数。 若服务端逻辑需在多个处理器间共享,把它作为普通函数放进
server/utils/——它在server/内自动导入,就像组合式函数在app/内一样,但它只是函数,不绑定 Vue 实例。 - 对任何顺序重要的文件名补零——
01.auth.ts、02.logging.ts——这样后来加入03.rate-limit.ts的同事不会悄悄跳到从未重编号的2.something.ts之前。 - 在处理器顶部验证输入,在接触数据库或外部 API 之前——用 schema(Zod 或其他)配合
readValidatedBody,把畸形请求变成干净的 400,而不是三行之后令人困惑的失败。 - 把密钥挡在公开运行时配置之外。
nuxt.config的runtimeConfig(仅服务端)与runtimeConfig.public(打包进客户端)之间只隔一行——服务端路由可安全读取私有部分;在页面组件中,私有键仅存在于服务端渲染期间,绝不进入浏览器——因此绝不要渲染它们或放进useState。 - 从创建之日起就把每个
server/api文件当作公开端点,在第一个真实功能依赖它之前就加上鉴权/验证。
常见问题
能在服务端路由里用 useState 或 useRoute 吗? 不能——这些组合式函数需要活的 Nuxt 应用实例,只存在于 Vue/应用世界(页面、组件、插件)。server/api/server/routes/server/middleware 文件作为普通 Nitro/h3 处理器运行,没有这样的实例。请通过 server/utils/ 共享逻辑。
server/api 和 server/routes 的实际区别是什么? 路由规则完全相同(文件名、方法后缀、动态段)——唯一区别是 server/api 文件自动加 /api 前缀,而 server/routes 不加。需要精确路径时用 server/routes,如 /robots.txt 或固定 webhook URL。
为什么我的日志中间件跑在鉴权中间件之前,明明我先创建的鉴权?server/middleware 文件按文件名字符串字母顺序运行——创建顺序和文件树位置无关。用补零数字前缀重命名(01.auth.ts、02.logging.ts)来强制你想要的顺序。
用 useFetch 调用自己的 /api 路由会发出真实网络请求吗? 只有从浏览器发出时才会。SSR 期间,Nitro 识别目标是自己的路由,在同一进程内直接调用处理器函数——没有 HTTP 往返。水合后从浏览器发出的同一调用则像其他请求一样走网络。
动态路由段在运行时可能是 undefined 吗? 对于文件名保证的段不会——[id].ts 在匹配请求上总会有 id,尽管其 TypeScript 类型可能更宽松。用 getRouterParam(event, 'id')(类型仍为 string | undefined)或配合 schema 的 getValidatedRouterParams 来获得保证的类型化值。
速查表
| 想要…… | 这样做 |
|---|---|
匹配 /api/x 的任意方法 | server/api/x.ts |
仅匹配 GET/POST 等 | server/api/x.get.ts / x.post.ts |
| 匹配动态段 | server/api/x/[id].ts → getRouterParam(event, 'id') |
| 匹配捕获所有 | server/api/x/[...slug].ts → getRouterParam(event, 'slug') |
提供无 /api 前缀的路径 | server/routes/robots.txt.ts |
| 在每个请求前运行代码 | server/middleware/NN.name.ts(补零前缀) |
| 从中间件向处理器传数据 | event.context.yourKey = value |
| 读取查询串/请求体 | getQuery(event) / readBody(event) |
| 以真实 HTTP 状态失败 | throw createError({ status, statusText }) |
| 在服务端路由间共享逻辑 | server/utils/ 中的普通函数 |
| 让值不进入客户端包 | nuxt.config 中的 runtimeConfig(非 .public) |
关键要点
server/api、server/routes和server/middleware运行在 Nitro 中——一个与页面渲染所在的 Vue 应用分离的请求处理世界,没有组件实例,也无法访问useState或useRoute等组合式函数。- 路由完全由文件名驱动:路径、HTTP 方法、动态段和捕获所有都由文件命名决定,而非任何注册代码。
server/middleware顺序是文件名的字母字符串排序,不是创建顺序也不是数字顺序——任何需要固定位置的前缀都要补零。- SSR 期间用
useFetch调用自己的 API 路由会跳过网络直接调用函数;水合后从浏览器发出的同一调用是真实 HTTP 请求。
终于说得通的端点
文章开头那个 /api/profile 处理器现在有了诚实的修复方案:去掉 useState 调用,从 event.context 读取用户(由上游鉴权中间件设置),返回纯数据。修复本身并不奇特——只是尊重了它所在的文件从一开始就不属于 Vue 应用这一事实。
下次服务端路由抛出 useState is not defined,或中间件以你未预期的顺序运行时,你会清楚自己站在两个世界中的哪一个——而这在你打开堆栈跟踪之前就已经完成了大部分调试。
原文链接
https://dev.to/parsajiravand/nuxt-server-routes-explained-how-nitro-builds-your-api-9m2