RFC 9457 Problem Details middleware for Hono.
Returns application/problem+json structured error responses with a single app.onError setup.
If this saved you from hand-rolling RFC 9457 in yet another Hono project, please ⭐ star the repo — it helps others discover it.
Without a contract, HTTP error bodies drift. Every Hono project ends up reinventing the same scaffolding — and every client ends up parsing whatever shows up.
- Inconsistent shapes across routes:
{ message },{ error },{ code, reason }, or raw text - Validation errors from each schema library return a different format, so clients special-case each
- OpenAPI drift: docs describe one error shape, the server returns another
- No standard for extensions: adding
retryAfterorcorrelationIdmeans breaking your own contract
RFC 9457 defines one structure — { type, status, title, detail, instance } plus arbitrary extension members — and this middleware makes it the default for every
error in your Hono app: thrown ProblemDetailsError, HTTPException, validation failures, and unhandled
exceptions alike. One app.onError() line, one contract your clients, OpenAPI spec, and integration tests
can all agree on.
- RFC 9457 compliant —
type,status,title,detail,instance+ extension members (flattened per §3.1, standard fields always win) - Hono native —
app.onErrorhandler with RFC-compliant defaults - Zod integration —
@hono/zod-validatorhook for validation errors - Valibot integration —
@hono/valibot-validatorhook for validation errors - OpenAPI integration —
@hono/zod-openapischemas for API documentation - Standard Schema —
@hono/standard-validatorhook (works with any schema library) - Type-safe — full TypeScript support with inference
- Zero runtime dependencies —
honois the only required peer dependency; validator integrations are optional - Localization —
localizecallback for title/detail translation - Edge-first — works on Cloudflare Workers, Deno, Bun, and Node.js
npm install hono-problem-details- Hono
>= 4.12.14(peer dependency) - TypeScript
>= 5.0— the published.d.tsfiles are CI-tested against TS 5.0, 5.4, 5.7, 5.9, and 6.0. Older TS versions may work but are not verified. - Node.js
>= 22(Node 20 reached end-of-life in April 2026; v0.6.0 raised the floor)
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { problemDetailsHandler } from "hono-problem-details";
const app = new Hono();
app.onError(problemDetailsHandler());
app.get("/not-found", (c) => {
throw new HTTPException(404, { message: "Resource not found" });
});
// Response:
// HTTP/1.1 404 Not Found
// Content-Type: application/problem+json
// {
// "type": "about:blank",
// "status": 404,
// "title": "Not Found",
// "detail": "Resource not found"
// }Common error shapes for day-to-day API work. Validation errors are covered separately by the Zod / Valibot / Standard Schema hooks — this section is for errors you throw yourself.
import { problemDetails } from "hono-problem-details";throw problemDetails({
status: 401,
title: "Unauthorized",
detail: "Missing or invalid credentials",
type: "https://api.example.com/problems/unauthorized",
});Clients key off type to trigger a re-auth flow — no need to parse detail.
throw problemDetails({
status: 403,
title: "Forbidden",
detail: `User ${userId} cannot access resource ${resourceId}`,
type: "https://api.example.com/problems/forbidden",
extensions: { requiredRole: "admin" },
});throw problemDetails({
status: 404,
title: "Not Found",
detail: `Order ${orderId} does not exist`,
instance: `/orders/${orderId}`,
});instance points at the specific occurrence — clients can use it as a key for retry logic
or deduplication.
Auto-fill shortcuts.
titleis optional — when omitted, the standard HTTP reason phrase forstatusis used (404→"Not Found"). Similarly,instancecan be populated from the request path automatically viaproblemDetailsHandler({ autoInstance: true }). Both shortcuts skip the boilerplate in the example above; explicit values always win.
throw problemDetails({
status: 409,
title: "Order Conflict",
detail: `Order ${orderId} already exists`,
type: "https://api.example.com/problems/order-conflict",
instance: `/orders/${orderId}`,
});Domain conflicts should always carry a project-specific type URI. about:blank is fine for
generic 4xx/5xx but loses its value the moment a client needs to distinguish two conflicts.
throw problemDetails({
status: 429,
title: "Too Many Requests",
detail: "Request quota exceeded",
type: "https://api.example.com/problems/rate-limited",
extensions: { retryAfter: 60, quota: 1000, remaining: 0 },
});Rate-limit metadata goes in extensions — clients read it straight from the body instead
of juggling Retry-After headers.
Anything thrown that isn't a ProblemDetailsError or HTTPException (and isn't matched by
mapError) becomes a generic 500. detail is always the constant string
"An unexpected error occurred" — never the raw error.message or stack — so UIs that
render detail verbatim cannot leak server internals.
app.get("/boom", () => {
throw new Error("DB connection lost: ECONNREFUSED");
});
// HTTP/1.1 500 Internal Server Error
// Content-Type: application/problem+json
// {
// "type": "about:blank",
// "status": 500,
// "title": "Internal Server Error",
// "detail": "An unexpected error occurred"
// }In development, set includeStack: true to surface the stack trace as a top-level stack
extension member. detail stays constant either way — read the stack from body.stack:
problemDetailsHandler({
includeStack: process.env.NODE_ENV !== "production",
});
// HTTP/1.1 500 Internal Server Error
// {
// "type": "about:blank",
// "status": 500,
// "title": "Internal Server Error",
// "detail": "An unexpected error occurred",
// "stack": "Error: DB connection lost: ECONNREFUSED\n at ..."
// }Keep includeStack off in production — stack traces should not leave the server even via
opt-in extension fields.
Extension members are flattened to top level per RFC 9457:
throw problemDetails({
status: 422,
title: "Validation Error",
extensions: {
errors: [
{ field: "email", message: "must be a valid email" },
],
},
});
// Response body:
// {
// "type": "about:blank",
// "status": 422,
// "title": "Validation Error",
// "errors": [{ "field": "email", "message": "must be a valid email" }]
// }Pre-define your API's error types for type-safe error creation:
import { createProblemTypeRegistry } from "hono-problem-details";
const problems = createProblemTypeRegistry({
ORDER_CONFLICT: {
type: "https://api.example.com/problems/order-conflict",
status: 409,
title: "Order Conflict",
},
RATE_LIMITED: {
type: "https://api.example.com/problems/rate-limited",
status: 429,
title: "Too Many Requests",
},
});
// Type-safe error creation
app.post("/orders", (c) => {
throw problems.create("ORDER_CONFLICT", {
detail: `Order ${id} already exists`,
instance: `/orders/${id}`,
});
});
// With extensions
throw problems.create("RATE_LIMITED", {
extensions: { retryAfter: 60 },
});Reach for createProblemTypeRegistry when your API has a fixed set of domain errors and you
want one source of truth for type / status / title. It pays off the moment the same error
is thrown from more than one handler — renames and URI changes happen in one place.
Use problemDetails() directly for one-off errors, prototypes, or generic 4xx/5xx where
about:blank is the right type. RFC 9457 explicitly allows about:blank when the HTTP status
code alone is enough context — don't force a URI just to have one.
import { zValidator } from "@hono/zod-validator";
import { zodProblemHook } from "hono-problem-details/zod";
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
age: z.number().positive(),
});
app.post("/users", zValidator("json", schema, zodProblemHook()), (c) => {
const data = c.req.valid("json");
// ...
});
// Validation error response:
// HTTP/1.1 422 Unprocessable Content
// Content-Type: application/problem+json
// {
// "type": "about:blank",
// "status": 422,
// "title": "Validation Error",
// "detail": "Request validation failed",
// "errors": [{ "field": "email", "message": "Invalid email", "code": "invalid_string" }]
// }import { vValidator } from "@hono/valibot-validator";
import { valibotProblemHook } from "hono-problem-details/valibot";
import * as v from "valibot";
const schema = v.object({
email: v.pipe(v.string(), v.email()),
age: v.pipe(v.number(), v.minValue(1)),
});
app.post("/users", vValidator("json", schema, valibotProblemHook()), (c) => {
const data = c.req.valid("json");
// ...
});Works with any Standard Schema compatible library (Zod, Valibot, ArkType, etc.):
import { sValidator } from "@hono/standard-validator";
import { standardSchemaProblemHook } from "hono-problem-details/standard-schema";
import { z } from "zod"; // or valibot, arktype, etc.
const schema = z.object({
email: z.string().email(),
});
app.post("/users", sValidator("json", schema, standardSchemaProblemHook()), (c) => {
const data = c.req.valid("json");
// ...
});Peer dependencies:
./openapirequires@hono/zod-openapi@^1.0.0, which in turn requireszod@^4.0.0. The base./zodintegration (validator hook) works with bothzod@^3.25.0andzod@^4.0.0— the version constraint above only applies when you import fromhono-problem-details/openapi.
Use with @hono/zod-openapi to document Problem Details error responses in your OpenAPI spec:
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { problemDetailsHandler } from "hono-problem-details";
import {
ProblemDetailsSchema,
createProblemDetailsSchema,
problemDetailsResponse,
} from "hono-problem-details/openapi";
const app = new OpenAPIHono();
app.onError(problemDetailsHandler());
// Use problemDetailsResponse() in route definitions
const route = createRoute({
method: "get",
path: "/users/{id}",
request: {
params: z.object({ id: z.string() }),
},
responses: {
200: {
content: {
"application/json": {
schema: z.object({ id: z.string(), name: z.string() }),
},
},
description: "User found",
},
404: problemDetailsResponse(404),
422: problemDetailsResponse(422, "Validation Error"),
},
});
// With extension members
const errorWithExtensions = createProblemDetailsSchema(
z.object({
errors: z.array(z.object({ field: z.string(), message: z.string() })),
}),
);
// Use: problemDetailsResponse(422, "Validation Error", errorWithExtensions)Use the localize callback to translate title and detail based on the request context.
Return a partial patch with just the fields you want to override — everything else falls
through unchanged. Returning nothing (or undefined) leaves the response untouched.
problemDetailsHandler({
localize: (pd, c) => {
const lang = c.req.header("Accept-Language");
if (lang?.startsWith("ja")) {
return { title: translate("ja", pd.title) };
}
return pd;
},
});The callback receives the fully-built ProblemDetails object and the Hono Context, allowing access to headers like Accept-Language. Return a new ProblemDetails with translated fields.
Note on caching: If your responses vary by
Accept-Language, addVary: Accept-Languagefrom your own middleware so CDNs and browser caches don't serve the wrong translation. This middleware intentionally does not setVary— error handlers shouldn't mutate request-scope headers that also apply to successful responses.
Note on failures: If your
localizecallback throws, the handler falls back to the un-localizedProblemDetailsand continues. Throwing from insideapp.onErrorwould cause the error handler to re-enter itself, so the swallow is deliberate. Catch errors inside your callback if you need to observe them.
problemDetailsHandler({
// Prefix for type URI (e.g., "https://api.example.com/problems")
typePrefix: "https://api.example.com/problems",
// Default type URI (default: "about:blank")
defaultType: "about:blank",
// Include stack trace in `extensions.stack` on 500 responses (development only)
includeStack: process.env.NODE_ENV === "development",
// Populate `instance` from `c.req.path` when the thrown problem didn't specify one
autoInstance: true,
// Localize title/detail before sending the response.
// Return a partial patch — fields you omit fall through from the original.
localize: (pd, c) => {
const lang = c.req.header("Accept-Language") ?? "en";
return { title: `[${lang}] ${pd.title}` };
},
// Custom error mapping
mapError: (error) => {
if (error instanceof MyCustomError) {
return {
status: error.statusCode,
title: error.name,
detail: error.message,
};
}
return undefined; // fallback to default handling
},
});The following Hono middleware libraries use hono-problem-details as an optional dependency for RFC 9457 error responses:
- hono-idempotency — Idempotency key middleware for Hono
- hono-webhook-verify — Webhook signature verification middleware for Hono
- hono-cf-access — Country / ASN blocking and maintenance mode via Cloudflare Workers
request.cf
MIT