Handler Overrides
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
- Authentication and the
pre-auth/post-authplugin middleware run. - The resource's permission for the operation is checked (
get/list→read,create→create, …) — an undeclared permission is denied. - Query validation runs.
- Your handler runs.
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
- Custom Endpoints — every option and helper
- Lifecycle Hooks — change the generated routes' behaviour without replacing them
Configuration Reference
Every module option, every resource registration field and every authorization key in one place. The guides explain when to use them; this page lists what exists.
Cloudflare D1
Nuxt Auto API fully supports Cloudflare D1, allowing you to deploy your API to the edge with a serverless SQLite database.