Validation
Validation
@websideproject/nuxt-auto-api uses Zod for validation with automatic schema generation from Drizzle tables via drizzle-zod.
Automatic Validation
By default, validation schemas are auto-generated from your Drizzle tables:
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
email: text('email').notNull(),
name: text('name'),
})
This automatically validates:
- Required fields (
emailis required,nameis optional) - Field types (integer, text, boolean, etc.; date columns accept ISO strings)
- Enum values (if defined)
Server-owned columns are not part of the generated schemas — the primary key on update (and on create
when the database generates it: auto-increment, serial, identity), createdAt on update, soft-delete columns, createdBy / updatedBy, the tenant column of a scoped resource, and a
registration's protectedFields. They are never required and never accepted: a body that sends them has them
dropped. The create/update handlers strip them again after validation, so this also holds with a custom
schema.
Custom Validation
For custom rules, create validation schemas with drizzle-zod:
// server/validation/users.ts
import { createInsertSchema } from 'drizzle-zod'
import { z } from 'zod'
import { users } from '../database/schema'
const baseInsertSchema = createInsertSchema(users)
export const usersValidation = {
create: baseInsertSchema.extend({
email: z.string().email().toLowerCase().min(1),
name: z.string().min(2).max(100),
role: z.enum(['user', 'admin', 'editor']).default('user'),
}),
update: baseInsertSchema.partial().extend({
email: z.string().email().toLowerCase().optional(),
name: z.string().min(2).max(100).optional(),
}),
}
Register Validation
Register validation schemas in your module:
// modules/base/index.ts
nuxt.hook('autoApi:registerSchema', (registry) => {
registry.register('users', {
schema: createModuleImport(resolver.resolve('../../server/database/schema'), 'users'),
authorization: createModuleImport(resolver.resolve('./auth'), 'usersAuth'),
validation: createModuleImport(
resolver.resolve('../../server/validation/users'),
'usersValidation'
),
})
})
Validation Errors
Invalid requests return 400 with Zod error details. Zod's async refinements are supported (schemas are parsed
with parseAsync).
Request:
POST /api/users
{
"email": "invalid-email",
"name": "A"
}
Response:
{
"statusCode": 400,
"message": "Validation error",
"data": {
"errors": [
{
"code": "invalid_format",
"format": "email",
"path": ["email"],
"message": "Invalid email address"
},
{
"code": "too_small",
"origin": "string",
"minimum": 2,
"inclusive": true,
"path": ["name"],
"message": "Too small: expected string to have >=2 characters"
}
]
}
}
data.errors is Zod's issues array as-is (Zod 4 shapes shown).
Bulk Requests
Bulk routes validate every item against the same schemas; error paths point at the item:
| Route | Body | Validated against |
|---|---|---|
POST /api/users/bulk | { "items": [{…}, …] } | create per item |
PATCH /api/users/bulk | { "items": [{ "id": 1, "data": {…} }, …] } | update per data |
DELETE /api/users/bulk | { "ids": [1, 2] } | — |
{ "statusCode": 400, "message": "Validation error", "data": { "errors": [{ "path": ["items", 2, "email"], "message": "Invalid email address" }] } }
Query Parameter Validation
The default query schema types the known parameters (page and limit are positive integers) and passes
unknown ones through, so plugins can read their own parameters. It does not enforce the value of limit —
that is clamped to pagination.maxLimit. Field names in filter, sort, fields and include are checked
by the handlers against the columns the caller may read (unknown or hidden → 400).
GET /api/users?page=-1 → 400
GET /api/users?limit=1000 → 200, limit clamped to maxLimit (100)
GET /api/users?sort=-passwordHash → 400 (hidden column)
GET /api/users?limit=50&page=2&sort=-createdAt
Custom Query Validation
A query schema replaces the default one. Query values arrive as strings (use z.coerce), a JSON ?filter=
is parsed before validation, and keys your schema strips are ignored by the handler — keep
.passthrough() unless you mean to disable parameters such as cursor or include:
export const usersValidation = {
// ... create, update schemas
query: z.object({
filter: z.object({
role: z.enum(['user', 'admin', 'editor']).optional(),
verified: z.union([z.boolean(), z.stringbool()]).optional(), // JSON true, or 'true'/'false' from filter[verified]=
}).optional(),
sort: z.enum(['name', '-name', 'createdAt', '-createdAt']).optional(),
limit: z.coerce.number().int().positive().max(50).optional(), // a stricter limit, as a 400
}).passthrough(),
}
Accessing Validated Data
In custom endpoints, a resource-bound createEndpoint runs the resource's
validation; its own Zod body / query schemas give typed ctx.body / ctx.queryParams:
import { z } from 'zod'
export default createEndpoint({
resource: 'users',
operation: 'update',
body: z.object({ email: z.string().email() }),
async handler(ctx) {
ctx.body.email // typed, validated
ctx.validated.query // the resource's query validation
},
})
The request body of a custom endpoint is never checked against the resource's create/update schemas — only
your own body schema applies. skipValidation: true skips the resource's query schema.
Best Practices
- Use drizzle-zod for base schemas - keeps validation in sync with database
- Extend, don't replace - build on auto-generated schemas
- Validate at boundaries - user input, not internal operations
- Use TypeScript - get type inference from Zod schemas
- Test validation - write tests for edge cases
Advanced Patterns
Conditional Validation
const createUserSchema = baseInsertSchema.extend({
email: z.string().email(),
role: z.enum(['user', 'admin']),
}).refine(
(data) => {
// Admin users must have @company.com email
if (data.role === 'admin') {
return data.email.endsWith('@company.com')
}
return true
},
{
message: 'Admin users must use company email',
path: ['email'],
}
)
Async Validation
const createUserSchema = baseInsertSchema.extend({
email: z.string().email(),
}).refine(
async (data) => {
// Check if email already exists (`db`: your own Drizzle instance — this file has no request context)
const existing = await db.query.users.findFirst({
where: eq(users.email, data.email),
})
return !existing
},
{
message: 'Email already exists',
path: ['email'],
}
)
Transform Data
const createUserSchema = baseInsertSchema.extend({
email: z.string().email().transform(val => val.toLowerCase()),
name: z.string().transform(val => val.trim()),
})
Billing & Plan Gating
Subscriptions, plan-gated features and usage quotas on top of auto-api: the payment provider owns the money, a webhook keeps a subscriptions table in sync, a context extender puts the caller's plan on every request, and resources gate on it like on any other permission. The UI uses Nuxt UI's pricing components.
Rate Limiting
Rate limiting is provided as a built-in plugin to protect your API from abuse and ensure fair usage. It registers as pre-auth middleware on the auto-api pipeline: every generated route and every createEndpoint route. Plain Nitro handlers (defineEventHandler) are not covered.