Home / Blog / A Custom Opinionated Event Handler for Nuxt API Endpoints with Guards And Validation Support
A Custom Opinionated Event Handler for Nuxt API Endpoints with Guards And Validation Support

A Custom Opinionated Event Handler for Nuxt API Endpoints with Guards And Validation Support

Daniel Kelly
Daniel Kelly
Updated: May 29th 2025

How would you like to write Nuxt API endpoints with opinionated features like payload validation and route-specific middleware? In other words, does writing API endpoints like this appeal to you?

// server/api/todos/index.get.ts

import { z } from 'zod'

export default defineApiEventHandler({

    //All validation done here EVERY TIME
  validation: z.object({
    query: z.string().optional(),

    // query string variable coerced to number
    take: z.coerce.number().optional()
  }),

  // ALL route specific middleware defined in ONE place EVERY TIME
  guards: [userIsLoggedInGuard, userIsAdminGuard],

  // Handler with payload values already guaranteed valid
  handler: async (event, { query, take }) => {
    // data paginated by take and query
    return []
  }
})

What about guards that look like this? And are attached to API endpoints as above?

// server/guards/userGuards
export const userIsLoggedInGuard = defineGuard(async (event) => {
    await requireUserSession(event)
})

export const userIsAdminGuard = defineGuard(async (event) => {
    const session = await getUserSession(event)
    if(session.user.role !== 'admin'){
        throw createError({
            statusCode: 403,
            message: `Forbidden. User with role ${session.user.role} doesn't have the proper permissions.`
        })
    }
})

// server/guards/postGuards
export const postBelongsToUserGuard = 
    defineGuard<{ postId: number }>(async (event, payload) => {
  const postId = payload?.postId

  const postBelongsToUser = true // check if post belongs to user

  if (!postBelongsToUser) {
    throw createError({ 
        statusCode: 403, 
        statusMessage: 'Post does not belong to user' 
      })
  }
})

The above is totally possible, type-safe, and actually pretty easy to do! Let me show you an implementation I’ve been experimenting with lately that works great and fits my needs.

Motivation For A Custom Nuxt API Event Handler

Before diving into the how, let’s quickly talk about the why.

Nitro, Nuxt's server engine provides a flexible way to define API endpoints, but as your application grows, you may find yourself repeating logic or writing unnecessary boilerplate for:

  • Validating request payloads
  • Running authorization or other pre-handler checks ("guards")
  • Handling validation errors in a consistent way

Worse, you might end up handling these 2 concerns in different ways across various endpoints creating more mental overhead when reading existing or writing new endpoints.

Also, while Nitro has built in support for global middleware on the server, it lacks the concept of server route middleware (guards).

This custom event handler standardizes these needs! Great! Let’s see how it works!

Define Custom Event Handler Functions in Nuxt

The first step, is to define a custom defineApiEventHandler function. This will be used exactly like the built in defineEventHandler function but will support our customized/opinionated conventions. Since we define it in server/utils directory, it’s auto-imported in all API endpoint files. We’ll define the functionality first for ease of following the code, then we’ll use TypeScript to make it type safe.

// server/utils/baseServerUtils.ts
import { ZodError } from 'zod'

// First we accept either a handler function 
// or a config object ({ validation, guards, handler})
export function defineApiEventHandler(handlerOrConfig) {

    // this allows calling defineApiEventHandler with only a callback function
    // just like defineEventHandler
  if (typeof handlerOrConfig === 'function') {
    return defineEventHandler(handlerOrConfig)
  }

  const { validation, handler, guards } = handlerOrConfig

    // return an event handler like normal
  return defineEventHandler(async (event) => {
    try {
      // get the rawData
      // we'll see getPayload in just a mintue
      const rawData = await getPayload(event)

      // validate the rawData and transform it to the payload
      // we'll see runValidation in just a mintue
      const payload = await runValidation(rawData, validation)

      // run all the guards
      await runGuards(event, payload, guards)

      // run and return the provided handler the handler
      return handler(event, payload)
    }
    catch (err) {

        // If validation throw's a zod error throw a standardized
        // 422 status code for invalid payloads 
        // along with the zod validation errors as data
      if (err instanceof ZodError) {
        throw createError({
          statusCode: 422,
          statusMessage: `Invalid request payload`,
          data: err
        })
      }
      throw err
    }
  })
}

