JSON in Nitro: defineEventHandler, readBody, and H3 Utilities

Last updated:

Nitro is a server toolkit that powers Nuxt 3, but also works standalone. It uses H3 (an HTTP framework) for request handling — every route is a defineEventHandler function that returns a value, and Nitro automatically serializes it to JSON with Content-Type: application/json. Return a plain JavaScript object and Nitro calls JSON.stringify and sets the correct headers. For POST requests, await readBody(event) parses the incoming JSON body. Nitro routes live in the server/api/ directory (when used with Nuxt) or routes/ directory (standalone Nitro). File-based routing: a file at server/api/products.get.ts handles GET /api/products; server/api/products.post.ts handles POST /api/products. TypeScript types are inferred automatically — the return type of defineEventHandler becomes the API response type, and readBody<T>(event) accepts a generic for the parsed body type. Nitro also provides storage via useStorage() (backed by unstorage), caching with defineCachedEventHandler, and server-sent events. This guide covers route handlers, JSON response patterns, readBody, H3 utilities, validation with Zod, typed responses, caching, and storage.

defineEventHandler: Returning JSON from Routes

Bottom line: every Nitro route is a defineEventHandler(async (event) => { ... }). Return any JSON-serializable value and Nitro sets Content-Type: application/json and calls JSON.stringify automatically — 0 lines of serialization boilerplate.

H3 utilities are imported directly from "h3" (or re-exported from "nitropack/h3" in Nitro 2.x). getQuery(event) returns the parsed query string as an object. getRouterParam(event, 'id') reads a dynamic segment defined with bracket notation — a file at server/api/products/[id].ts exposes /api/products/:id. To send a non-200 status code, call setResponseStatus(event, 201) before returning. Custom response headers go through setResponseHeader(event, 'X-Total-Count', '42'). You can add a typed return to defineEventHandler as a generic — the return type is checked at compile time and flows through to auto-generated type stubs if you use Nuxt's useFetch.

// server/api/products.get.ts
// Handles: GET /api/products?category=electronics&limit=20
import { defineEventHandler, getQuery, setResponseHeader } from "h3"

interface Product {
  id: number
  name: string
  price: number
  category: string
}

// Return type generic provides compile-time checking
export default defineEventHandler<{ products: Product[]; total: number }>(
  async (event) => {
    const query = getQuery(event)
    // query.category → "electronics", query.limit → "20" (always strings)
    const limit = Number(query.limit ?? 20)

    const products = await db.findAll({ category: query.category, limit })

    setResponseHeader(event, "X-Total-Count", String(products.length))
    // Nitro serializes this object → { "products": [...], "total": 42 }
    return { products, total: products.length }
  }
)

// server/api/products/[id].get.ts
// Handles: GET /api/products/123
import { defineEventHandler, getRouterParam, setResponseStatus, createError } from "h3"

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, "id")
  if (!id) throw createError({ statusCode: 400, statusMessage: "Missing id" })

  const product = await db.findById(Number(id))
  if (!product) throw createError({ statusCode: 404, statusMessage: "Product not found" })

  return product   // → { "id": 123, "name": "Widget", "price": 9.99 }
})

// server/api/products.post.ts — status 201 on creation
import { defineEventHandler, readBody, setResponseStatus } from "h3"

export default defineEventHandler(async (event) => {
  const body = await readBody<{ name: string; price: number }>(event)
  const created = await db.create(body)
  setResponseStatus(event, 201)
  return created   // Content-Type: application/json, HTTP 201
})

readBody: Parsing Incoming JSON

Bottom line: readBody<T>(event) reads and parses the request body in 1 line — no manual JSON.parse, no stream concatenation. It handles application/json and application/x-www-form-urlencoded automatically based on the Content-Type header.

The generic T is compile-time only — it sets the TypeScript type of the returned value but performs zero runtime checks. For runtime safety, add Zod (covered in the next section). If the client sends malformed JSON (e.g., trailing commas or unquoted keys), Nitro automatically throws an HTTP 400 response before your handler code runs. To customize the 400 response message, wrap readBody in a try-catch and rethrow with createError. Use readRawBody(event) when you need the raw string (e.g., to verify an HMAC signature on a webhook payload). For file uploads alongside JSON metadata, use readMultipartFormData(event) — it returns an array of parts, each with a name, filename, type, and data buffer.

// server/api/products.post.ts
import {
  defineEventHandler,
  readBody,
  readRawBody,
  readMultipartFormData,
  getHeader,
  createError,
  setResponseStatus,
} from "h3"

