Laut.ar/o

Formulario de Contacto con Cloudflare Turnstile en Nuxt

22 de June del 2025

Los formularios de contacto son esenciales en cualquier sitio web, pero sin protección contra spam pueden convertirse en una pesadilla. Cloudflare Turnstile es una excelente alternativa gratuita a reCAPTCHA que es menos intrusiva para los usuarios y fácil de implementar.

En este artículo te muestro cómo crear un formulario de contacto seguro en Nuxt, empezando con un ejemplo vanilla y luego mejorándolo con Nuxt UI.

Configuración inicial

Antes de empezar, necesitás configurar las credenciales de Turnstile. Tenés dos opciones dependiendo de si estás en desarrollo o producción:

Para desarrollo y testing

Durante el desarrollo, Cloudflare ofrece credenciales de prueba que funcionan desde cualquier dominio, incluyendo localhost, sin necesidad de registrarte o configurar dominios específicos.

Site Keys de prueba (públicas)

Site KeyDescripciónVisibilidad
1x00000000000000000000AASiempre pasaVisible
2x00000000000000000000ABSiempre bloqueaVisible
1x00000000000000000000BBSiempre pasaInvisible
2x00000000000000000000BBSiempre bloqueaInvisible
3x00000000000000000000FFFuerza un desafío interactivoVisible

Secret Keys de prueba (privadas)

Secret KeyDescripción
1x0000000000000000000000000000000AASiempre pasa
2x0000000000000000000000000000000AASiempre falla
3x0000000000000000000000000000000AADevuelve error "token already spent"

Ejemplo de configuración para desarrollo

# .env.development o .env.local
NUXT_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
NUXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA

Importante: Las credenciales de prueba generan un token dummy (XXXX.DUMMY.TOKEN.XXXX) que solo es aceptado por las secret keys de prueba. Las claves reales de producción rechazarán este token para prevenir configuraciones erróneas.

Para producción

Para usar en producción, necesitás registrarte en Cloudflare Turnstile y crear un widget desde el panel de control.

En el panel de Cloudflare, creá un nuevo widget de Turnstile:

Configuración de Turnstile Widget

  1. Añadí el dominio de tu sitio web
  2. Elegí el modo: Recomiendo "Managed" para la mayoría de casos
  3. Pre-clearance: Podés dejarlo en "No" para empezar

Una vez creado, obtenés las claves que necesitás:

Claves de Turnstile

Configuración en Nuxt

Con tus credenciales (ya sean de desarrollo o producción), configurá tu nuxt.config.ts:

export default defineNuxtConfig({
  runtimeConfig: {
    // Private keys (solo disponibles en el servidor)
    turnstileSecretKey: '',
    public: {
      // Public keys (disponibles en cliente y servidor)
      turnstileSiteKey: '',
    }
  }
})

Los strings vacíos se usan porque Nuxt automáticamente reemplaza estos valores con las variables de entorno correspondientes. Nuxt busca variables que tengan el prefijo NUXT_ para claves privadas y NUXT_PUBLIC_ para claves públicas.

Creá un archivo .env con tus claves:

# Para desarrollo
NUXT_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
NUXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA

# Para producción
# NUXT_TURNSTILE_SECRET_KEY=tu-clave-secreta-de-turnstile
# NUXT_PUBLIC_TURNSTILE_SITE_KEY=tu-clave-publica-de-turnstile

Implementación básica

Empecemos con un formulario simple sin librerías adicionales. Primero, el componente del widget de Turnstile:

<!-- ~/components/TurnstileWidget.vue -->
<script setup lang="ts">
const { public: { turnstileSiteKey } } = useRuntimeConfig()

// Cargar el script de Turnstile usando useHead
useHead({
  script: [
    {
      src: 'https://challenges.cloudflare.com/turnstile/v0/api.js',
      async: true,
      defer: true,
    }
  ]
})
</script>

<template>
  <div
    class="cf-turnstile"
    :data-sitekey="turnstileSiteKey"
  />
</template>

Ahora el formulario principal:

<!-- ~/components/ContactForm.vue -->
<script setup lang="ts">
const formData = ref({
  name: '',
  email: '',
  message: ''
})

const loading = ref(false)
const success = ref(false)
const error = ref('')

