nuxt-openapi-meta
v0.2.2
Published
Auto-generate Nitro defineRouteMeta (OpenAPI) for Nuxt server routes from JSDoc + Zod/Valibot schemas
Maintainers
Readme
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": "[email protected]" }
*/
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: '[email protected]', name: 'New Student' }
export const responseSchema = UserSchema
export const responseExample = { id: 'user_123', email: '[email protected]' }
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