interface CreateProductDto {
  name: string
  price: number
  category: string
}

export default defineEventHandler(async (event) => {
  // readBody parses JSON automatically — no JSON.parse needed
  const body = await readBody<CreateProductDto>(event)
  // body.name, body.price, body.category are typed as CreateProductDto

  const product = await db.create(body)
  setResponseStatus(event, 201)
  return product
})

// ── Custom 400 for invalid JSON ──────────────────────────────────────────────
export const customParseHandler = defineEventHandler(async (event) => {
  let body: CreateProductDto
  try {
    body = await readBody<CreateProductDto>(event)
  } catch {
    throw createError({
      statusCode: 400,
      statusMessage: "Invalid JSON",
      data: { hint: "Ensure Content-Type: application/json and valid JSON body" },
    })
  }
  return await db.create(body)
})

// ── readRawBody: HMAC webhook verification ───────────────────────────────────
import { createHmac } from "node:crypto"

export const webhookHandler = defineEventHandler(async (event) => {
  const raw = await readRawBody(event) ?? ""
  const sig = getHeader(event, "x-signature") ?? ""
  const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!).update(raw).digest("hex")
  if (sig !== expected) throw createError({ statusCode: 401, statusMessage: "Invalid signature" })

  const payload = JSON.parse(raw)   // parse manually after verification
  await processWebhook(payload)
  return { received: true }
})

// ── readMultipartFormData: file upload + JSON metadata ───────────────────────
export const uploadHandler = defineEventHandler(async (event) => {
  const parts = await readMultipartFormData(event) ?? []
  const metaPart = parts.find(p => p.name === "metadata")
  const filePart = parts.find(p => p.name === "file")

  const metadata = metaPart ? JSON.parse(metaPart.data.toString()) : {}
  // filePart.data is a Buffer — save to storage or cloud
  return { uploaded: filePart?.filename, metadata }
})

Zod Validation for Request JSON

Bottom line: readBody provides TypeScript types but no runtime guards — pair it with Zod safeParse to reject invalid payloads before they reach your database. A 5-line pattern handles validation, error formatting, and the happy path.

Define the Zod schema once and derive the TypeScript type from it with z.infer<typeof schema>. Pass that inferred type to readBody so the compile-time type and the runtime schema stay in sync with a single source of truth. When safeParse returns { success: false }, throwcreateError with statusCode: 400 and data: result.error.flatten()— the flattened error object separates field-level errors from form-level errors, making it straightforward to map back to a frontend form. Set setResponseStatus(event, 201) on successful creation, then return the created resource. The complete flow: read body validate create respond with 201.

// server/api/products.post.ts
import { defineEventHandler, readBody, setResponseStatus, createError } from "h3"
import { z } from "zod"

// 1. Define the schema — single source of truth
const createProductSchema = z.object({
  name: z.string().min(1).max(200),
  price: z.number().positive(),
  category: z.enum(["electronics", "clothing", "food"]),
  tags: z.array(z.string()).max(10).optional(),
})

// 2. Derive the TypeScript type from the schema
type CreateProductDto = z.infer<typeof createProductSchema>

export default defineEventHandler(async (event) => {
  // 3. Read the raw body (no TypeScript safety yet at runtime)
  const raw = await readBody(event)

  // 4. Runtime validation with Zod
  const result = createProductSchema.safeParse(raw)
  if (!result.success) {
    throw createError({
      statusCode: 400,
      statusMessage: "Validation failed",
      // flatten() gives { formErrors: [], fieldErrors: { price: ["Expected number"] } }
      data: result.error.flatten(),
    })
  }

  // 5. result.data is typed as CreateProductDto — safe to use
  const product = await db.create(result.data)
  setResponseStatus(event, 201)
  return product
})

// ── Typed return + Zod in one route ──────────────────────────────────────────
const updateProductSchema = z.object({
  name: z.string().min(1).optional(),
  price: z.number().positive().optional(),
})

interface Product {
  id: number
  name: string
  price: number
  updatedAt: string
}

export const updateHandler = defineEventHandler<Product>(async (event) => {
  const id = Number(getRouterParam(event, "id"))
  const raw = await readBody(event)

  const result = updateProductSchema.safeParse(raw)
  if (!result.success) {
    throw createError({ statusCode: 422, data: result.error.flatten() })
  }

  const updated = await db.update(id, result.data)
  if (!updated) throw createError({ statusCode: 404, statusMessage: "Not found" })
  return updated   // typed as Product
})

