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-Lengthheader 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
| Layer | Location | Mechanism | Purpose |
|---|---|---|---|
| Auth | Route handler | session?.user?.isAdmin | Role-based access |
| Body size | lib/utils/request-body.ts | Stream reader | Prevent oversized payloads |
| Pagination | lib/utils/pagination-validation.ts | URLSearchParams parsing | Validate page/limit |
| Enum params | Route handler inline | Type guard functions | Validate status, sortBy, etc. |
| Body schema | lib/validations/*.ts | Zod schemas | Structured input validation |
| Search | Route handler inline | Trim + CSV parsing | Input sanitization |