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.
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:
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!
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.
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
}
}
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)
}
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']
}
}
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)
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
}
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.
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
}
defineApiEventHandler
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
};
},
});
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
}
})
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 [];
},
});
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.
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}
},
});
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!
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!
© All rights reserved. Made with ❤️ by BitterBrains, Inc.