Skip to main content

API Request Validation

The template validates API requests at multiple layers: Zod schemas for body/query validation, utility functions for pagination and body size limits, and inline type guards for enum parameters. This page documents each validation mechanism and how they are used in API route handlers.

Validation Architecture

Zod Validation Schemas

Location Schema (lib/validations/item.ts)

All fields are optional; strictness is controlled by form-level settings:

export const locationSchema = z.object({
address: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
country: z.string().optional(),
postal_code: z.string().optional(),
latitude: z.number()
.min(-90, 'Latitude must be between -90 and 90')
.max(90, 'Latitude must be between -90 and 90')
.optional(),
longitude: z.number()
.min(-180, 'Longitude must be between -180 and 180')
.max(180, 'Longitude must be between -180 and 180')
.optional(),
service_area: z.enum(['local', 'regional', 'national', 'global']).optional(),
is_remote: z.boolean().optional(),
geocoded_by: z.enum(['mapbox', 'google']).optional(),
}).optional();

Client Item Schemas (lib/validations/client-item.ts)

Create Item

export const clientCreateItemSchema = z.object({
name: z.string()
.min(ITEM_VALIDATION.NAME_MIN_LENGTH)
.max(ITEM_VALIDATION.NAME_MAX_LENGTH),
description: z.string()
.min(ITEM_VALIDATION.DESCRIPTION_MIN_LENGTH)
.max(ITEM_VALIDATION.DESCRIPTION_MAX_LENGTH),
source_url: z.string().url('Invalid URL format'),
category: z.union([
z.string().min(1, 'Category is required'),
z.array(z.string().min(1)).min(1),
]).optional().nullable(),
tags: z.array(z.string().min(1)).optional().default([]),
icon_url: z.string().url().optional().or(z.literal('')),
location: locationSchema,
});

Update Item

Uses the same field definitions with all fields optional:

export const clientUpdateItemSchema = z.object({
name: z.string().min(...).max(...).optional(),
description: z.string().min(...).max(...).optional(),
source_url: z.string().url().optional(),
category: z.union([z.string(), z.array(z.string())]).optional(),
tags: z.array(z.string()).optional(),
icon_url: z.string().url().optional().or(z.literal('')),
location: locationSchema,
});

List Query Parameters

Query parameters use .transform() to convert string inputs to typed values:

export const clientItemsListQuerySchema = z.object({
page: z.string().optional()
.transform(val => (val ? parseInt(val, 10) : 1))
.refine(val => !Number.isNaN(val))
.refine(val => val >= 1),
limit: z.string().optional()
.transform(val => (val ? parseInt(val, 10) : 10))
.refine(val => !Number.isNaN(val))
.refine(val => val >= 1 && val <= 100),
status: z.enum(['all', 'pending', 'approved', 'rejected']).optional().default('all'),
search: z.string().max(100).optional(),
sortBy: z.enum(['name', 'updated_at', 'status', 'submitted_at']).optional().default('updated_at'),
sortOrder: z.enum(['asc', 'desc']).optional().default('desc'),
deleted: z.string().optional().transform(val => val === 'true'),
});

Password Schema (lib/validations/auth.ts)

export const passwordSchema = z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Must contain at least one uppercase letter")
.regex(/[a-z]/, "Must contain at least one lowercase letter")
.regex(/[0-9]/, "Must contain at least one number")
.regex(/[^A-Za-z0-9]/, "Must contain at least one special character");

Company Schemas (lib/validations/company.ts)

export const createCompanySchema = z.object({
name: z.string().min(1).max(255),
website: z.string().url().optional().or(z.literal("")),
domain: z.string().max(255).optional()
.transform(val => val?.toLowerCase().trim() || undefined),
slug: z.string().max(255).optional()
.transform(val => val?.toLowerCase().trim() || undefined)
.refine(val => !val || /^[a-z0-9-]+$/.test(val)),
status: z.enum(["active", "inactive"]).default("active"),
});

Inferred Types

All schemas export Zod-inferred types alongside the schema:

export type ClientUpdateItemInput = z.infer<typeof clientUpdateItemSchema>;
export type ClientCreateItemInput = z.infer<typeof clientCreateItemSchema>;
export type CreateCompanyInput = z.infer<typeof createCompanySchema>;

