skills.vishalvoid
all skills
Patterns

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.

ZodTypeScriptAPIValidation

Install

$curl -fsSL https://skills.vishalvoid.com/install zod-api-validation | bash

The 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
// 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
// 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
// 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
// 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

back to all skills