Skip to Content
guidesValidation

Last Updated: 3/9/2026


Validation

Validation ensures incoming data meets your requirements. Hono provides flexible validation that integrates with popular validators like Zod, Valibot, and ArkType.

Quick Start

Install a validator middleware:

::: code-group

npm install @hono/zod-validator zod
yarn add @hono/zod-validator zod
pnpm add @hono/zod-validator zod
bun add @hono/zod-validator zod

:::

Use it in your routes:

import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const schema = z.object({ title: z.string().min(1).max(100), published: z.boolean().default(false), }) app.post('/posts', zValidator('json', schema), (c) => { const data = c.req.valid('json') // ^? { title: string; published: boolean } return c.json({ success: true, data }, 201) } )

That’s it! The validator runs automatically, and you get type-safe validated data.

Validation Targets

You can validate different parts of the request:

JSON Body

app.post('/api/posts', zValidator('json', z.object({ title: z.string(), body: z.string(), })), (c) => { const { title, body } = c.req.valid('json') return c.json({ title, body }) } )

Important: The request must include Content-Type: application/json header.

Form Data

app.post('/submit', zValidator('form', z.object({ username: z.string(), email: z.string().email(), })), (c) => { const { username, email } = c.req.valid('form') return c.json({ username, email }) } )

Important: The request must include Content-Type: application/x-www-form-urlencoded or multipart/form-data header.

Query Parameters

app.get('/search', zValidator('query', z.object({ q: z.string(), page: z.coerce.number().int().positive().default(1), })), (c) => { const { q, page } = c.req.valid('query') return c.json({ query: q, page }) } )
GET /search?q=hono&page=2 → { "query": "hono", "page": 2 }

Note: Query parameters are always strings. Use z.coerce to convert to numbers.

URL Parameters

app.get('/posts/:id', zValidator('param', z.object({ id: z.string().regex(/^[0-9]+$/), })), (c) => { const { id } = c.req.valid('param') return c.json({ postId: id }) } )

Headers

app.post('/api', zValidator('header', z.object({ 'authorization': z.string().startsWith('Bearer '), 'content-type': z.literal('application/json'), })), (c) => { const headers = c.req.valid('header') return c.json({ ok: true }) } )

Important: Header names must be lowercase.

// ❌ Wrong z.object({ 'Authorization': z.string() }) // ✅ Correct z.object({ 'authorization': z.string() })

Cookies

import { getCookie } from 'hono/cookie' app.get('/dashboard', zValidator('cookie', z.object({ session: z.string().uuid(), })), (c) => { const { session } = c.req.valid('cookie') return c.json({ sessionId: session }) } )

Multiple Validators

Validate different parts of the request:

app.post('/posts/:id/comments', zValidator('param', z.object({ id: z.string(), })), zValidator('json', z.object({ body: z.string().min(1), author: z.string(), })), (c) => { const { id } = c.req.valid('param') const { body, author } = c.req.valid('json') return c.json({ postId: id, comment: { body, author } }, 201) } )

Validation Schemas

Basic Types

import { z } from 'zod' const schema = z.object({ // Strings name: z.string(), email: z.string().email(), url: z.string().url(), // Numbers age: z.number().int().positive(), price: z.number().positive(), // Booleans published: z.boolean(), // Dates createdAt: z.date(), // Enums status: z.enum(['draft', 'published', 'archived']), })

Arrays

const schema = z.object({ tags: z.array(z.string()), scores: z.array(z.number()).min(1).max(10), })

Nested Objects

const schema = z.object({ user: z.object({ name: z.string(), email: z.string().email(), address: z.object({ street: z.string(), city: z.string(), zip: z.string(), }), }), })

Optional and Default Values

const schema = z.object({ title: z.string(), published: z.boolean().default(false), tags: z.array(z.string()).optional(), metadata: z.object({}).optional(), })

String Constraints

const schema = z.object({ username: z.string() .min(3, 'Username must be at least 3 characters') .max(20, 'Username must be at most 20 characters') .regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores'), password: z.string() .min(8) .regex(/[A-Z]/, 'Must contain uppercase letter') .regex(/[0-9]/, 'Must contain number'), })

Number Constraints

const schema = z.object({ age: z.number().int().min(18).max(120), price: z.number().positive().multipleOf(0.01), rating: z.number().min(1).max(5), })

Custom Validation

const schema = z.object({ email: z.string().email(), confirmEmail: z.string().email(), }).refine((data) => data.email === data.confirmEmail, { message: 'Emails must match', path: ['confirmEmail'], })

Error Handling

Default Error Response

By default, validation errors return a 400 response:

{ "success": false, "error": { "issues": [ { "code": "too_small", "minimum": 1, "type": "string", "inclusive": true, "message": "String must contain at least 1 character(s)", "path": ["title"] } ] } }

Custom Error Handling

Customize the error response:

app.post('/posts', zValidator('json', schema, (result, c) => { if (!result.success) { return c.json({ error: 'Validation failed', details: result.error.flatten() }, 400) } }), (c) => { const data = c.req.valid('json') return c.json({ data }) } )

Global Error Handler

Handle all validation errors in one place:

import { HTTPException } from 'hono/http-exception' app.onError((err, c) => { if (err instanceof HTTPException) { return c.json({ error: err.message, status: err.status }, err.status) } return c.json({ error: 'Internal Server Error' }, 500) })

Other Validators

Standard Schema Validator

Use any Standard Schema-compatible validator:

npm install @hono/standard-validator

With Valibot

import { sValidator } from '@hono/standard-validator' import * as v from 'valibot' const schema = v.object({ name: v.string(), age: v.number(), }) app.post('/author', sValidator('json', schema), (c) => { const data = c.req.valid('json') return c.json(data) } )

With ArkType

import { sValidator } from '@hono/standard-validator' import { type } from 'arktype' const schema = type({ name: 'string', age: 'number', }) app.post('/author', sValidator('json', schema), (c) => { const data = c.req.valid('json') return c.json(data) } )

Manual Validation

Validate manually without a library:

import { validator } from 'hono/validator' app.post('/posts', validator('json', (value, c) => { const title = value['title'] const body = value['body'] if (!title || typeof title !== 'string') { return c.text('Invalid title', 400) } if (!body || typeof body !== 'string') { return c.text('Invalid body', 400) } return { title, body } }), (c) => { const { title, body } = c.req.valid('json') return c.json({ title, body }, 201) } )

Type-Safe RPC

Validation integrates with Hono’s RPC client for end-to-end type safety:

// server.ts import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const route = app.post('/posts', zValidator('json', z.object({ title: z.string(), published: z.boolean(), })), (c) => { const data = c.req.valid('json') return c.json({ post: data }, 201) } ) export type AppType = typeof route
// client.ts import { hc } from 'hono/client' import type { AppType } from './server' const client = hc<AppType>('http://localhost:3000') const res = await client.posts.$post({ json: { title: 'Hello', published: true, } }) if (res.ok) { const data = await res.json() // ^? { post: { title: string; published: boolean } } }

See also: RPC Client Guide

Common Patterns

File Upload Validation

const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB app.post('/upload', zValidator('form', z.object({ file: z.instanceof(File) .refine((file) => file.size <= MAX_FILE_SIZE, { message: 'File must be less than 5MB', }) .refine((file) => ['image/jpeg', 'image/png'].includes(file.type), { message: 'File must be JPEG or PNG', }), })), async (c) => { const { file } = c.req.valid('form') // Process file... return c.json({ filename: file.name }) } )

Pagination

app.get('/posts', zValidator('query', z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().min(1).max(100).default(20), })), (c) => { const { page, limit } = c.req.valid('query') const offset = (page - 1) * limit return c.json({ posts: [], pagination: { page, limit, offset } }) } )

API Key Validation

app.use('/api/*', zValidator('header', z.object({ 'x-api-key': z.string().uuid(), })), async (c, next) => { const { 'x-api-key': apiKey } = c.req.valid('header') // Verify API key... await next() } )

Conditional Validation

const schema = z.object({ type: z.enum(['user', 'admin']), email: z.string().email(), permissions: z.array(z.string()).optional(), }).refine( (data) => data.type !== 'admin' || data.permissions !== undefined, { message: 'Admins must have permissions', path: ['permissions'], } )

Testing

Test validated endpoints:

import { describe, test, expect } from 'vitest' describe('POST /posts', () => { test('accepts valid data', async () => { const res = await app.request('/posts', { method: 'POST', body: JSON.stringify({ title: 'Hello', published: true, }), headers: { 'Content-Type': 'application/json' }, }) expect(res.status).toBe(201) }) test('rejects invalid data', async () => { const res = await app.request('/posts', { method: 'POST', body: JSON.stringify({ title: '', // Too short }), headers: { 'Content-Type': 'application/json' }, }) expect(res.status).toBe(400) }) })

See also: Testing Guide

What’s Next?