Pagination Validation (lib/utils/pagination-validation.ts)

A shared utility for validating page and limit query parameters:

export function validatePaginationParams(
searchParams: URLSearchParams
): PaginationParams | PaginationError {
const page = pageParam ? parseInt(pageParam, 10) : 1;
const limit = limitParam ? parseInt(limitParam, 10) : 10;

if (isNaN(page) || page < 1) {
return { error: 'Invalid page parameter. Must be a positive integer.', status: 400 };
}
if (isNaN(limit) || limit < 1 || limit > 100) {
return { error: 'Invalid limit parameter. Must be between 1 and 100.', status: 400 };
}
return { page, limit };
}

Usage in route handlers follows a discriminated union pattern:

const paginationResult = validatePaginationParams(searchParams);
if ('error' in paginationResult) {
return NextResponse.json(
{ success: false, error: paginationResult.error },
{ status: paginationResult.status }
);
}
const { page, limit } = paginationResult;

Request Body Size Limits (lib/utils/request-body.ts)

readBodyWithLimit

Reads the request body via ReadableStream with incremental size checking:

export async function readBodyWithLimit<T = unknown>(
request: NextRequest,
options: ReadBodyOptions
): Promise<ReadBodyResult<T>>

Features:

  • Fast path: checks Content-Length header first
  • Incremental: reads stream chunks and checks size as bytes arrive
  • Cancellation: calls reader.cancel() when limit is exceeded
  • JSON parsing: optional, gracefully handles SyntaxError
// Usage
const { data } = await readBodyWithLimit(request, { maxSize: 1024 });

validateContentLength

Early rejection without reading the body:

export function validateContentLength(request: NextRequest, maxSize: number): boolean

Throws BodySizeLimitError if Content-Length header exceeds the limit.

BodySizeLimitError

Custom error class with maxSize and actualSize properties:

export class BodySizeLimitError extends Error {
constructor(
public readonly maxSize: number,
public readonly actualSize: number
) {
super(`Request body too large. Maximum size is ${maxSize} bytes, received ${actualSize} bytes.`);
}
}

Inline Parameter Validation

For enum parameters that are not covered by Zod schemas, route handlers use inline type guards:

// Type-safe status validation
const validStatuses = ['draft', 'pending', 'approved', 'rejected'] as const;
type ItemStatus = (typeof validStatuses)[number];
const isItemStatus = (s: string): s is ItemStatus =>
(validStatuses as readonly string[]).includes(s);

if (statusParam && !isItemStatus(statusParam)) {
return NextResponse.json(
{ success: false, error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` },
{ status: 400 }
);
}

This pattern is repeated for sortBy and sortOrder parameters.

Search Input Sanitization

Text search parameters are trimmed and normalized:

const searchRaw = searchParams.get('search');
const search = searchRaw?.trim() ? searchRaw.trim() : undefined;

CSV parameters are parsed and normalized:

const parseCsv = (value: string | null): string[] | undefined => {
if (!value) return undefined;
const arr = value.split(',').map(v => v.trim()).filter(Boolean);
return arr.length ? arr : undefined;
};

Pagination Utilities (lib/paginate.ts)

Simple pagination helpers for template-level pagination:

export const PER_PAGE = 12;

export function totalPages(size: number, perPage: number = PER_PAGE) {
return Math.ceil(size / perPage);
}

export function paginateMeta(rawPage: number | string = 1, perPage: number = PER_PAGE) {
const page = typeof rawPage === 'string' ? parseInt(rawPage) : rawPage;
const start = (page - 1) * perPage;
return { page, start };
}

Validation Layer Summary

LayerLocationMechanismPurpose
AuthRoute handlersession?.user?.isAdminRole-based access
Body sizelib/utils/request-body.tsStream readerPrevent oversized payloads
Paginationlib/utils/pagination-validation.tsURLSearchParams parsingValidate page/limit
Enum paramsRoute handler inlineType guard functionsValidate status, sortBy, etc.
Body schemalib/validations/*.tsZod schemasStructured input validation
SearchRoute handler inlineTrim + CSV parsingInput sanitization