async function handleSubmit(event: Event) {
  event.preventDefault()
  
  const form = event.target as HTMLFormElement
  const turnstileResponse = (form.querySelector('[name="cf-turnstile-response"]') as HTMLInputElement)?.value
  
  if (!turnstileResponse) {
    error.value = 'Por favor, completá la verificación'
    return
  }
  
  loading.value = true
  error.value = ''
  
  try {
    await $fetch('/api/send-message', {
      method: 'POST',
      body: {
        ...formData.value,
        'cf-turnstile-response': turnstileResponse
      }
    })
    
    // Reset form
    formData.value = { name: '', email: '', message: '' }
    success.value = true
    
    // Reset Turnstile
    if (window.turnstile) {
      window.turnstile.reset()
    }
    
  } catch (err: any) {
    error.value = err.data?.message || 'Error al enviar el mensaje'
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <div>
    <h2>Contacto</h2>
    <p>Si tenés alguna pregunta, podés contactarme a través de este formulario.</p>
    
    <!-- Success message -->
    <div v-if="success">
      ✅ Tu mensaje ha sido enviado. Gracias por contactarme.
    </div>
    
    <!-- Error message -->
    <div v-if="error">
      ❌ {{ error }}
    </div>
    
    <form @submit="handleSubmit">
      <div>
        <label for="name">Nombre *</label>
        <input
          id="name"
          v-model="formData.name"
          type="text"
          required
        >
      </div>
      
      <div>
        <label for="email">Email *</label>
        <input
          id="email"
          v-model="formData.email"
          type="email"
          required
        >
      </div>
      
      <div>
        <label for="message">Mensaje *</label>
        <textarea
          id="message"
          v-model="formData.message"
          rows="4"
          required
        />
      </div>
      
      <TurnstileWidget />
      
      <button type="submit" :disabled="loading">
        {{ loading ? 'Enviando...' : 'Enviar mensaje' }}
      </button>
    </form>
  </div>
</template>

Endpoint del servidor

En el lado del servidor necesitás verificar el token de Turnstile y procesar el formulario:

// ~~/server/api/send-message.post.ts
import { z } from 'zod'

// Definimos el esquema de validación con Zod
// Esto nos permite validar automáticamente los datos que llegan del frontend
const bodySchema = z.object({
  name: z.string().min(1, 'El nombre es requerido'),           // String no vacío
  email: z.string().email('El email no es válido'),           // Email válido
  message: z.string().min(1, 'El mensaje es requerido'),      // String no vacío
  'cf-turnstile-response': z.string().min(1, 'El token de verificación es requerido'), // Token de Turnstile
})

export default defineEventHandler(async (event) => {
  // readValidatedBody automáticamente valida los datos usando nuestro schema
  // Si los datos no son válidos, automáticamente devuelve un error 400
  // Si son válidos, continúa y tenemos garantía de types correctos
  const body = await readValidatedBody(event, bodySchema.parse)
  const { turnstileSecretKey } = useRuntimeConfig(event)

  // Verificar el token de Turnstile con la API de Cloudflare
  const ip = getRequestHeader(event, 'CF-Connecting-IP')
  
  const turnstile = await $fetch<{ success: boolean }>('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      secret: turnstileSecretKey,
      response: body['cf-turnstile-response'],
      remoteip: ip || '',
    } as Record<string, string>).toString(),
  })

  // Si Turnstile dice que el token no es válido, devolvemos error
  if (!turnstile.success) {
    throw createError({
      statusCode: 400,
      message: 'El token de verificación es inválido',
    })
  }

  // En este punto sabemos que:
  // 1. Los datos tienen el formato correcto (gracias a Zod)
  // 2. El usuario pasó la verificación anti-spam (gracias a Turnstile)
  // 3. Podemos procesar los datos de forma segura
  
  // Acá podés hacer lo que necesites con los datos:
  // - Enviar un email
  // - Guardar en base de datos
  // - Enviar a un webhook
  // - Procesar con cualquier servicio
  
  console.log('Mensaje recibido:', body)

  return sendNoContent(event)
})

Versión con Nuxt UI

Si ya estás usando Nuxt UI, podés hacer todo mucho más elegante y con menos código. Nuxt UI incluye validación automática, mejor UX y componentes ya estilizados:

<!-- ~/components/ContactForm.vue -->
<script setup lang="ts">
import type { FormSubmitEvent } from '@nuxt/ui'
import { z } from 'zod'

const schema = z.object({
  name: z.string().min(1, 'El nombre es requerido'),
  email: z.string().email('El email no es válido'),
  message: z.string().min(1, 'El mensaje es requerido'),
})

const data = reactive({
  name: '',
  email: '',
  message: '',
})

const success = ref(false)
const error = ref(false)
const loading = ref(false)

