Validating API Routes in Nuxt 4 with Zod
Stop writing manual validation logic in every API route. Here are some utility functions that make Zod validation painless in Nuxt.
Background
If you're building API routes in Nuxt, YOU SHOULD BE validating incoming data. Request bodies, query parameters, router params, form uploads: they all need to be checked before you trust them.
Nuxt's H3 already provides readValidatedBody and getValidatedRouterParams, but handling errors consistently across your app takes a bit more work. You want structured error responses that your frontend can actually use.
Error Handling Setup
Before we get to the validation utilities, you'll need a simple error handling layer. This gives us consistent error responses across the app.
First, create the error handler at server/utils/errors.ts
// server/utils/errors.ts
import type { H3Error, H3Event } from "h3";
// Add more error codes as you see fit for your projects. This is stripped down for brevity.
export const ErrorCode = {
VALIDATION_ERROR: "VALIDATION_ERROR",
INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR",
} as const;
export type ErrorCodeType = typeof ErrorCode[keyof typeof ErrorCode];
export interface ErrorDetails {
code: ErrorCodeType;
message: string;
statusCode: number;
details?: Record<string, unknown>;
timestamp?: string;
}
export class AppError extends Error {
public readonly code: ErrorCodeType;
public readonly statusCode: number;
public readonly details?: Record<string, unknown>;
public readonly timestamp: string;
constructor(params: ErrorDetails) {
super(params.message);
this.name = "[AppError]";
this.code = params.code;
this.statusCode = params.statusCode;
this.details = params.details;
this.timestamp = params.timestamp || new Date().toISOString();
}
}
export function handleError(event: H3Event, error: unknown): H3Error {
if (isError(error)) {
return error;
}
if (error instanceof AppError) {
const isValidationError = error.code === ErrorCode.VALIDATION_ERROR;
return createError({
statusCode: error.statusCode,
statusMessage: error.message,
data: {
code: error.code,
timestamp: error.timestamp,
...(isValidationError && error.details?.errors ? { errors: error.details.errors } : {}),
},
});
}
return createError({
statusCode: 500,
statusMessage: "Internal Server Error",
data: {
code: ErrorCode.INTERNAL_SERVER_ERROR,
timestamp: new Date().toISOString(),
},
});
}
export const Errors = {
validation: (message: string, details?: Record<string, unknown>) =>
new AppError({
code: ErrorCode.VALIDATION_ERROR,
statusCode: 422,
message,
details,
}),
};
Then wire it up with a Nitro plugin at server/plugins/00.errors.ts
// server/plugins/00.errors.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('error', (error, { event }) => {
if (event) handleError(event, error);
});
});
Now validation errors will be transformed into consistent responses automatically.
The Validation Utilities
These utilities wrap H3's validation helpers and format Zod errors into a consistent shape.
Validating Request Bodies
import type { H3Event } from 'h3';
import type { ZodType, z } from 'zod';
export async function validateRequestBody<T extends ZodType>(
event: H3Event,
schema: T,
): Promise<z.infer<T>> {
const { data, error } = await readValidatedBody(event, schema.safeParse);
if (error) {
const errors: Record<string, string> = {};
for (const issue of error.issues) {
const field = issue.path.join('.');
if (field && !errors[field]) {
errors[field] = issue.message;
}
}
throw Errors.validation("Invalid request body", { error, errors });
}
return data;
}
Validating Router Params
export async function validateRouterParams<T extends ZodType>(
event: H3Event,
schema: T,
): Promise<z.infer<T>> {
const { data, error } = await getValidatedRouterParams(
event,
schema.safeParse,
);
if (error) {
const errors: Record<string, string> = {};
for (const issue of error.issues) {
const field = issue.path.join('.');
if (field && !errors[field]) {
errors[field] = issue.message;
}
}
throw Errors.validation("Invalid router parameters", { error, errors });
}
return data;
}
Validating Query Parameters
export async function validateRequestQuery<T extends ZodType>(
event: H3Event,
schema: T,
): Promise<z.infer<T>> {
const query = getQuery(event);
const { data, error } = schema.safeParse(query);
if (error) {
const errors: Record<string, string> = {};
for (const issue of error.issues) {
const field = issue.path.join('.');
if (field && !errors[field]) {
errors[field] = issue.message;
}
}
throw Errors.validation("Invalid query parameters", { error, errors });
}
return data;
}
Validating Form Data
This one handles both multipart/form-data and JSON bodies:
export async function validateFormData<T extends ZodType>(
event: H3Event,
schema: T,
): Promise<z.infer<T>> {
const contentType = getHeader(event, 'content-type') || '';
let formData: Record<string, string | File> = {};
if (contentType.includes('multipart/form-data')) {
const form = await readMultipartFormData(event);
if (form) {
for (const field of form) {
if (!field.name) continue;
if (field.filename) {
formData[field.name] = new File(
[new Uint8Array(field.data)],
field.filename,
{ type: field.type || 'application/octet-stream' }
);
} else {
formData[field.name] = field.data?.toString() || '';
}
}
}
} else {
formData = await readBody(event);
}
const { data, error } = schema.safeParse(formData);
if (error) {
const errors: Record<string, string> = {};
for (const issue of error.issues) {
const field = issue.path.join('.');
if (field && !errors[field]) {
errors[field] = issue.message;
}
}
throw Errors.validation("Invalid form data", { error, errors });
}
return data;
}
Usage
Say we have a blog post where users can submit comments. We want to validate the id router param of the blog post, and the request body (name and comment).
Here's what using the utilities looks like:
// server/api/posts/[id]/comments/index.post.ts
import { z } from 'zod';
const paramsSchema = z.object({
id: z.string().uuid(),
});
const bodySchema = z.object({
name: z.string().min(1, { error: "Name is required" }),
comment: z.string().min(1, { error: "Comment cannot be empty" }),
});
export default defineEventHandler(async (event) => {
const { id } = await validateRouterParams(event, paramsSchema);
const { name, comment } = await validateRequestBody(event, bodySchema);
// id, name, and comment are fully typed
// If validation fails, a structured error is thrown automatically
});
The error response your frontend receives looks like this:
{
"message": "Invalid request body",
"errors": {
"name": "Name is required",
"comment": "Comment cannot be empty"
}
}
Your frontend can map these errors directly to form fields.
Conclusion
These utilities give you type-safe validation with consistent error handling across all your API routes. Define your schemas, call the validator, and let TypeScript infer the rest. The error handling is centralized, the responses are predictable, and you're not writing the same boilerplate in every route.
Drop these in your server/utils/ directory and they'll be auto-imported throughout your Nuxt server code. If you have auto-imports disabled, just import them where needed. 😉