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 zodyarn add @hono/zod-validator zodpnpm add @hono/zod-validator zodbun 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-validatorWith 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?
- Type-safe clients: RPC Client Guide
- Learn middleware: Using Middleware
- API reference: Validator API