async function sendMessage(event: FormSubmitEvent<z.output<typeof schema>>) {
  if (!schema.safeParse(data).success) return

  error.value = false
  loading.value = true

  try {
    await $fetch('/api/send-message', {
      method: 'POST',
      body: {
        ...data,
        'cf-turnstile-response': (event.target as HTMLFormElement).querySelector('[name="cf-turnstile-response"]') as HTMLInputElement)?.value ?? '',
      }
    })

    data.name = ''
    data.email = ''
    data.message = ''
    success.value = true
  } catch (err) {
    console.error(err)
    error.value = true
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <UAlert
    v-if="success"
    icon="carbon:checkmark-filled"
    color="success"
    title="Tu mensaje ha sido enviado"
    description="Gracias por contactarme. Intentaré responder lo antes posible."
  />
  <UAlert
    v-if="error"
    icon="carbon:warning-filled"
    color="error"
    title="Ha ocurrido un error"
    description="Por favor, intenta nuevamente."
  />
  
  <UForm :state="data" :schema="schema" class="flex flex-col gap-4" @submit="sendMessage">
    <div class="flex flex-col gap-2">
      <span class="text-2xl font-bold">Contacto</span>
      <p class="text-sm text-muted">
        Si tenés alguna pregunta, o simplemente querés saludar, podés contactarme a través de este formulario.
      </p>
    </div>
    
    <UFormField label="Nombre" name="name" required>
      <UInput v-model="data.name" class="w-full" color="neutral" />
    </UFormField>
    
    <UFormField label="Email" name="email" required>
      <UInput v-model="data.email" class="w-full" color="neutral" icon="carbon:email" />
    </UFormField>
    
    <UFormField label="Mensaje" name="message" required>
      <UTextarea v-model="data.message" class="w-full" color="neutral" />
    </UFormField>
    
    <TurnstileWidget class="mx-auto" />
    
    <UButton class="ml-auto" type="submit" label="Enviar" trailing-icon="carbon:send" :loading="loading" />
  </UForm>
</template>

Y el widget de Turnstile para Nuxt UI puede usar useScript que es más limpio y optimizado:

<!-- ~/components/TurnstileWidget.vue -->
<script setup lang="ts">
const { public: { turnstileSiteKey } } = useRuntimeConfig()

useScript({
  src: 'https://challenges.cloudflare.com/turnstile/v0/api.js',
  async: true,
  defer: true,
})
</script>

<template>
  <div
    class="cf-turnstile"
    :data-sitekey="turnstileSiteKey"
  />
</template>

Nota importante: Para usar useScript necesitás instalar el módulo @nuxt/scripts:

npm install @nuxt/scripts

Y agregarlo a tu nuxt.config.ts:

export default defineNuxtConfig({
  modules: ['@nuxt/scripts'],
  // ... resto de tu configuración
})

useScript optimiza automáticamente la carga de scripts externos con beneficios como:

  • Lazy loading - Solo carga cuando es necesario
  • Deduplicación - Evita cargar el mismo script múltiples veces
  • Performance optimizada - Mejora Core Web Vitals
  • SSR-friendly - Maneja correctamente la hidratación

Si preferís no usar este módulo, podés mantener la versión con useHead que funciona igual de bien.

Integración con Resend

Una vez que tenés los datos validados, probablemente querés que te lleguen por email para poder responder a las consultas. Resend es una excelente opción para esto.

La idea es simple: cada vez que alguien complete el formulario, vos recibís un email con los datos de contacto (nombre, email y mensaje). Podés usar cualquier email personal como destino - tu Gmail, Outlook, o el que prefieras.

Acá te muestro cómo configurarlo:

Primero, instalá Resend:

npm install resend

Luego agregá tu API key a la configuración:

// ~~/nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    resendApiKey: '', // Variable privada
    turnstileSecretKey: '',
    public: {
      turnstileSiteKey: '',
    }
  }
})

Y en tu .env:

NUXT_RESEND_API_KEY=tu-clave-de-resend

Modificá el endpoint para incluir el envío de email:

// server/api/send-message.post.ts
import { z } from 'zod'
import { Resend } from 'resend'

const bodySchema = z.object({
  name: z.string().min(1, 'El nombre es requerido'),
  email: z.string().email('El email no es válido'),
  message: z.string().min(1, 'El mensaje es requerido'),
  'cf-turnstile-response': z.string().min(1, 'El token de verificación es requerido'),
})

export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, bodySchema.parse)
  const { resendApiKey, turnstileSecretKey } = useRuntimeConfig(event)

  // Verificar Turnstile (mismo código de antes)
  const ip = getRequestHeader(event, 'CF-Connecting-IP')
  
  const turnstile = await $fetch<{ success: boolean }>('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      secret: turnstileSecretKey,
      response: body['cf-turnstile-response'],
      remoteip: ip || '',
    } as Record<string, string>).toString(),
  })

  if (!turnstile.success) {
    throw createError({
      statusCode: 400,
      message: 'El token de verificación es inválido',
    })
  }

  // Enviar email con Resend
  const resend = new Resend(resendApiKey)
  
  await resend.emails.send({
    from: 'Contacto <contactform@tudominio.com>', // Email desde el que se envía (debe ser de tu dominio)
    to: 'tu-email@gmail.com', // Tu email personal donde recibís las consultas
    subject: 'Nuevo mensaje del formulario de contacto',
    html: `
      <h3>Nuevo mensaje de contacto</h3>
      <p><strong>Nombre:</strong> ${body.name}</p>
      <p><strong>Email:</strong> ${body.email}</p>
      <p><strong>Mensaje:</strong></p>
      <p>${body.message.replace(/\n/g, '<br>')}</p>
    `,
  })

  return sendNoContent(event)
})
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