The defineApiEventHandler function isn’t complex. But it serves as a wrapper to call our opinionated validation and guard logic before running the endpoint specific handler.

Payload Handling

What is getPayload doing? It combines the various data sources from an incoming request into a single payload object that can be validated at one time (no need to handle route params, request body, and query params separately - it’s all the request payload).

async function getPayload(event: H3Event) {
  const method = event.method

    //set the payload to any values in the query string
  let payload: Record<string, unknown> = getQuery(event) || {}

    // if a request type that supports a body
    // add the body to the payload
    // prioritize data in the body over data in the query string
    // (if query variable named same as body variable, body wins and query is overridden)
  if (['PUT', 'POST'].includes(method)) {
    const body = await readBody(event)
    payload = {
      ...payload,
      ...body
    }
  }

    // add in route params to the payload
    // prioritize route params over query variables and body data
  return {
    ...payload,
    ...event.context.params
  }
}

Validation Handling

Next, let’s see the implementation of runValidation. With the payload bundled as a single object validating it’s really simple to call zod’s parseAsync function on all incoming data (input).

async function runValidation<T>(data: unknown, validation: z.ZodType<T>) {
  return await validation.parseAsync(data)
}

Running Guards in Parallel

That takes care of validation, what about the runGuards function? It’s only job is to run all the guards in parallel. It’s a guard’s job to throw an error if access to the endpoint should be restricted in some way based on the event or the payload.

async function runGuards<T>(event: H3Event, payload: T, guards?: Guard<T>[]) {
  if (!guards) return
  if (!Array.isArray(guards)) {
    throw createError({
      statusCode: 500,
      statusMessage: `Guards must be an array`
    })
  }
  await Promise.all(guards.map(guard => guard(event, payload)))
}

