nuxt-openapi-meta
| Install | |
|---|---|
npm install nuxt-openapi-meta |
|
| Latest Version: | 0.2.2 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Sep 20, 2026 |
| Links: | GitHub · npm |
nuxt-openapi-meta
Auto-generate Nitro defineRouteMeta (OpenAPI) for Nuxt server routes from JSDoc annotations + Zod / Valibot schemas. Zero boilerplate: just write a route, get documented OpenAPI.
Features
- 🔍 Auto-scan
server/api/**route files at build time - 📝 JSDoc annotations (
@tag,@summary,@response, …) → OpenAPI operation - 🧪
export const bodySchema / querySchema / paramsSchema / responseSchema(Zod v3/v4, Valibot) → JSON Schema - ⚡ Serves generated
defineRouteMetathrough Nitro's?metapipeline (no source files touched) - 🛡 Respects existing
defineRouteMeta(skip unlessoverwrite: true) - ⚙️
tagMap,defaultErrors,createError()auto-detect, generic@paramsupport - 🔐 JWT/Bearer auth: global
securitySchemes+securitywith per-route override (Swagger/Scalar Authorize button)
Quick Setup
npx nuxt module add nuxt-openapi-meta
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-openapi-meta'],
openapiMeta: {
tagMap: { '/api/auth': 'Auth' },
},
nitro: {
experimental: { openAPI: true }, // enables /_openapi.json + /_scalar + /_swagger
},
})
Usage
Minimal convention — no defineRouteMeta needed:
// server/api/auth/forgot-password.post.ts
/**
* @tag Auth
* @summary Request password reset
* @description Send a reset email if the address exists.
* @bodyDescription Password-reset request payload.
* @param header x-request-id optional Idempotency key for safe retries
* @response 429 Too many requests
* @example { "email": "student@fpt.edu.vn" }
*/
import { z } from 'zod'
export const bodySchema = z.object({
email: z.email(),
})
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, bodySchema.parse)
return { ok: true }
})
At build time the module serves (via Nitro's ?meta pipeline):
// virtual: server/api/auth/forgot-password.post.ts?meta
export default {
openAPI: {
tags: ['Auth'],
summary: 'Request password reset',
// ... parameters, requestBody (JSON Schema), responses
},
}
Your handler source files are never modified.
JSDoc annotations
| Tag | Meaning |
|---|---|
@tag X / @tags A, B |
Operation tags (fallback: tagMap, then Default) |
@summary / @description |
Operation summary/description |
@bodyDescription … |
requestBody.description |
@param <in> <name>[:type] [required|optional] [desc…] |
Generic parameter — see below |
@paramExample <name> <value> |
Example for the named parameter (JSON or plain text) |
@operationId |
Explicit operationId |
@deprecated |
Mark deprecated |
@security bearerAuth |
security: [{ bearerAuth: [] }] (needs securitySchemes below or no Authorize button shows) |
@response 200 { … } |
Success example (JSON object) |
@response 404 Not found |
Error description — stack one line per status |
@example { … } |
Request-body example (inline JSON) |
Generic parameters (@param)
Project-agnostic — use it for any header/query/path/cookie your project needs (CSRF tokens, idempotency keys, pagination, …). The module ships no hardcoded custom headers.
/**
* @param header x-request-id optional Idempotency key for safe retries
* @param query limit:number required Max items (1-100)
* @param query include Comma-separated relations to include
* @param path id The user id
* @paramExample x-request-id req_9f2c4a1e
* @paramExample limit 25
*/
Syntax: @param <in> <name>[:type] [required|optional] [description…]
<in>:queryheaderpathcookie:type:string(default)numberinteger/intboolean/boolarrayobjectrequiredpresent →required: true, elsefalse(pathis always required)- Entries override auto-detected params with the same
in:name(so@param path id …documents the:idsegment instead of duplicating it);openAPIMeta.parameterswins over both.
Error responses: stack @response lines
/**
* @response 400 Email is invalid
* @response 409 Email already exists
* @response 422 Account is locked
*/
export const errorResponses = {
409: ConflictErrorDto, // Zod/Valibot schema → content.schema
422: { error: 'locked' }, // plain object → content.example
}
Description precedence per status: openAPIMeta.responses[N] › @response N text ›
defaultErrors option › createError({ statusCode }) auto-detect. Schemas from
errorResponses attach as content; @response N {…} objects become examples.
Descriptions everywhere
- Operation:
@description - Body:
@bodyDescription(oropenAPIMeta.requestBody.description) - Field-level: describe fields in the DTO itself — it flows into JSON Schema:
z.email().describe('Login email') // Zod v.pipe(v.string(), v.description('Login email')) // Valibot - Non-object shapes are first-class:
z.array(UserDto),z.string(), unions and plain array/primitive examples all pass through toschema/exampleuntouched.
Schema exports (build-time via jiti)
| Export | Used as |
|---|---|
bodySchema |
requestBody for POST/PUT/PATCH |
querySchema |
parameters (in: query) |
paramsSchema |
Reserved (path params come from filename) |
responseSchema |
responses.200 schema |
bodyExample |
requestBody example — plain object, no JSON-in-comment needed |
responseExample |
responses.200 example — plain object/array |
errorResponses |
{ 404: NotFoundDto, … } — per-status error DTOs (schema → JSON Schema, plain object → example) |
openAPIMeta |
Raw override merged over JSDoc (typed via defineOpenAPIMeta) |
Precedence for examples: openAPIMeta.example › bodyExample export › @example. For 200: openAPIMeta.responses[200] › responseExample › @response 200 {…}.
DTOs: share schemas across routes
Schemas are loaded by executing the route file with jiti, so plain imports work — including Nuxt aliases (~/…, @/…):
// server/dto/user.ts
import { z } from 'zod'
export const UserSchema = z.object({
id: z.string(),
email: z.email(),
})
export const ErrorSchema = z.object({
statusCode: z.number(),
statusMessage: z.string(),
})
// server/api/users/index.post.ts
import { ErrorSchema, UserSchema, CreateUserSchema } from '../../dto/user'
// (or: from '~/server/dto/user')
export const bodySchema = CreateUserSchema
export const bodyExample = { email: 'new@fpt.edu.vn', name: 'New Student' }
export const responseSchema = UserSchema
export const responseExample = { id: 'user_123', email: 'new@fpt.edu.vn' }
export const errorResponses = { 409: ErrorSchema, 400: ErrorSchema }
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, bodySchema.parse)
return { id: 'user_123', ...body }
})
Validators: Zod vs Valibot
- Zod: pass
schema.parsedirectly —readValidatedBody(event, bodySchema.parse). - Valibot has no
.parsemethod — wrap the standalone parser:
import * as v from 'valibot'
export const querySchema = v.object({ include: v.optional(v.string()) })
export default defineEventHandler(async (event) => {
const query = await getValidatedQuery(event, data => v.parse(querySchema, data))
// ...
})
import { defineOpenAPIMeta } from 'nuxt-openapi-meta/dist/runtime/utils'
export const openAPIMeta = defineOpenAPIMeta({
tags: ['Auth'],
operationId: 'forgotPassword',
})
Supported validators (latest versions):
zodv4 — nativez.toJSONSchema()/schema.toJSONSchema()zodv3 — viazod-to-json-schemavalibotv1 — via@valibot/to-json-schema
Authentication (JWT / Bearer)
Swagger/Scalar only show the Authorize button when the spec defines both:
components.securitySchemes(what auth exists) andsecurityon an operation (which routes require it).
Enable it globally — shortest way, auto-adds Bearer:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-openapi-meta'],
openapiMeta: {
tagMap: { '/api/auth': 'Auth' },
auth: true, // = bearerAuth scheme + security: ['bearerAuth'] for every route
},
nitro: {
experimental: { openAPI: true },
},
})
Customize the hint text / scheme name:
openapiMeta: {
auth: {
description: 'Paste the raw JWT token — "Bearer" is added automatically', // default, override to localize
// name: 'jwt', // default 'bearerAuth'
// mode: 'bearer', // default 'bearer' — see toggle below
},
}
Toggle the automatic Bearer prefix (auth.mode, optional, default 'bearer'):
mode |
Spec emitted | UX |
|---|---|---|
'bearer' |
type: http, scheme: bearer |
Swagger/Scalar prepend Bearer automatically — user pastes the raw JWT only |
'apiKey' |
type: apiKey, in: header, name: Authorization |
No auto-prefix — user types the full header value by hand, e.g. Bearer {token} |
Omit auth (or set auth: false) to disable auth entirely.
ℹ️ Users only paste the raw JWT — Swagger/Scalar prepend
Bearerto theAuthorizationheader automatically because the scheme istype: http, scheme: bearer(per the OpenAPI standard). Only the legacytype: apiKeystyle forces users to type theBearerprefix by hand.
Or spell it out manually (explicit config always wins over auth):
openapiMeta: {
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer' },
},
security: ['bearerAuth'], // default for every route
}
The module injects securitySchemes via Nitro's $global.components.securitySchemes
into every generated ?meta, so /_openapi.json carries it and the UIs render Authorize.
Per-route override (wins over global):
// server/api/me.get.ts — protected
/**
* @security bearerAuth
*/
// server/api/auth/login.post.ts — public, opt out of the global default
import { defineOpenAPIMeta } from 'nuxt-openapi-meta/dist/runtime/utils'
export const openAPIMeta = defineOpenAPIMeta({
security: [],
})
Advanced: openAPIMeta.securitySchemes merges over the global one for that route,
and openAPIMeta.$global passes through to Nitro (e.g. extra components.schemas),
deep-merged under the global securitySchemes.
More schemes besides bearerAuth
securitySchemes is a free-form map — key names are yours to choose, one entry
per auth method your API supports:
openapiMeta: {
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer' },
basicAuth: { type: 'http', scheme: 'basic' },
apiKey: { type: 'apiKey', in: 'header', name: 'X-API-Key' },
// apiKey can also live in query or cookies:
// sessionCookie: { type: 'apiKey', in: 'cookie', name: 'session' },
oauth2: {
type: 'oauth2',
flows: {
authorizationCode: {
authorizationUrl: 'https://example.com/oauth/authorize',
tokenUrl: 'https://example.com/oauth/token',
scopes: { read: 'Read access', write: 'Write access' },
},
},
},
oidc: {
type: 'openIdConnect',
openIdConnectUrl: 'https://example.com/.well-known/openid-configuration',
},
},
security: ['bearerAuth'], // global default; per-route `@security apiKey` overrides it
}
Notes:
security: ['bearerAuth', 'apiKey']means OR (either one unlocks the route). Thestring[]shorthand always emits empty scopes ([{ name: [] }]), which fits HTTP/API-key schemes; for OAuth2 scopes per route, declare that route's owndefineRouteMetainstead.- The
auth: trueshorthand only covers the Bearer case — everything above needs explicitsecuritySchemes.
Manual override
If a route file already contains defineRouteMeta(...), the module skips it. Set overwrite: true to force regeneration.
Module options
export interface ModuleOptions {
routesDirs?: string[] // default ['server/api', 'server/routes'] (whichever exist)
routesDir?: string // single dir (backward compat, overrides the default)
enabled?: boolean // default true
tagMap?: Record<string, string> // e.g. { '/api/auth': 'Auth' }
defaultErrors?: Array<{ status: number, description: string }>
overwrite?: boolean // default false
detectCreateError?: boolean // default true — parse createError({ statusCode })
securitySchemes?: Record<string, unknown> // e.g. { bearerAuth: { type: 'http', scheme: 'bearer' } }
security?: string[] // default security for every route, e.g. ['bearerAuth']
auth?: boolean | { name?: string, description?: string, mode?: 'bearer' | 'apiKey' } // optional shorthand, omit/false = off
}
Both Nuxt server dirs are scanned by default: server/api/** maps to /api/*,
server/routes/** maps to /* (e.g. server/routes/hello.get.ts → GET /hello).
How it works
nitro:confighook registers a rollup plugin scoped toroutesDir+*.(get|post|put|patch|delete|all).ts.- Nitro feeds
/_openapi.jsonfrom virtualserver-handlers-meta, which doesimport XMeta from "<handler>?meta"— and Nitro's ownnitro:handlers-metaplugin resolves?metabyreadFile-ing the handler from disk. A plaintransform()injection into the handler module therefore never reaches the meta pipeline (this is why the module does not injectdefineRouteMetainto your source). - Instead, our plugin (user
rollupConfig.pluginsrun first) intercepts<handler>?metaand serves a virtual moduleexport default <generated meta>:parseJSDoc(code)→jiti.import(id)→ schema-to-JSON-Schema →buildOpenAPI(...). - Files already containing
defineRouteMetaare left alone (unlessoverwrite: true), so Nitro extracts the author's own metadata.
Notes:
defineRouteMetametadata is read by Nitro from?metavirtual modules loaded straight from disk — hence interception instead of source injection, and handler files stay untouched (zero runtime overhead).- Schema loading never fails the build: route files are executed via
jitiwith Nitro/h3 auto-imports (defineEventHandler, …) stubbed; if import throws, the module falls back to JSDoc-only metadata. - File-system params:
[id]→:id,[...slug]→:slug*,index→ parent path.
Development
npm install
npm run dev:prepare
npm run dev # playground
npm run test # vitest
npm run lint
💖 Support the project
If this project is useful to you, consider buying me a coffee! ☕
Your support motivates me to keep building and maintaining open-source projects! 🚀
Other ways to support:
- ⭐ Star the project on GitHub
- 🐛 Report bugs or suggest new features
- 🔀 Contribute code via Pull Request
- 📢 Share the project with the community
Related Packages
Runtime-validated contracts, status-typed clients, and OpenAPI for Nuxt server r...
Google Identity Services integration for Nuxt 3 & 4 with a simple composable and...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.2.2 | ^3.0 | MIT | |
| 0.2.1 | ^3.0 | MIT | |
| 0.1.2 | ^3.0 | MIT | |
| 0.1.1 | ^3.0 | MIT |