nuxt-openapi-meta

Auto-generate Nitro defineRouteMeta (OpenAPI) for Nuxt server routes from JSDoc + Zod/Valibot schemas
688
Install
npm install nuxt-openapi-meta
Latest Version:0.2.2
Vue:3
License:MIT
Last Updated:Sep 20, 2026
Links: GitHub  ·  npm
Maintainer: davidthehero

nuxt-openapi-meta

npm version npm downloads License Nuxt

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 defineRouteMeta through Nitro's ?meta pipeline (no source files touched)
  • 🛡 Respects existing defineRouteMeta (skip unless overwrite: true)
  • ⚙️ tagMap, defaultErrors, createError() auto-detect, generic @param support
  • 🔐 JWT/Bearer auth: global securitySchemes + security with 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>: query header path cookie
  • :type: string (default) number integer/int boolean/bool array object
  • required present → required: true, else false (path is always required)
  • Entries override auto-detected params with the same in:name (so @param path id … documents the :id segment instead of duplicating it); openAPIMeta.parameters wins 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 (or openAPIMeta.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 to schema/example untouched.

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.parse directly — readValidatedBody(event, bodySchema.parse).
  • Valibot has no .parse method — 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):

  • zod v4 — native z.toJSONSchema() / schema.toJSONSchema()
  • zod v3 — via zod-to-json-schema
  • valibot v1 — via @valibot/to-json-schema

Authentication (JWT / Bearer)

Swagger/Scalar only show the Authorize button when the spec defines both:

  1. components.securitySchemes (what auth exists) and
  2. security on 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 Bearer to the Authorization header automatically because the scheme is type: http, scheme: bearer (per the OpenAPI standard). Only the legacy type: apiKey style forces users to type the Bearer prefix 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). The string[] shorthand always emits empty scopes ([{ name: [] }]), which fits HTTP/API-key schemes; for OAuth2 scopes per route, declare that route's own defineRouteMeta instead.
  • The auth: true shorthand only covers the Bearer case — everything above needs explicit securitySchemes.

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

  1. nitro:config hook registers a rollup plugin scoped to routesDir + *.(get|post|put|patch|delete|all).ts.
  2. Nitro feeds /_openapi.json from virtual server-handlers-meta, which does import XMeta from "<handler>?meta" — and Nitro's own nitro:handlers-meta plugin resolves ?meta by readFile-ing the handler from disk. A plain transform() injection into the handler module therefore never reaches the meta pipeline (this is why the module does not inject defineRouteMeta into your source).
  3. Instead, our plugin (user rollupConfig.plugins run first) intercepts <handler>?meta and serves a virtual module export default <generated meta>: parseJSDoc(code) → jiti.import(id) → schema-to-JSON-Schema → buildOpenAPI(...).
  4. Files already containing defineRouteMeta are left alone (unless overwrite: true), so Nitro extracts the author's own metadata.

Notes:

  • defineRouteMeta metadata is read by Nitro from ?meta virtual 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 jiti with 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! ☕

Buy 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

Made with ❤️ using Nuxt & Nitro

GitHub Stars GitHub Forks

Related Packages

nuxt-api-contract

Type-safe API contracts between Nitro server routes and the Nuxt client.

3,144 0
nuxt-endpoints

Runtime-validated contracts, status-typed clients, and OpenAPI for Nuxt server r...

2,479 5
nuxt-yup

yup implementation for nuxt

2,981 4
nuxt-proxy-request

A http proxy module for nuxt(3) powered by h3-proxy.

35,748 15
nuxt-google-auth

Google Identity Services integration for Nuxt 3 & 4 with a simple composable and...

19,921 3

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