Nuxt module that brings schema-validated API handlers to your server routes and auto-generates a fully typed client API object for the frontend.
defineSchemaHandler— declareinput(params, query, body) andoutputschemas on your Nitro handlers; validation runs automatically at runtime via Standard Schema v1- Generated
apiclient — for everydefineSchemaHandlerroute, the module generates a typed client object auto-imported everywhere in your Nuxt app - TanStack Query integration (optional) —
useQuery,fetchQuerywith reactive cache keys - Nuxt-native —
useFetchand$fetchvariants always available - Cache key utilities —
key()returns a hierarchical key (["structure", id, "invoices"]) enabling precise cache invalidation - Custom fetch —
setApiFetch()to override the underlying fetch function globally (auth interceptors, token refresh, etc.) - Schema access —
schema.params,schema.query,schema.bodyon each endpoint for form validation reuse (works with any Standard Schema library) - OpenAPI metadata — optional Nitro plugin that exposes route schemas as OpenAPI docs
- MCP server — expose your API as an MCP tool server for AI agents, with optional OAuth 2.0 authentication (auth: alpha)
npx nuxi module add @creatiwity/nuxt-schemaOr manually:
npm install -D @creatiwity/nuxt-schema// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@creatiwity/nuxt-schema'],
})If you want useQuery and fetchQuery, install @tanstack/vue-query and set up a Nuxt plugin:
npm install @tanstack/vue-query// plugins/vue-query.ts
import type { DehydratedState, VueQueryPluginOptions } from '@tanstack/vue-query'
import { useState } from '#imports'
import { dehydrate, hydrate, QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
export default defineNuxtPlugin((nuxt) => {
const vueQueryState = useState<DehydratedState | null>('vue-query')
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 60000 } },
})
nuxt.vueApp.use(VueQueryPlugin, { queryClient } as VueQueryPluginOptions)
if (import.meta.server) {
nuxt.hooks.hook('app:rendered', () => {
vueQueryState.value = dehydrate(queryClient)
})
}
if (import.meta.client) {
hydrate(queryClient, vueQueryState.value)
}
})The module auto-detects @tanstack/vue-query in your project and adds useQuery/fetchQuery to the generated client.
By default all generated API calls use Nuxt's global $fetch. You can replace it with a custom instance via setApiFetch, which is auto-imported:
// plugins/auth-fetch.ts
export default defineNuxtPlugin(() => {
const authFetch = $fetch.create({
async onResponseError(context) {
if (context.response.status === 401 && typeof context.options.retry !== 'number') {
// Guard: if retry is already a number, a retry is in progress — avoid infinite loop
// Refresh the token, then let ofetch retry the original request automatically
await $fetch('/api/auth/refresh', { method: 'POST' })
context.options.retry = 1
context.options.retryStatusCodes = [401]
context.options.retryDelay = 0
}
},
})
// All API calls (useQuery, fetchQuery, useFetch, $fetch) now go through authFetch
setApiFetch(authFetch)
})The override applies to every method on every generated endpoint: useQuery, fetchQuery, $fetch, and useFetch (via its $fetch option). Individual useFetch calls can still be overridden further by passing $fetch inside their fetchOptions.
Place schemas in shared/schemas/ so they are accessible on both server and client (Nuxt auto-imports them via the #shared alias).
// shared/schemas/invoices.ts
import z from 'zod/v4'
export const invoicesParams = z.object({ id: z.string() })
export const invoicesQuery = z.object({
page: z.coerce.number().optional(),
query: z.string().optional(),
})
export const invoicesResponse = z.discriminatedUnion('status', [
z.strictObject({
status: z.literal(200),
data: z.strictObject({ invoices: z.array(z.string()) }),
}),
z.strictObject({
status: z.literal(404),
data: z.strictObject({ error: z.string() }),
}),
])// server/api/structure/[id]/invoices.get.ts
import { invoicesParams, invoicesQuery, invoicesResponse } from '#shared/schemas/invoices'
export default defineSchemaHandler({
input: {
params: invoicesParams,
query: invoicesQuery,
},
output: invoicesResponse,
}, ({ params, query }) => {
return {
status: 200 as const,
data: { invoices: [`invoice-${params.id}-page${query.page ?? 1}`] },
}
})defineSchemaHandler validates params, query, and body at runtime. Invalid input returns a descriptive error. The output is also validated — a mismatch returns 500.
api and useApi are auto-imported everywhere in your Nuxt app. The API tree mirrors your file structure: dynamic segments [id] become $id, and the HTTP method becomes the terminal node $get / $post / etc.
server/api/structure/[id]/invoices.get.ts
→ api.structure.$id.invoices.$get
<script setup lang="ts">
// TanStack reactive query
const { data, isPending } = api.structure.$id.invoices.$get.useQuery({
params: { id: 'abc' },
query: { page: 1 },
})
// Nuxt native
const { data } = api.structure.$id.invoices.$get.useFetch({
params: { id: 'abc' },
})
</script>// Reactive query (TanStack) — re-fetches when params/query change
const { data, isPending } = api.structure.$id.invoices.$get.useQuery(
{ params: { id: 'abc' }, query: { page: 1 } },
queryOptions?, // Omit<UseQueryOptions, 'queryKey' | 'queryFn'>
)
// Imperative fetch (TanStack) — for prefetch or event handlers
const result = await api.structure.$id.invoices.$get.fetchQuery(
queryClient,
{ params: { id: 'abc' } },
queryOptions?,
)
// Nuxt native composable
const { data, pending } = api.structure.$id.invoices.$get.useFetch(
{ params: { id: 'abc' }, query: { page: 1 } },
fetchOptions?,
)
// Raw fetch
const data = await api.structure.$id.invoices.$get.$fetch({ params: { id: 'abc' } })
// Cache key — params are interleaved with path segments for hierarchical invalidation
const key = api.structure.$id.invoices.$get.key({ params: { id: 'abc' }, query: { page: 1 } })
// → ["structure", "abc", "invoices", { page: 1 }]
// Invalidate all queries for this structure, regardless of sub-resource or query params
await queryClient.invalidateQueries({ queryKey: ["structure", "abc"] })
// Invalidate all invoices queries for this structure (any page/query)
await queryClient.invalidateQueries({ queryKey: ["structure", "abc", "invoices"] })
// Schema access — reuse schemas for form validation (works with any Standard Schema library)
const querySchema = api.structure.$id.invoices.$get.schema.query
querySchema.parse({ page: '2' }) // → { page: 2 }Type rules for GET options:
paramsis required when the route has dynamic segments (e.g.[id])queryis optional at the wrapper level; field-level required/optional is controlled by your schema
// Reactive mutation (TanStack)
const { mutate, isPending } = api.orders.$post.useMutation(mutationOptions?)
mutate(body)
// Nuxt native
const { data } = await api.orders.$post.useFetch(body, fetchOptions?)
// Raw fetch
await api.orders.$post.$fetch(body)For endpoints with dynamic params:
await api.structure.$id.orders.$post.$fetch(body, { params: { id: 'abc' } })The third argument to defineSchemaHandler is optional:
defineSchemaHandler(schema, handler, {
// Override the H3 handler factory (useful for testing)
defineHandler?: typeof defineEventHandler,
// Called when input or output validation fails — use to log or report
onValidationError?: (type: 'params' | 'query' | 'body' | 'output', result, event) => void,
// Called when the handler throws an H3Error
onH3Error?: (h3Error, event) => void,
// Called when the handler throws any other error
onHandlerError?: (error, event) => void,
})// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@creatiwity/nuxt-schema'],
nuxtSchema: {
// Enables OpenAPI metadata extraction via a Nitro Rollup plugin.
// Requires nitro.experimental.openAPI: true in your nuxt.config.
enabled: false,
mcp: {
// Expose a Streamable HTTP MCP server at the configured path.
enabled: false,
// Server name and version reported to MCP clients.
name: 'my-app',
version: '1.0.0',
// 'opt-out' (default): all defineSchemaHandler routes are exposed as MCP tools
// unless mcp: false is set on the handler.
// 'opt-in': only handlers with mcp: true are exposed.
mode: 'opt-out',
// Path where the MCP endpoint is mounted.
path: '/_mcp',
// Authentication — see "MCP Authentication" section below.
auth: undefined,
},
},
})The MCP server exposes your defineSchemaHandler routes as Model Context Protocol tools, allowing AI agents (Claude Desktop, Cursor, etc.) to call your API directly.
// nuxt.config.ts
nuxtSchema: {
mcp: {
enabled: true,
name: 'my-app',
mode: 'opt-out', // all routes exposed by default
},
}Connect any MCP client to http://localhost:3000/_mcp.
// Explicitly exposed (useful in opt-in mode)
defineSchemaHandler({ mcp: true, ... }, handler)
// Explicitly hidden (useful in opt-out mode)
defineSchemaHandler({ mcp: false, ... }, handler)
// Custom tool name
defineSchemaHandler({ mcp: true, mcpName: 'list-invoices', ... }, handler)Alpha feature — the authentication layer has not been extensively tested in production. APIs and behaviour may change in a future minor release.
Three authentication modes are available. Without auth configured, the MCP endpoint is publicly accessible.
mcp: {
enabled: true,
auth: { type: 'bearer', token: process.env.MCP_SECRET },
}Clients must send Authorization: Bearer <token> on every request.
mcp: {
enabled: true,
auth: {
type: 'jwt',
issuer: 'https://auth.example.com', // must expose /.well-known/openid-configuration
audience: 'my-api', // optional
},
}The module validates incoming Bearer tokens against the provider's userinfo endpoint (result cached 5 min). It also exposes GET /.well-known/oauth-protected-resource so MCP clients can discover the authorization server automatically.
Use this when your authorization server is not directly reachable by the MCP client, or when you need to insert custom logic (tenant routing, custom scopes, etc.).
// nuxt.config.ts
mcp: {
enabled: true,
auth: { type: 'oauth' },
}Create server/mcp-auth.ts to implement the two required callbacks:
// server/mcp-auth.ts
export default defineMcpAuthHandler({
// Return the URL the user should be redirected to for login.
// callbackUrl is the URL your provider should redirect back to (/_mcp/callback).
getAuthorizationUrl(callbackUrl, state) {
return `https://my-platform.com/login?redirect_uri=${callbackUrl}&state=${state}`
},
// Exchange the external provider's authorization code for user claims.
// Return an object with at least { sub: string }.
async exchangeCode(event, code) {
const user = await myPlatform.verifyCode(code)
return { sub: user.id, email: user.email }
},
})The module exposes the full OAuth 2.0 PKCE flow automatically:
| Endpoint | Description |
|---|---|
GET /.well-known/oauth-protected-resource |
RFC 9728 — points MCP clients to the auth server |
GET /.well-known/oauth-authorization-server |
RFC 8414 — auth server metadata |
GET /_mcp/authorize |
Starts the authorization flow |
GET /_mcp/callback |
Receives the external provider's callback |
POST /_mcp/token |
Exchanges the auth code for an access token |
Access tokens, auth sessions, and auth codes are stored via Nitro Storage. In development the default driver is in-memory. For multi-instance deployments (e.g. serverless/Azure Functions), configure a shared storage driver:
// nuxt.config.ts
nitro: {
storage: {
'mcp-auth': {
driver: 'redis',
url: process.env.REDIS_URL,
},
},
},Any unstorage driver that supports TTL (Redis, Cloudflare KV, Vercel KV, …) works out of the box.
At nuxi prepare / nuxi dev startup, the module:
- Scans
server/api/for*.get.ts,*.post.ts,*.put.ts,*.patch.ts,*.delete.ts - Filters to files that contain
defineSchemaHandler - Parses each handler's first argument to extract schema variable names and their import sources
- Writes
.nuxt/schema-api/<endpoint>.ts— one typed file per endpoint - Writes
.nuxt/schema-api.ts— theapitree that imports all endpoints and is registered as an auto-import
During development, builder:watch triggers regeneration whenever a route handler or a shared/schemas/** file changes.
The generator traces schema imports to resolve types. Schemas must be importable from code that runs on the client (no server-only imports). Using shared/schemas/ is the recommended pattern:
// ✅ Accessible on both client and server
import { mySchema } from '#shared/schemas/foo'
import { mySchema } from '~/shared/schemas/foo'
// ❌ Server-only — the generator cannot import this on the client
import { mySchema } from '~/server/utils/private-schema'# Install dependencies
bun install
# Generate type stubs and prepare playground
npm run dev:prepare
# Start playground dev server
npm run dev
# Build the playground
npm run dev:build
# Run ESLint
npm run lint
# Run Vitest
npm run test
npm run test:watch
# Type check
npm run test:types
# Release
npm run release