Caching JSON Responses

Bottom line: replace defineEventHandler with defineCachedEventHandler and add { maxAge: 60, swr: true } — Nitro caches the JSON response for 60 seconds and revalidates in the background on stale hits, with 0 changes to your handler logic.

The cache key is derived from the route path by default. Override it with a getKey function to scope caching to query parameters or user context. The varies array appends header values to the key — varies: ["x-user-id"] gives each user their own cache entry. For non-handler functions (e.g., a database fetch used by multiple routes), use defineCachedFunction — same API, same options. Invalidate a cache entry by removing its key from useStorage('nitro:cache'). External storage backends — Redis, filesystem, Cloudflare KV — are configured in nitro.config.ts under the storage key. The cache layer sits above the handler, so validation and auth logic still runs on every request if you place them before the cached call.

// server/api/products.get.ts
import { defineCachedEventHandler, getQuery } from "h3"

// maxAge: 60 → cache for 60 seconds
// swr: true  → return stale cache, revalidate in background
export default defineCachedEventHandler(
  async (event) => {
    const { category } = getQuery(event)
    return await db.findAll({ category })
  },
  {
    maxAge: 60,
    swr: true,
    // Custom cache key includes query params
    getKey: (event) => {
      const { category } = getQuery(event)
      return `products:${category ?? "all"}`
    },
  }
)

// ── Per-user caching with varies ─────────────────────────────────────────────
export const userDashboardHandler = defineCachedEventHandler(
  async (event) => {
    const userId = getHeader(event, "x-user-id")
    return await db.getDashboard(userId)
  },
  {
    maxAge: 300,   // 5 minutes
    swr: true,
    varies: ["x-user-id"],   // separate cache per user
  }
)

// ── defineCachedFunction: cache a non-route function ─────────────────────────
import { defineCachedFunction } from "nitropack/runtime"

const getCachedProducts = defineCachedFunction(
  async (category: string) => {
    return await db.findAll({ category })
  },
  { maxAge: 120, name: "products-by-category" }
)

// ── Cache invalidation ────────────────────────────────────────────────────────
import { defineEventHandler, useStorage } from "h3"

export const invalidateHandler = defineEventHandler(async () => {
  const cache = useStorage("nitro:cache")
  // Remove a specific cache entry by its generated key
  await cache.removeItem("products:electronics")
  return { invalidated: true }
})

// ── nitro.config.ts: Redis cache backend ─────────────────────────────────────
// export default defineNitroConfig({
//   storage: {
//     "nitro:cache": {
//       driver: "redis",
//       url: process.env.REDIS_URL,
//     },
//   },
// })

useStorage for JSON Persistence

Bottom line: useStorage("data") gives you a typed key-value store where objects go in and come back out as parsed JSON — 2 lines to persist a record, 1 line to retrieve it.

storage.setItem(key, value) serializes value to JSON and writes it to the configured driver. storage.getItem<T>(key) reads and parses the stored JSON, returning T | null — null when the key does not exist. storage.getKeys() returns all keys with the configured prefix, useful for listing all records. The development default is the filesystem driver — data lands in .nitro/data/ as .json files. Swap to Redis by adding a data key under storage in nitro.config.ts. For Cloudflare Workers deployments, the cloudflare-kv-binding driver maps to a Workers KV namespace with no code changes. Storage is the right tool for session data (keyed by session ID), counters (atomic increment with getItem + setItem), feature flags, and lightweight persistence where a full relational database adds unnecessary overhead.

// server/api/products/[id].get.ts
import { defineEventHandler, getRouterParam, useStorage, createError } from "h3"

interface Product {
  id: number
  name: string
  price: number
  createdAt: string
}

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, "id")
  const storage = useStorage("data")

  // getItem returns Product | null — auto-deserializes JSON
  const product = await storage.getItem<Product>(`products:${id}`)
  if (!product) throw createError({ statusCode: 404, statusMessage: "Not found" })

  return product
})

// ── CRUD with useStorage ──────────────────────────────────────────────────────
import { defineEventHandler, readBody, setResponseStatus, useStorage } from "h3"
import { z } from "zod"

const productSchema = z.object({ name: z.string().min(1), price: z.number().positive() })

export const createStorageHandler = defineEventHandler(async (event) => {
  const storage = useStorage("data")
  const body = productSchema.parse(await readBody(event))

  const id = Date.now()   // simple unique ID
  const product: Product = { id, ...body, createdAt: new Date().toISOString() }

  // setItem serializes the object to JSON automatically
  await storage.setItem(`products:${id}`, product)

  setResponseStatus(event, 201)
  return product
})

