npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nuxt-openapi-meta

v0.2.2

Published

Auto-generate Nitro defineRouteMeta (OpenAPI) for Nuxt server routes from JSDoc + Zod/Valibot schemas

Readme

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": "[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>: 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: '[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.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! ☕

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