Validation

@websideproject/nuxt-auto-api uses Zod for validation with automatic schema generation from Drizzle tables via drizzle-zod.

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 (email is required, name is 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:

RouteBodyValidated 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

  1. Use drizzle-zod for base schemas - keeps validation in sync with database
  2. Extend, don't replace - build on auto-generated schemas
  3. Validate at boundaries - user input, not internal operations
  4. Use TypeScript - get type inference from Zod schemas
  5. 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()),
})

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.