export const listStorageHandler = defineEventHandler(async () => {
  const storage = useStorage("data")
  // getKeys returns all keys with the "data:" prefix
  const keys = await storage.getKeys("products")

  const products = await Promise.all(
    keys.map(key => storage.getItem<Product>(key))
  )
  return products.filter(Boolean)   // remove any null entries
})

// ── nitro.config.ts: switch to Redis driver ───────────────────────────────────
// export default defineNitroConfig({
//   storage: {
//     data: {
//       driver: "redis",
//       url: process.env.REDIS_URL,   // "redis://localhost:6379"
//     },
//   },
// })

// ── Cloudflare KV driver (edge deployments) ───────────────────────────────────
// export default defineNitroConfig({
//   storage: {
//     data: {
//       driver: "cloudflare-kv-binding",
//       binding: "MY_KV_NAMESPACE",
//     },
//   },
// })

Error Handling and JSON Error Responses

Bottom line: throw createError(({ statusCode, statusMessage, data }) and Nitro sends a structured JSON error response automatically — no manual event.respondWith or status-setting needed.

The default error shape is { statusCode, statusMessage, message, data }. The data field is your extension point — pass Zod's result.error.flatten() for field-level errors, or a custom object for domain-specific context. Unhandled promise rejections inside defineEventHandler become HTTP 500 responses with a generic message — the original error is logged server-side but not exposed to clients. Always wrap async calls to external services in try-catch and rethrow as createError with an appropriate status code (502 for upstream failures, 503 for temporary unavailability). For centralized logging, create server/plugins/error-handler.ts and export a plugin that hooks into Nitro's error event. Nitro 2.9+ adds defineNitroErrorHandler — export it from server/error.ts to fully control the error response shape across all routes.

// server/api/products/[id].delete.ts — createError patterns
import { defineEventHandler, getRouterParam, createError } from "h3"

export default defineEventHandler(async (event) => {
  const id = Number(getRouterParam(event, "id"))
  if (isNaN(id)) throw createError({ statusCode: 400, statusMessage: "Invalid id" })

  const deleted = await db.delete(id)
  // → HTTP 404: { "statusCode": 404, "statusMessage": "Product not found" }
  if (!deleted) throw createError({ statusCode: 404, statusMessage: "Product not found" })

  return { deleted: true }   // HTTP 200
})

// ── 422 with field-level error data ──────────────────────────────────────────
throw createError({
  statusCode: 422,
  statusMessage: "Validation failed",
  data: {
    field: "price",
    message: "must be a positive number",
    received: -5,
  },
})
// Response body: { statusCode: 422, statusMessage: "Validation failed",
//                  data: { field: "price", ... } }

// ── Wrap external API calls ───────────────────────────────────────────────────
import { defineEventHandler, createError } from "h3"

export const externalHandler = defineEventHandler(async () => {
  let data: unknown
  try {
    const res = await fetch("https://api.example.com/products")
    if (!res.ok) throw new Error(`Upstream ${res.status}`)
    data = await res.json()
  } catch (err) {
    throw createError({
      statusCode: 502,
      statusMessage: "Upstream service unavailable",
      data: { cause: (err as Error).message },
    })
  }
  return data
})

// ── server/plugins/error-handler.ts: centralized logging ─────────────────────
export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook("error", (err, { event }) => {
    console.error("[Error]", {
      url: event?.path,
      status: err.statusCode ?? 500,
      message: err.message,
    })
  })
})

// ── server/error.ts: custom error shape (Nitro 2.9+) ─────────────────────────
// export default defineNitroErrorHandler((error, event) => {
//   setResponseStatus(event, error.statusCode ?? 500)
//   setResponseHeader(event, "Content-Type", "application/json")
//   return send(event, JSON.stringify({
//     ok: false,
//     code: error.statusCode,
//     message: error.statusMessage ?? "Internal Server Error",
//     ...(error.data ? { details: error.data } : {}),
//   }))
// })

FAQ

How do I return JSON from a Nitro route?

Return any JavaScript object from defineEventHandler — Nitro serializes it automatically and sets Content-Type: application/json. Use setResponseStatus(event, 201) before returning to send a custom status code. File-based routing maps HTTP method suffixes to handlers: products.get.ts handles GET, products.post.ts handles POST. Dynamic segments use bracket notation — products/[id].get.ts — and the param is read with getRouterParam(event, 'id'). Query strings come from getQuery(event). Add custom headers with setResponseHeader.

