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.

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.

Responses are not filtered for you.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:

HelperWhat 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

HelperDescription
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.

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.