Handler Overrides

Sometimes a generated route is not enough — a resource needs an extra action (/users/:id/stats, /orders/:id/checkout) or a route with custom logic. Write it as a regular Nitro file with createEndpoint() bound to the resource: the auto-api pipeline (authentication, plugin middleware, the resource's permission gate, validation) runs before your handler.

Handler Overrides

Sometimes a generated route is not enough — a resource needs an extra action (/users/:id/stats, /orders/:id/checkout) or a route with custom logic. Write it as a regular Nitro file with createEndpoint() bound to the resource: the auto-api pipeline (authentication, plugin middleware, the resource's permission gate, validation) runs before your handler.

defineAutoApiHandler was removed. See Upgrading for the one-line migration.

Basic usage

// server/api/users/[id]/stats.get.ts
import { count, eq } from 'drizzle-orm'
import { posts, comments } from '~~/server/database/schema'

export default createEndpoint({
  resource: 'users',
  operation: 'get', // gated by the resource's `read` permission

  async handler(ctx) {
    // Row visibility: tenant, listFilter, soft delete (404) and objectLevel (403)
    const user = await findAuthorizedRow(ctx, 'users', ctx.params.id)

    const [[postStats], [commentStats]] = await Promise.all([
      ctx.db.select({ count: count() }).from(posts).where(eq(posts.userId, user.id)),
      ctx.db.select({ count: count() }).from(comments).where(eq(comments.userId, user.id)),
    ])

    // Responses are not filtered for you: strip hidden and unreadable fields as the generated routes do
    const visible = await filterReadableFields(filterHiddenFields(user, ctx), ctx)
    return { ...visible, stats: { postCount: postStats.count, commentCount: commentStats.count } }
  },
})
GET /api/users/123/stats
  1. Authentication and the pre-auth / post-auth plugin middleware run.
  2. The resource's permission for the operation is checked (get/list → read, create → create, …) — an undeclared permission is denied.
  3. Query validation runs.
  4. Your handler runs.
The pipeline decides whether the caller may use the operation; it does not load rows for you. A handler that queries ctx.db directly sees every tenant's rows. Load rows through findAuthorizedRow / findAuthorizedRows / rowScope so tenant scoping, listFilter, soft delete and objectLevel apply exactly as on the generated routes — and return them through filterHiddenFields / filterReadableFields, since createEndpoint does not filter responses.

The context

ctx is a HandlerContext plus body, queryParams and adapter:

interface HandlerContext {
  db: any // Drizzle instance
  schema: Record<string, any> // every registered table
  user: AuthUser | null
  permissions: string[]
  params: Record<string, string> // route params
  query: Record<string, any>
  validated: { body?: any, query?: any }
  event: H3Event
  resource: string
  operation: 'list' | 'get' | 'create' | 'update' | 'delete' | 'aggregate' | 'm2m'
  objectLevelCheck?: (row: any, ctx: HandlerContext) => boolean | Promise<boolean>
  listFilter?: (table: any, ctx: HandlerContext) => SQL | undefined
  tenant?: { id: string | number, field: string, canAccessAllTenants: boolean }
}

Writes

Strip the server-owned columns from client input the same way the generated routes do, and stamp the tenant:

// server/api/posts/import.post.ts
import { z } from 'zod'
import { posts } from '~~/server/database/schema'

export default createEndpoint({
  resource: 'posts',
  operation: 'create',
  body: z.object({ items: z.array(z.record(z.string(), z.unknown())).max(100) }),

  async handler(ctx) {
    const protectedCols = protectedFieldsFor(ctx, 'posts', posts, 'create')
    const tenantCol = tenantWriteField(ctx, 'posts', posts)
    const values = ctx.body.items.map(item => ({
      ...stripProtectedFields(item, protectedCols),
      ...(tenantCol ? { [tenantCol]: ctx.tenant!.id } : {}),
    }))
    const rows = await insertReturning(ctx.db, posts, values) // .returning() on SQLite/Postgres, read-back on MySQL
    return filterHiddenFields(rows, ctx)
  },
})

Opening one operation on an otherwise closed resource

A resource can keep an operation closed (create: false) and open it for one named endpoint through authorization.custom:

// the resource's auth config
export const ordersAuth: ResourceAuthConfig = {
  permissions: { read: 'member', create: false },
  custom: {
    checkout: { permissions: { create: 'member' } },
  },
}
// server/api/orders/checkout.post.ts
export default createEndpoint({
  resource: 'orders',
  operation: 'create',
  endpointName: 'checkout', // → custom.checkout.permissions.create decides
  async handler(ctx) { /* … */ },
})

An operation the named gate does not declare falls back to the resource's own gate — never to "allowed".

Object-level checks before the handler

authorize runs after validation and before the handler; return false for a 403:

export default createEndpoint({
  resource: 'webhooks',
  operation: 'update',
  authorize: async ctx => !!(await findAuthorizedRow(ctx, 'webhooks', ctx.params.id)),
  async handler(ctx) { /* … */ },
})

Skipping steps

createEndpoint({
  resource: 'posts',
  skipAuthorization: true, // the permission gate does not run — you own authorization
  skipValidation: true, // skip the resource's QUERY schema (bodies are only checked against your own `body`)
  handler: async (ctx) => { /* … */ },
})

skipAuthorization turns off the permission gate only; the handler still has to scope the rows it reads.

When to use a handler override

✅ Custom actions, aggregations the /aggregate route cannot express, file uploads, third-party calls.

❌ Plain CRUD (use the generated routes), extra validation (use validation), authorization rules (use the auth config), field selection (?fields=).

See also

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.