Zod API Validation
Define your API request and response shapes once as Zod schemas. TypeScript types are inferred automatically, runtime validation happens for free, and your API, service layer, and frontend all share the same type contract with no drift.
Install
curl -fsSL https://skills.vishalvoid.com/install zod-api-validation | bashThe problem with manual types
Most teams define TypeScript types by hand and separately write runtime validation logic. These drift apart within weeks: the type says `string`, the validator allows `null`, and the UI crashes in production. Zod solves this by making the schema the single source of truth — types are derived from validation, not the other way around.
Schema-first API design
Define the schema once. Infer the request type, the response type, and the error structure from it. Every other layer imports from this file — nothing is duplicated.
// lib/validations/post.schema.ts
import { z } from 'zod'
export const createPostSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
content: z.string().min(10, 'Content must be at least 10 characters'),
tags: z.array(z.string()).max(5).default([]),
published: z.boolean().default(false),
})
export const updatePostSchema = createPostSchema.partial().extend({
id: z.string().uuid(),
})
export const postResponseSchema = z.object({
id: z.string().uuid(),
title: z.string(),
content: z.string(),
tags: z.array(z.string()),
published: z.boolean(),
authorId: z.string(),
createdAt: z.string().datetime(),
})
// Inferred types — never written by hand
export type CreatePostInput = z.infer<typeof createPostSchema>
export type UpdatePostInput = z.infer<typeof updatePostSchema>
export type PostResponse = z.infer<typeof postResponseSchema>// lib/validations/post.schema.ts
import { z } from 'zod'
export const createPostSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
content: z.string().min(10, 'Content must be at least 10 characters'),
tags: z.array(z.string()).max(5).default([]),
published: z.boolean().default(false),
})
export const updatePostSchema = createPostSchema.partial().extend({
id: z.string().uuid(),
})
export const postResponseSchema = z.object({
id: z.string().uuid(),
title: z.string(),
content: z.string(),
tags: z.array(z.string()),
published: z.boolean(),
authorId: z.string(),
createdAt: z.string().datetime(),
})
// Inferred types — never written by hand
export type CreatePostInput = z.infer<typeof createPostSchema>
export type UpdatePostInput = z.infer<typeof updatePostSchema>
export type PostResponse = z.infer<typeof postResponseSchema>Validating API route inputs
In the API route, parse the incoming body against the schema. If it fails, Zod gives you structured field-level errors at no extra cost. If it passes, the parsed value is fully typed — no `as` casts needed.
// app/api/posts/route.ts
import { NextRequest } from 'next/server'
import { createPostSchema } from '@/lib/validations/post.schema'
import { createPost } from '@/lib/services/post.service'
import { auth } from '@/lib/auth'
export async function POST(req: NextRequest) {
const session = await auth()
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const result = createPostSchema.safeParse(body)
if (!result.success) {
return Response.json(
{
error: 'Validation failed',
// Structured field errors — ready for form error display
fields: result.error.flatten().fieldErrors,
},
{ status: 422 }
)
}
// result.data is fully typed as CreatePostInput here
const post = await createPost({ ...result.data, authorId: session.user.id })
return Response.json(post, { status: 201 })
}// app/api/posts/route.ts
import { NextRequest } from 'next/server'
import { createPostSchema } from '@/lib/validations/post.schema'
import { createPost } from '@/lib/services/post.service'
import { auth } from '@/lib/auth'
export async function POST(req: NextRequest) {
const session = await auth()
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const result = createPostSchema.safeParse(body)
if (!result.success) {
return Response.json(
{
error: 'Validation failed',
// Structured field errors — ready for form error display
fields: result.error.flatten().fieldErrors,
},
{ status: 422 }
)
}
// result.data is fully typed as CreatePostInput here
const post = await createPost({ ...result.data, authorId: session.user.id })
return Response.json(post, { status: 201 })
}Reusing schemas in Server Actions
The same schema that validates your API route also validates your Server Action. One schema definition, two validation points — no drift possible.
// app/(dashboard)/posts/actions.ts
'use server'
import { createPostSchema } from '@/lib/validations/post.schema'
import { createPost } from '@/lib/services/post.service'
import { auth } from '@/lib/auth'
export async function createPostAction(formData: FormData) {
const session = await auth()
if (!session) return { error: 'Not authenticated' }
const raw = {
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on',
}
const result = createPostSchema.safeParse(raw)
if (!result.success) {
return { error: 'Invalid input', fields: result.error.flatten().fieldErrors }
}
const post = await createPost({ ...result.data, authorId: session.user.id })
return { success: true, post }
}// app/(dashboard)/posts/actions.ts
'use server'
import { createPostSchema } from '@/lib/validations/post.schema'
import { createPost } from '@/lib/services/post.service'
import { auth } from '@/lib/auth'
export async function createPostAction(formData: FormData) {
const session = await auth()
if (!session) return { error: 'Not authenticated' }
const raw = {
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on',
}
const result = createPostSchema.safeParse(raw)
if (!result.success) {
return { error: 'Invalid input', fields: result.error.flatten().fieldErrors }
}
const post = await createPost({ ...result.data, authorId: session.user.id })
return { success: true, post }
}Type-safe fetch on the client
On the client, parse API responses through the response schema. If the API changes its shape without updating the schema, parsing fails loudly in development instead of silently returning `undefined` fields in production.
// lib/api/posts.ts
import { postResponseSchema, type PostResponse } from '@/lib/validations/post.schema'
export async function fetchPost(id: string): Promise<PostResponse> {
const res = await fetch(`/api/posts/${id}`)
if (!res.ok) {
throw new Error(`Failed to fetch post ${id}: ${res.statusText}`)
}
const json = await res.json()
// Parse validates the response matches what we expect
// Throws in dev if the API returns unexpected shape
return postResponseSchema.parse(json)
}// lib/api/posts.ts
import { postResponseSchema, type PostResponse } from '@/lib/validations/post.schema'
export async function fetchPost(id: string): Promise<PostResponse> {
const res = await fetch(`/api/posts/${id}`)
if (!res.ok) {
throw new Error(`Failed to fetch post ${id}: ${res.statusText}`)
}
const json = await res.json()
// Parse validates the response matches what we expect
// Throws in dev if the API returns unexpected shape
return postResponseSchema.parse(json)
}Related skills