Custom Endpoints
Custom Endpoints
@websideproject/nuxt-auto-api provides two approaches for building custom server endpoints that integrate with the auto-api pipeline: createEndpoint() for full pipeline integration, and standalone helpers for lightweight use in regular Nitro handlers.
createEndpoint
createEndpoint() is the recommended way to build custom endpoints. It handles the full middleware pipeline, Zod validation, response formatting, and serialization.
Resource-Bound Endpoint
When you specify a resource, the endpoint runs the auto-api pipeline — authentication, plugin middleware, the
resource's permission for operation (undeclared = denied), query validation — before your handler. The request
body is never checked against the resource's table schema (that describes a row, and a custom endpoint's body is
its own shape): declare body to validate it.
// server/api/users/[id]/stats.get.ts
import { count, eq } from 'drizzle-orm'
import { posts } from '~~/server/database/schema' // `~~` is the project root in Nuxt 4 server code
export default createEndpoint({
resource: 'users',
operation: 'get',
async handler(ctx) {
const user = await findAuthorizedRow(ctx, 'users', ctx.params.id) // 404 / 403 like GET /api/users/:id
const [{ count: postCount }] = await ctx.db.select({ count: count() }).from(posts).where(eq(posts.userId, user.id))
const visible = await filterReadableFields(filterHiddenFields(user, ctx), ctx) // see "Responses" below
return { ...visible, postCount }
},
})
The gate answers "may this caller use the operation?"; loading rows is still your query. Use the
row access helpers so tenant scoping, listFilter, soft delete and objectLevel apply.
createEndpoint returns what the handler returns; findAuthorizedRow and
ctx.db give whole rows. Pass rows through filterHiddenFields(rows, ctx) (the resource's hiddenFields) and
await filterReadableFields(rows, ctx) (fields[x].read rules) before returning them — otherwise a
passwordHash the generated routes never show is in your response.Route params: a resource-bound endpoint gets ctx.params.id and ctx.params.relation only (the names the
generated routes use). Read any other segment of your file route with getRouterParam(event, 'slug') — the
handler's second argument is the event.
createEndpoint and every helper on this page are auto-imported in server code; they can also be imported from
@websideproject/nuxt-auto-api/utils.
Standalone Endpoint
Without a resource, the caller is resolved (authentication and context extenders run, ctx.user /
ctx.tenant / ctx.permissions are set, ctx.schema holds every registered table) and plugin middleware runs —
but nothing is authorized for you. Gate it with authorize, or in the handler:
// server/api/health.get.ts
export default createEndpoint({
async handler(ctx) {
return { status: 'ok', timestamp: new Date().toISOString() }
},
responseFormat: 'raw', // Don't wrap in { data }
})
Body and Query Validation
Use Zod schemas for type-safe input validation:
// server/api/users/invite.post.ts
import { z } from 'zod'
import { users } from '~~/server/database/schema'
export default createEndpoint({
resource: 'users',
operation: 'create',
body: z.object({
email: z.string().email(),
role: z.enum(['user', 'editor', 'admin']).default('user'),
}),
query: z.object({
sendEmail: z.stringbool().default(true), // 'false' → false (z.coerce.boolean would make it true)
}),
async handler(ctx) {
// ctx.body and ctx.queryParams are typed
const { email, role } = ctx.body
const { sendEmail } = ctx.queryParams
const [user] = await insertReturning(ctx.db, users, { email, role })
if (sendEmail) {
await sendInviteEmail(email) // your mailer
}
return filterHiddenFields(user, ctx)
},
})
Response Format
Control how the response is wrapped:
// 'auto' (default) - wraps in { data: result }
createEndpoint({
responseFormat: 'auto',
handler: async () => ({ name: 'Alice' }),
})
// Returns: { data: { name: 'Alice' } }
// 'raw' - passes through as-is
createEndpoint({
responseFormat: 'raw',
handler: async () => ({ name: 'Alice' }),
})
// Returns: { name: 'Alice' }
Transform
Transform the result before sending:
createEndpoint({
handler: async (ctx) => {
return await ctx.db.query.users.findMany() // needs drizzle(…, { schema }); standalone, so nothing is authorized
},
transform: (users, ctx) => ({
users,
total: users.length,
requestedBy: ctx.user?.email,
}),
responseFormat: 'auto',
})
// Returns: { data: { users: [...], total: 5, requestedBy: 'admin@example.com' } }
Named gates (endpointName)
endpointName matches authorization.custom[endpointName] on the resource. Operations that gate declares are
decided by it — so one endpoint can open an operation the resource keeps closed (create: false on a resource
that exposes /checkout). Operations it does not declare fall back to the resource's own permissions.
// resource auth: { permissions: { create: false }, custom: { checkout: { permissions: { create: 'member' } } } }
export default createEndpoint({
resource: 'orders',
operation: 'create',
endpointName: 'checkout',
handler: async (ctx) => { /* … */ },
})
authorize
Runs after the pipeline and Zod validation, before the handler. Return false (or throw) to reject with 403:
export default createEndpoint({
resource: 'webhooks',
operation: 'update',
authorize: async ctx => !!(await findAuthorizedRow(ctx, 'webhooks', ctx.params.id)),
handler: async (ctx) => { /* … */ },
})
Skip Authorization / Validation
createEndpoint({
resource: 'users',
skipAuthorization: true, // the resource's permission gate does not run
skipValidation: true, // skip the resource's QUERY validation (bodies are only checked against your own `body`)
handler: async (ctx) => { /* ... */ },
})
Row access
The generated routes load rows through these helpers; use them in custom endpoints to get the same answers:
| Helper | What it does |
|---|---|
findAuthorizedRow(ctx, resource, id, { softDeleted? }) | One row by primary key. Invisible or missing → 404; fails objectLevel → 403 |
findAuthorizedRows(ctx, resource, ids) | The subset of ids the caller may see |
rowScope(ctx, resource, table, { softDeleted? }) | The SQL condition (tenant + listFilter + soft delete) for your own queries; undefined when nothing applies |
passesObjectLevel(ctx, resource, row) | Run the resource's objectLevel on a row |
assertResourcePermission(resource, op, ctx, { recordId? }) | 401/403 unless the caller holds op on any registered resource |
checkPermission(op, authConfig, ctx) / getAuthConfig(ctx, resource) | Boolean permission check against a resource's effective config |
await readableColumns(ctx, resource, table) / hiddenFieldsOf(ctx, resource) | Columns the caller may read (async) / hidden columns |
filterHiddenFields(rows, ctx) / await filterReadableFields(rows, ctx) | Strip hidden fields / fields the caller may not read from what you return |
protectedFieldsFor(ctx, resource, table, 'create' | 'update') + stripProtectedFields(data, set) | Drop server-owned columns (soft delete, audit, tenant, pk) from client input |
tenantWriteField(ctx, resource, table) | The tenant column to stamp on writes, if any |
insertReturning(db, table, values) / updateReturning(db, table, id, set) | Insert/update and return the rows on every dialect (MySQL has no RETURNING) |
softDeleted is 'exclude' (default), 'include' or 'only'.
// server/api/posts/recent.get.ts
import { and, desc, gte } from 'drizzle-orm'
import { posts } from '~~/server/database/schema'
export default createEndpoint({
resource: 'posts',
operation: 'list',
async handler(ctx) {
const since = new Date(Date.now() - 7 * 86_400_000)
const scope = rowScope(ctx, 'posts', posts)
return ctx.db.select().from(posts)
.where(and(gte(posts.createdAt, since), scope))
.orderBy(desc(posts.createdAt))
.limit(20)
},
})
When you apply rowScope yourself, the resource's objectLevel (a per-row function) is not part of the SQL —
filter with passesObjectLevel if the resource has one.
Standalone Helpers
For regular Nitro event handlers where you don't need the full pipeline, use the standalone helpers:
getAutoApiContext
Resolve the caller (authentication + context extenders) in a plain Nitro handler. Like a standalone
createEndpoint, it authorizes nothing. Pass { resource } so the permission and row-access helpers read that
resource's config:
// server/api/dashboard.get.ts
import { count } from 'drizzle-orm'
import { posts, users } from '~~/server/database/schema'
export default defineEventHandler(async (event) => {
const ctx = await getAutoApiContext(event)
if (!ctx.user) throw createError({ statusCode: 401 })
const userCount = await ctx.db.select({ count: count() }).from(users)
const postCount = await ctx.db.select({ count: count() }).from(posts)
return respondWith({
users: userCount[0].count,
posts: postCount[0].count,
})
})
validateBody / validateQuery
Validate request input with Zod and get typed results:
// server/api/contact.post.ts
import { z } from 'zod'
const ContactSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(10),
})
export default defineEventHandler(async (event) => {
const body = await validateBody(event, ContactSchema)
// body is typed as { name: string, email: string, message: string }
await sendContactEmail(body) // your mailer
return respondWith({ sent: true })
})
respondWith / respondWithList / respondWithError
Standardized response formatting with serialization:
// Single item → { data: { id: 1, name: 'Alice', createdAt: '2025-01-01T00:00:00.000Z' } }
return respondWith(user)
// List with metadata → { data: [...], meta: { total: 100, page: 1, limit: 20 } }
return respondWithList(rows, { total: 100, page: 1, limit: 20 })
// Error (throws) → 404 / 422 with `data.details`
respondWithError(404, 'User not found')
respondWithError(422, 'Validation failed', { field: 'email', message: 'Already taken' })
getDb
Quick access to the database and adapter:
export default defineEventHandler(async (event) => {
const { db, adapter } = getDb()
// All-or-nothing on every engine — a transaction, or one db.batch() on D1
await atomicWrites([
tx => tx.insert(orders).values(orderData),
tx => tx.insert(orderItems).values(itemsData),
], adapter)
return respondWith({ success: true })
})
adapter.atomic(fn) is a real transaction everywhere except D1, where it runs fn directly (see
Database Adapters).
All Available Helpers
| Helper | Description |
|---|---|
createEndpoint(options) | Full pipeline endpoint with Zod validation |
getAutoApiContext(event, { resource?, operation? }) | Caller context (user, tenant, permissions, db, schema) for plain handlers — authorizes nothing |
validateBody(event, zodSchema) | Validate request body, throw 400 on failure |
validateQuery(event, zodSchema) | Validate query params, throw 400 on failure |
respondWith(data) | Wrap in { data } with serialization |
respondWithList(data, meta?) | Wrap in { data, meta } with serialization |
respondWithError(status, message, details?) | Throw standardized error |
getDb() | Get { db, adapter } |
await getResourceSchema(name) | Get table schema from registry |
await getRegistry() | Get full resource registry |
serialize(data) | Serialize dates to ISO strings |
filterHidden(data, ctx) | Alias of filterHiddenFields: remove the resource's hidden fields |
See Row access for the authorization helpers.
Migration from defineAutoApiHandler
defineAutoApiHandler was removed; execute becomes handler, and the handler returns the payload (the
{ data } wrapper is added for you). See Upgrading.
Database Adapters
@websideproject/nuxt-auto-api supports multiple database engines through a unified adapter layer. Each adapter normalizes engine-specific behavior like transactions, batch operations, and mutation count parsing.
Multi-Tenancy
Build multi-tenant SaaS APIs where every organization only ever sees its own rows.