Also to support defining your guards in a single well-organized server folder named guards add the following to your nuxt.config.ts file. (Otherwise, you’d need to manually import or store guards in the server/utils directory.

nitro: {
    imports: {
    // autoimport all guards from the server/guards directory
    dirs: ['./server/guards']
    }
}

Run The Event Handler

After the validation and guards run in the defineApiEventHandler, the endpoint specific handler is run and given type safe access to the validated payload.

return handler(event, payload)

Support for Nuxt Endpoints Guards

A guard is a function that runs before your main handler. It can check authentication, permissions, or any other precondition. If a guard throws an error, the request is halted.

We’ve seen how to define the guards and how to use the guards in the article intro but where does the defineGuard function come from? It’s defined right along with defineApiEventListener in our utils/baseServerUtils.ts file. It’s a pass-through function that just returns the provided callback but helps keep our guard definitions type safe.

export const defineGuard = <T>(callback: (event: H3Event, payload: T) => Promise<void>) => {
  return callback
}
When calling defineGuard in your IDE we get a properly typed event, and can define the shape of payloads that the guard can be used with

Make the defineApiEventHandler Function TypeSafe

Speaking of type-safe, we can also provide a few custom types to the function defintion to make it easy to pass the right options to defineApiEventHandler, and so that calls the the endpoints with useFetch and $fetch still have dynamically typed responses

import type { EventHandler, EventHandlerRequest, H3Event } from 'h3'
import type { z } from 'zod'

// Guards are simply the return type of the defineGuard function
type Guard<T> = ReturnType<typeof defineGuard<T>>

// the API Event handler takes in the options object
// with validation, guards, and handler
// or a callback function

// and returns an Event Handler (just like all API endpoints)

type ApiEventHandler<
  T extends EventHandlerRequest,
  D,
  S extends z.ZodType
> = {
    // validation is required and must be a zod type
  validation: S

  // guards must be an array of guards
  // and their expected payloads must overlap with the payload validation 
  guards?: Guard<z.infer<S>>[]

  // handler is passed the H3Event
  // and payload as it conforms to the zod schema used as the validaton
  handler: (
    event: H3Event<T>,
    payload: z.infer<S>
  ) => Promise<D> | D
}

export function defineApiEventHandler<
  T extends EventHandlerRequest = EventHandlerRequest,
  D = unknown,
  S extends z.ZodType = z.ZodType
>(
  handlerOrConfig: ApiEventHandler<T, D, S> | EventHandler<T, D>
): EventHandler<T, D> {

With this little bit type juggling, we can code with confidence in our API endpoints.

Screenshot 2025-05-12 at 5.43.35 PM.png

The Complete Solution

If you put together all the code above into a single file called server/utils/baseServerUtils.ts it looks like this:

import type { EventHandler, EventHandlerRequest, H3Event } from 'h3'
import type { z } from 'zod'
import { ZodError } from 'zod'

type Guard<T> = ReturnType<typeof defineGuard<T>>

type ApiEventHandler<
  T extends EventHandlerRequest,
  D,
  S extends z.ZodType
> = {
  validation: S
  guards?: Guard<z.infer<S>>[]
  handler: (
    event: H3Event<T>,
    payload: z.infer<S>
  ) => Promise<D> | D
}

export function defineApiEventHandler<
  T extends EventHandlerRequest = EventHandlerRequest,
  D = unknown,
  S extends z.ZodType = z.ZodType
>(
  handlerOrConfig: ApiEventHandler<T, D, S> | EventHandler<T, D>
): EventHandler<T, D> {
  if (typeof handlerOrConfig === 'function') {
    return defineEventHandler(handlerOrConfig)
  }

  const { validation, handler, guards } = handlerOrConfig

  return defineEventHandler<T>(async (event) => {
    try {
      // get the rawData
      const rawData = await getPayload(event)

      // validate the rawData and transform it to the payload
      const payload = await runValidation<S>(rawData, validation)

      // run guards
      await runGuards<S>(event, payload, guards)

      // run the handler
      return handler(event, payload)
    }
    catch (err) {
      if (err instanceof ZodError) {
        throw createError({
          statusCode: 422,
          statusMessage: `Invalid request payload`,
          data: err
        })
      }
      throw err
    }
  })
}

// Local function to get the payload from the event
// should NOT be exported
async function getPayload(event: H3Event) {
  const method = event.method

  let payload: Record<string, unknown> = getQuery(event) || {}

  if (['PUT', 'POST'].includes(method)) {
    const body = await readBody(event)
    payload = {
      ...payload,
      ...body
    }
  }

  return {
    ...payload,
    ...event.context.params
  }
}

// local function to run guards
async function runGuards<T>(event: H3Event, payload: T, guards?: Guard<T>[]) {
  if (!guards) return
  if (!Array.isArray(guards)) {
    throw createError({
      statusCode: 500,
      statusMessage: `Guards must be an array`
    })
  }
  await Promise.all(guards.map(guard => guard(event, payload)))
}

// local function to run validation
async function runValidation<T>(data: unknown, validation: z.ZodType<T>) {
  return await validation.parseAsync(data)
}

export const defineGuard = <T>(callback: (event: H3Event, payload?: T) => Promise<void>) => {
  return callback
}

Some Recipes for Using the defineApiEventHandler

Coerce Query String Variables to Proper Types

Query strings can be annoying as they ALWAYS come through as strings. Get full control over query string data types with zod’s coerce option.

import { z } from "zod";

export default defineApiEventHandler({
  validation: z.object({
    // 👇 coerce the id to a number
    id: z.coerce.number(),
  }),
  handler: async (event, { id }) => {
    return {
      id,
      dataType: typeof id, // number
    };
  },
});

Create a Validation Rule with Async Logic with Extend

The zod refine method can be used to write async logic for validation rules.

// server/api/posts/index.post.ts
import { z } from 'zod'

export default defineApiEventHandler({
  validation: z.object({
    slug: z.string().refine(async (slug) => {
      const slugIsUnique = // check if slug is unique in your DB
      return slugIsUnique
    }, {
      message: 'Slug must be unique'
    })
  }),
  handler: async (event, { slug }) => {
    // slug guaranteed unique here
  }
})

Extend Shared/Base Validators

Often times you’ll have common payload params that you share between multiple API endpoints. That’s easily supported! Store them and export them from a central place (I chose server/utils/baseValidators.ts).

// baseValidators.ts
import { z } from "zod";

export const baseListValidator = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.string().optional(),
  order: z.enum(["asc", "desc"]).optional().default("asc"),
  search: z.string().optional(),
  filter: z.record(z.unknown()).optional(),
});