How do I parse a JSON request body in Nitro?

Use await readBody<T>(event) inside a defineEventHandler. It auto-parses application/json and URL-encoded bodies based on the Content-Type header. The generic T is compile-time only — no runtime validation. Use readRawBody(event) for the raw string. Malformed JSON triggers an automatic HTTP 400 before your code runs. Customize the error with a try-catch and createError({ statusCode: 400 }). For multipart form data with file uploads, use readMultipartFormData(event).

How do I validate JSON in a Nitro route?

readBody provides TypeScript types but no runtime validation — add Zod. Call const result = schema.safeParse(await readBody(event)). On failure, throw createError(({ statusCode: 400, data: result.error.flatten() }). Use z.infer<typeof schema> as the readBody generic to keep types in sync. A complete POST handler reads body, validates, creates the record, sets status 201, and returns the created resource — 5 steps, no type duplication.

How do I cache JSON responses in Nitro?

Replace defineEventHandler with defineCachedEventHandler and add { maxAge: 60, swr: true }. maxAge sets the TTL in seconds. swr: true returns stale cache immediately and revalidates in the background. Use varies: ["x-user-id"] for per-user caching. Invalidate via useStorage('nitro:cache').removeItem(key). Configure Redis as the cache backend in nitro.config.ts under the storage key.

How do I store JSON data in Nitro without a database?

useStorage("data") returns a key-value store. setItem(key, obj) serializes to JSON automatically. getItem<T>(key) parses and types the result, returning T | null. getKeys() lists all keys. The default driver writes .json files to .nitro/data/. Switch to Redis or Cloudflare KV by configuring the data driver in nitro.config.ts — no application code changes needed.

How do I return JSON error responses in Nitro?

Throw createError(({ statusCode, statusMessage, data }) — Nitro catches it and sends a structured JSON response. The data field passes extra context like field-level validation errors. For custom error shapes across all routes, export a defineNitroErrorHandler from server/error.ts (Nitro 2.9+). Centralized logging goes in server/plugins/error-handler.ts via the nitro.hooks.hook('error') callback. Unhandled promise rejections become HTTP 500 — always wrap async external calls in try-catch and rethrow as createError.

Key Terms

defineEventHandler
The core Nitro/H3 function for defining a route handler. Accepts an async function receiving an H3Event and returning any serializable value. Nitro wraps the return value in a JSON response automatically.
readBody()
An H3 utility that reads and parses the request body. Handles application/json (via JSON.parse) and URL-encoded forms. Accepts a TypeScript generic for compile-time typing but performs no runtime validation.
createError()
Creates an H3 error that Nitro serializes into a JSON error response with statusCode, statusMessage, and optional data fields. Throwing it from inside a handler short-circuits the response.
defineCachedEventHandler
A Nitro wrapper around defineEventHandler that caches the JSON response. Configured with maxAge (TTL in seconds), swr (stale-while-revalidate), and varies (per-header cache keys).
useStorage()
Returns an unstorage driver instance scoped to a namespace. Provides setItem, getItem, and getKeys with automatic JSON serialization and deserialization. Driver is configured in nitro.config.ts.
H3 event
The request context object passed to every handler. Exposes the request, response, route params, query string, headers, and H3 utility functions. Always the first argument of defineEventHandler.

Building with Nuxt or Hono instead?

Nitro powers the Nuxt server layer — see how JSON flows end-to-end in JSON in Nuxt. For a lighter HTTP framework with similar file-based patterns, see JSON in Hono.

Open JSON Tools on jsonic.io

Further reading and primary sources

  • Nitro official documentationOfficial Nitro docs covering route handlers, file-based routing, storage, caching, and deployment targets including Node.js, Cloudflare Workers, and Bun.
  • H3 framework — event utilitiesFull reference for H3 request utilities: readBody, readRawBody, readMultipartFormData, getQuery, getRouterParam, getHeader, and more.
  • Nitro storage (unstorage)Official Nitro storage guide covering useStorage, setItem, getItem, getKeys, and driver configuration for filesystem, Redis, and Cloudflare KV.
  • Nitro caching — defineCachedEventHandlerOfficial Nitro caching guide: defineCachedEventHandler, maxAge, swr, varies, defineCachedFunction, and external cache storage configuration.
  • Zod documentationZod schema validation library. Covers object schemas, safeParse, error formatting with flatten(), and z.infer for TypeScript type derivation.