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()with the same signature asuseQuery, forinvalidateQueries - Zod schema access —
zod.params,zod.query,zod.bodyon each endpoint for form validation reuse - OpenAPI metadata — optional Nitro plugin that exposes route schemas as OpenAPI docs
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.
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 — same signature as useQuery (without queryOptions)
const key = api.structure.$id.invoices.$get.key({ params: { id: 'abc' } })
// → ["structure", "$id", "invoices", "$get", { id: "abc" }]
// Partial key — invalidates all queries for this route regardless of query params
await queryClient.invalidateQueries({
queryKey: api.structure.$id.invoices.$get.key({ params: { id: 'abc' } }).slice(0, -1),
})
// Zod schema access — reuse schemas for form validation
const querySchema = api.structure.$id.invoices.$get.zod.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,
},
})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