Then use the shared validators as is in any API endpoint or extend them.

import { z } from "zod";
export default defineApiEventHandler({
  validation: baseListValidator.extend({
    // add any additional validation here
    latest: z.coerce.boolean().describe("get only the latest 5 posts"),
  }),
  handler: async (event, {  }) => {
    // get the posts

    return [];
  },
});
extending base validators provides the same type safety as defining validators inline the API endpoint

Map Request Payload Values to Payloads Expected by Guard

Sometimes you’ll have guards that name payload keys slightly differently than what you’ve defined in an API endpoint. Do try to avoid this and be consistent with your payload keys but you can work around it if necessary.

For example, let’s say a guard expects a payload with a postId key.

// server/guards/postGuards.ts

export const postBelongsToUserGuard = defineGuard<{ postId: string }>(
  async (event, payload) => {
    // check if the post belongs to the user
    // this can be any async operation
    // like checking if a post belongs to a user in a database
    const usersPosts = ["123", "456"];

    if (!usersPosts.includes(payload.postId)) {
      throw createError({
        statusCode: 403,
        statusMessage: "Post does not belong to user",
      });
    }
  }
);

Your API endpoint might take the post id as an id route param.

// server/api/posts/[id].put.ts
import { z } from "zod";
export default defineApiEventHandler({
  validation: z.object({
    id: z.string()
  }),
  guards: [postBelongsToUserGuard],
  handler: async (event, { id }) => {
    // update the post with id {id}
  },
});

But this not compitable with your guard payload type.

Your IDE will notify you if the guard isn’t compatible with the payload.

To fix this you could use the zod preprocess method to map the id route param to the payload postId.

import { z } from "zod";
export default defineApiEventHandler({

    // 👇 We re-map id to postId here
  validation: z.preprocess(
    (data) => {
      // map id param to postId
      const obj = data as Record<string, unknown>;
      return { postId: obj.postId || obj.id };
    },

    // and validate the payload as a postId
    z.object({
      postId: z.string(),
    })
  ),
  guards: [postBelongsToUserGuard],
  handler: async (event, { postId }) => {
    // update the post with id {postId}
  },
});

Conclusion

Nuxt API event handlers are already awesome. Adding some common conventions and familiar standardized concepts can make working with them even better. The point of this article isn’t necessarily that you use my implentation directly (though you could!) but more so that you get an idea of what’s possible so that you can DRY up your API endpoint handling with conventions fit for your project.

If you’d like to see this code in action, you can clone the repo and try it out for yourself.

If you’d like to learn more about the fundamentals of Nuxt checkout our course dedicated to the topic. If you want more of a deep dive into building a full stack Nuxt app, then the Mastering Nuxt course is a great option for you!

Start learning Vue.js for free

Daniel Kelly
Daniel Kelly
Daniel is the lead instructor at Vue School and enjoys helping other developers reach their full potential. He has 10+ years of developer experience using technologies including Vue.js, Nuxt.js, and Laravel.

Comments

Latest Vue School Articles

LLM Agents: Your Guide to Smarter Development

LLM Agents: Your Guide to Smarter Development

Explore LLM agents and how they enhance developer workflows with smarter automation and decisions. A guide for devs!
Eleftheria Batsou
Eleftheria Batsou
How Vue.js Developers Can Use AI Coding Agents to Build Faster

How Vue.js Developers Can Use AI Coding Agents to Build Faster

Discover how Vue.js developers can use AI coding agents to streamline workflows, with Vue & Nuxt examples
Eleftheria Batsou
Eleftheria Batsou
VueSchool logo

Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.

More than 200.000 users have already joined us. You are welcome too!

Follow us on Social

© All rights reserved. Made with ❤️ by BitterBrains, Inc.