Validation Schemas
The template uses Zod for runtime validation across API routes, server actions, and form submissions. Schemas are organized by domain in lib/validations/ and referenced by both server-side and client-side code.
File Structure
lib/validations/
auth.ts # Password validation schema
item.ts # Item location schema
client-item.ts # Client-facing item CRUD schemas
client-dashboard.ts # Dashboard query parameters
company.ts # Company create/update, item-company association
user-location.ts # User profile location settings
sponsor-ad.ts # Sponsor ad lifecycle schemas
Auth Schemas (auth.ts)
Password Schema
A shared schema enforcing strong password requirements:
import { z } from "zod";
export const passwordSchema = z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number")
.regex(/[^A-Za-z0-9]/, "Password must contain at least one special character");
Requirements:
- Minimum 8 characters
- At least 1 uppercase letter
- At least 1 lowercase letter
- At least 1 digit
- At least 1 special character
Item Schemas (item.ts)
Location Schema
Validates geographic location data for items. All fields are optional since strictness is controlled by site 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();
export type LocationSchemaInput = z.infer<typeof locationSchema>;
Client Item Schemas (client-item.ts)
Create Item
Schema for client-submitted items with required core fields:
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, 'At least one category is required'),
]).optional().nullable(),
tags: z.array(z.string().min(1)).optional().default([]),
icon_url: z.string().url('Invalid icon URL format').optional().or(z.literal('')),
location: locationSchema,
});
export type ClientCreateItemInput = z.infer<typeof clientCreateItemSchema>;
Update Item
Only allows fields that clients are permitted to modify (all optional):
export const clientUpdateItemSchema = z.object({
name: z.string().min(...).max(...).optional(),
description: z.string().min(...).max(...).optional(),
source_url: z.string().url('Invalid URL format').optional(),
category: z.union([z.string(), z.array(z.string())]).optional(),
tags: z.array(z.string().min(1)).optional(),
icon_url: z.string().url().optional().or(z.literal('')),
location: locationSchema,
});
export type ClientUpdateItemInput = z.infer<typeof clientUpdateItemSchema>;
Items List Query
Validates and transforms query parameters for paginated item lists:
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'),
});
export type ClientItemsListQueryInput = z.infer<typeof clientItemsListQuerySchema>;
Item ID Parameter
export const itemIdParamSchema = z.object({
id: z.string().min(1, 'Item ID is required'),
});
export type ItemIdParamInput = z.infer<typeof itemIdParamSchema>;
Company Schemas (company.ts)
Create Company
export const createCompanySchema = z.object({
name: z.string().min(1, "Company name is required").max(255),
website: z.string().url("Invalid URL format").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),
{ message: "Slug must contain only lowercase letters, numbers, and hyphens" }
),
status: z.enum(["active", "inactive"]).default("active"),
});
export type CreateCompanyInput = z.infer<typeof createCompanySchema>;
Update Company
Identical to createCompanySchema but with an id field and all other fields optional:
export const updateCompanySchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(255).optional(),
website: z.string().url().optional().or(z.literal("")),
domain: z.string().max(255).optional().transform(...),
slug: z.string().max(255).optional().transform(...).refine(...),
status: z.enum(["active", "inactive"]).optional(),
});
export type UpdateCompanyInput = z.infer<typeof updateCompanySchema>;
Item-Company Association
export const assignCompanyToItemSchema = z.object({
itemSlug: z.string().min(1).max(255)
.transform((val) => val.toLowerCase().trim()),
companyId: z.string().uuid("Invalid company ID format"),
});
export const removeCompanyFromItemSchema = z.object({
itemSlug: z.string().min(1).max(255)
.transform((val) => val.toLowerCase().trim()),
});
User Location Schemas (user-location.ts)
Location Privacy
export const locationPrivacyValues = ['private', 'city', 'exact'] as const;
export const locationPrivacySchema = z.enum(locationPrivacyValues);
export type LocationPrivacy = z.infer<typeof locationPrivacySchema>;
Update Location
Validates user profile location with a cross-field refinement ensuring latitude and longitude are provided together:
export const updateLocationSchema = z.object({
defaultLatitude: z.number().min(-90).max(90).nullable().optional(),
defaultLongitude: z.number().min(-180).max(180).nullable().optional(),
defaultCity: z.string().max(200).nullable().optional(),
defaultCountry: z.string().max(100).nullable().optional(),
locationPrivacy: locationPrivacySchema.optional(),
}).refine(
(data) => {
const hasLat = data.defaultLatitude != null;
const hasLng = data.defaultLongitude != null;
return hasLat === hasLng;
},
{ message: 'Both latitude and longitude must be provided together' }
);
export type UpdateLocationInput = z.infer<typeof updateLocationSchema>;
Sponsor Ad Schemas (sponsor-ad.ts)
Status and Interval Enums
export const sponsorAdStatuses = [
"pending_payment", "pending", "rejected",
"active", "expired", "cancelled",
] as const;
export const sponsorAdIntervals = ["weekly", "monthly"] as const;
Create Sponsor Ad
export const createSponsorAdSchema = z.object({
itemSlug: z.string().min(1, "Item slug is required"),
interval: z.enum(sponsorAdIntervals),
paymentProvider: z.string().min(1, "Payment provider is required"),
});
Update Sponsor Ad (Admin)
export const updateSponsorAdSchema = z.object({
id: z.string().uuid("Invalid sponsor ad ID"),
status: z.enum(sponsorAdStatuses).optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
subscriptionId: z.string().optional(),
customerId: z.string().optional(),
});
Approve and Reject
export const approveSponsorAdSchema = z.object({
id: z.string().uuid("Invalid sponsor ad ID"),
});
export const rejectSponsorAdSchema = z.object({
id: z.string().uuid("Invalid sponsor ad ID"),
rejectionReason: z.string()
.min(10, "Please provide a reason (minimum 10 characters)")
.max(500, "Rejection reason is too long (maximum 500 characters)"),
});
Cancel
export const cancelSponsorAdSchema = z.object({
id: z.string().uuid("Invalid sponsor ad ID"),
cancelReason: z.string().max(500).optional(),
});
Query Sponsor Ads
export const querySponsorAdsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(10),
status: z.enum(sponsorAdStatuses).optional(),
interval: z.enum(sponsorAdIntervals).optional(),
search: z.string().optional(),
sortBy: z.enum([
"createdAt", "updatedAt", "startDate", "endDate", "status"
]).default("createdAt"),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
});
Dashboard Schemas (client-dashboard.ts)
export const dashboardStatsQuerySchema = z.object({
// Reserved for future date range filters
});
export type DashboardStatsQueryInput = z.infer<typeof dashboardStatsQuerySchema>;
Usage Patterns
In API Routes
import { clientCreateItemSchema } from '@/lib/validations/client-item';
export async function POST(request: Request) {
const body = await request.json();
const parsed = clientCreateItemSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: parsed.error.flatten() },
{ status: 422 }
);
}
const validData = parsed.data;
// ... create item
}
In React Hook Form
import { zodResolver } from '@hookform/resolvers/zod';
import { createCompanySchema } from '@/lib/validations/company';
function CompanyForm() {
const form = useForm({
resolver: zodResolver(createCompanySchema),
defaultValues: { name: '', status: 'active' },
});
// Form fields are type-safe based on the schema
}
Related Files
lib/validations/auth.ts- Password validationlib/validations/item.ts- Item location schemalib/validations/client-item.ts- Client-facing item schemaslib/validations/client-dashboard.ts- Dashboard query schemaslib/validations/company.ts- Company and item-company schemaslib/validations/user-location.ts- User location schemaslib/validations/sponsor-ad.ts- Sponsor ad lifecycle schemaslib/types/item.ts-ITEM_VALIDATIONconstants used by schemas