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.
Antes de empezar, necesitás configurar las credenciales de Turnstile. Tenés dos opciones dependiendo de si estás en desarrollo o producción:
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 Key | Descripción | Visibilidad |
---|---|---|
1x00000000000000000000AA | Siempre pasa | Visible |
2x00000000000000000000AB | Siempre bloquea | Visible |
1x00000000000000000000BB | Siempre pasa | Invisible |
2x00000000000000000000BB | Siempre bloquea | Invisible |
3x00000000000000000000FF | Fuerza un desafío interactivo | Visible |
Secret Key | Descripción |
---|---|
1x0000000000000000000000000000000AA | Siempre pasa |
2x0000000000000000000000000000000AA | Siempre falla |
3x0000000000000000000000000000000AA | Devuelve error "token already spent" |
# .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 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:
Una vez creado, obtenés las claves que necesitás:
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
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>
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)
})
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:
Si preferís no usar este módulo, podés mantener la versión con useHead
que funciona igual de bien.
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)
})