Laut.ar/o

Validación de Datos en APIs de Nuxt: Más Seguridad con Menos Código

23 de June del 2025

Cuando desarrollás APIs en Nuxt, validar los datos que recibís es fundamental para la seguridad y estabilidad de tu aplicación. Gracias a h3 (el framework HTTP que usa Nitro por debajo), tenemos funciones especiales que combinan lectura y validación en un solo paso.

En lugar de usar las funciones tradicionales como readBody, getQuery y getRouterParams, podés usar sus versiones "validated" que no solo obtienen los datos sino que también los validan automáticamente.

El problema con la validación manual

Tradicionalmente, cuando querías validar datos en un endpoint, tenías que hacer algo así:

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  
  // Validación manual
  if (!body.name || typeof body.name !== 'string') {
    throw createError({
      statusCode: 400,
      statusMessage: 'Nombre requerido'
    })
  }
  
  if (!body.email || !isValidEmail(body.email)) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Email inválido'
    })
  }
  
  // Más validaciones...
  
  // Finalmente usar los datos
  return { message: `Hola ${body.name}` }
})

Esto funciona, pero es repetitivo, propenso a errores y no tenés type safety — TypeScript no sabe qué estructura tienen tus datos validados.

La solución: funciones validated

h3 (el framework HTTP que usa Nitro) incluye tres funciones especiales que combinan lectura y validación:

  • readValidatedBody() - Para validar el body de requests POST/PUT/PATCH
  • getValidatedQuery() - Para validar query parameters
  • getValidatedRouterParams() - Para validar parámetros de rutas

Cómo funcionan

Todas estas funciones reciben dos parámetros:

  1. El evento (como las funciones normales)
  2. Una función validadora que recibe los datos y debe:
    • Retornar los datos validados si son correctos
    • Hacer throw o retornar false si son inválidos

Si la validación falla, el endpoint automáticamente devuelve un error HTTP. Si pasa, continuás con los datos ya validados y con el tipo correcto.

Ejemplo práctico con Zod

Zod es perfecto para esto porque sus funciones parse() hacen exactamente lo que necesitamos:

import { z } from 'zod'

const bodySchema = z.object({
  name: z.string().min(1, 'Nombre requerido'),
  email: z.string().email('Email inválido'),
  message: z.string().min(10, 'Mensaje muy corto'),
})

export default defineEventHandler(async (event) => {
  // body va a tener el tipo { name: string, email: string, message: string }
  const body = await readValidatedBody(event, bodySchema.parse)
  
  // Ya podés usar los datos con confianza
  return { 
    message: `Gracias ${body.name}, tu mensaje fue recibido.`
  }
})

¡Eso es todo! Con una sola línea tenés:

  • ✅ Datos leídos
  • ✅ Datos validados
  • ✅ Type safety completo
  • ✅ Errores automáticos si la validación falla

Ejemplo con Valibot

Si preferís Valibot, podés hacer algo similar envolviendo la función parse:

import * as v from 'valibot'

const bodySchema = v.object({
  name: v.pipe(v.string(), v.minLength(1, 'Nombre requerido')),
  email: v.pipe(v.string(), v.email('Email inválido')),
  message: v.pipe(v.string(), v.minLength(10, 'Mensaje muy corto')),
})

export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, (data) => v.parse(bodySchema, data))
  
  return {
    message: `Hola ${body.name}, recibimos tu consulta.`
  }
})

Validando query parameters

Para validar parámetros de query (como ?page=1&limit=10), usás getValidatedQuery:

import { z } from 'zod'

const querySchema = z.object({
  page: z.coerce.number().min(1).default(1),
  limit: z.coerce.number().min(1).max(100).default(10),
  search: z.string().optional(),
})

export default defineEventHandler(async (event) => {
  const query = getValidatedQuery(event, querySchema.parse)
  
  // query tiene el tipo { page: number, limit: number, search?: string }
  return {
    pagination: {
      page: query.page,
      limit: query.limit,
      search: query.search
    }
  }
})

Tip: Usá z.coerce.number() para convertir automáticamente strings a números, ya que los query params siempre llegan como strings.

Validando parámetros de ruta

Para rutas como /api/users/[id].get.ts, podés validar el parámetro id:

import { z } from 'zod'

const paramsSchema = z.object({
  id: z.string().uuid('ID debe ser un UUID válido'),
})

export default defineEventHandler(async (event) => {
  const params = getValidatedRouterParams(event, paramsSchema.parse)
  
  // params.id es un string que sabemos que es un UUID válido
  const user = await getUserById(params.id)
  
  return { user }
})

Ejemplo completo: API de contacto

Acá tenés un ejemplo completo de un endpoint de contacto que valida tanto el body como un token de Turnstile:

import { z } from 'zod'

const contactSchema = z.object({
  name: z.string().min(1, 'Nombre es requerido'),
  email: z.string().email('Email inválido'),
  message: z.string().min(10, 'El mensaje debe tener al menos 10 caracteres'),
  'cf-turnstile-response': z.string().min(1, 'Verificación requerida'),
})

export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, contactSchema.parse)
  
  // Verificar Turnstile
  const verified = await verifyTurnstileToken(body['cf-turnstile-response'])
  if (!verified) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Verificación de Turnstile falló'
    })
  }
  
  // Enviar email
  await sendContactEmail({
    name: body.name,
    email: body.email,
    message: body.message,
  })
  
  return { message: 'Mensaje enviado correctamente' }
})

Ventajas de esta aproximación

  1. Menos código: Una línea en lugar de múltiples validaciones manuales
  2. Type safety: TypeScript conoce exactamente la estructura de tus datos
  3. Errores consistentes: Los errores de validación se manejan automáticamente
  4. Mejor DX: Menos boilerplate, más foco en la lógica de negocio
  5. Reutilizable: Los esquemas se pueden reutilizar en el frontend

¿Y si necesito personalizar el error?

Si necesitás más control sobre los errores, podés envolver tu función validadora:

const body = await readValidatedBody(event, (data) => {
  try {
    return bodySchema.parse(data)
  } catch (error) {
    throw createError({
      statusCode: 422,
      statusMessage: 'Datos inválidos',
      data: error.issues // Para Zod
    })
  }
})

Conclusión

Las funciones readValidatedBody, getValidatedQuery y getValidatedRouterParams de h3 hacen que validar datos en tus APIs de Nuxt sea súper simple y seguro. Con librerías como Zod o Valibot, tenés validación robusta con type safety completo en una sola línea.

Es una de esas features que una vez que las usás, no podés volver atrás. Menos código, más seguridad, mejor experiencia de desarrollo.

Suscribite a mi newsletter

Te voy a enviar una vez cada tanto un email con los posts más recientes que haya publicado.

Copyright © 2025 Lautaro Pereyra