Formulario de suscripción a newsletter8 min de lectura
Crea un formulario de suscripción a newsletter en Nuxt
Un formulario de newsletter en Nuxt que funciona con o sin JavaScript: una server route con comprobación de Turnstile envía a Ikiform, y una ruta de Nitro alimenta beehiiv.
Dale al formulario un action real que apunte a una server route de Nuxt y mejóralo con $fetch para que los lectores con JavaScript se queden en la página. La ruta comprueba la casilla de consentimiento y un token de Turnstile de @nuxtjs/turnstile, reenvía la suscripción a tu backend de formularios con una clave secreta y responde con JSON o con una redirección según quién haya preguntado. Una segunda ruta convierte el webhook firmado del backend en una suscripción de beehiiv.
La pestaña de Nuxt de la guía principal envía con $fetch y funciona una vez que Vue se ha hidratado. Esta página hace que el mismo formulario funcione antes de eso, añade una verificación humana y envía las suscripciones reales a beehiiv. La configuración del producto está en la guía de Nuxt de la documentación.
Una ruta, dos tipos de llamante
Una server route de Nuxt no sabe si la llamó un formulario del navegador o $fetch, así que lo decide por la cabecera Accept. El $fetch del componente pide JSON y recibe JSON o un error con un motivo. Un envío de formulario normal, antes de la hidratación o con los scripts desactivados, pide HTML, así que la ruta responde 303 a /newsletter con un código de resultado:
- El formulario envía a /api/newsletter, mediante $fetch una vez hidratado o como envío de formulario normal antes.
- La ruta comprueba el consentimiento y el token de Turnstile, y luego reenvía la suscripción a Ikiform.
- Lee la cabecera Accept para decidir cómo responder.
- $fetch pidió JSON: si todo va bien devuelve JSON, y si hay un problema lanza un error con un motivo que muestra el componente.
- Un envío de formulario normal recibe una redirección 303 a /newsletter con un código de resultado, y la página convierte el código en un mensaje.
Configuración del proyecto
Instala el módulo de Turnstile con npx nuxi@latest module add turnstile y luego:
app/
components/NewsletterForm.vue
pages/newsletter.vue
server/
api/newsletter.post.ts
routes/hooks/ikiform.post.ts
nuxt.config.ts
.envexport default defineNuxtConfig({
modules: ["@nuxtjs/turnstile"],
turnstile: { siteKey: "your_turnstile_site_key" },
runtimeConfig: {
ikiformSecretKey: "",
ikiformWebhookSecret: "",
beehiivApiKey: "",
beehiivPublicationId: "",
turnstile: { secretKey: "" },
},
routeRules: {
"/blog/**": { prerender: true },
},
});NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
NUXT_BEEHIIV_API_KEY=your_beehiiv_api_key
NUXT_BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000
NUXT_TURNSTILE_SECRET_KEY=your_turnstile_secret_keyCada clave de runtimeConfig se sobrescribe en tiempo de ejecución con la variable NUXT_ correspondiente, así que la configuración tiene cadenas vacías y el hosting tiene los valores. Nada fuera de runtimeConfig.public llega al navegador. La site key de Turnstile es pública por diseño. Crea la clave de Ikiform en API keys con el scope submissions:write y pega la definición de campos de la guía principal en la pestaña Fields del formulario.
Constrúyelo
La server route
interface Signup {
email?: string;
first_name?: string;
consent?: boolean | string;
source?: string;
_gotcha?: string;
"cf-turnstile-response"?: string;
}
type Result = "subscribed" | "consent" | "check" | "email" | "failed";
const PLACEMENTS = ["footer", "post", "newsletter-page"];
export default defineEventHandler(async (event) => {
const body = (await readBody<Signup>(event)) ?? {};
const wantsJson = getRequestHeader(event, "accept")?.includes("application/json") ?? false;
const config = useRuntimeConfig(event);
const finish = (result: Result, status = 200, message = "") => {
if (!wantsJson) return sendRedirect(event, `/newsletter?result=${result}`, 303);
if (result === "subscribed") return { subscribed: true };
throw createError({ status, message, data: { reason: result } });
};
if (body.consent !== true && body.consent !== "true") {
return finish("consent", 422, "Tick the box so we know you want the emails.");
}
const check = await verifyTurnstileToken(String(body["cf-turnstile-response"] ?? ""), event);
if (!check.success) return finish("check", 403, "The human check didn't pass. Try once more.");
const source = PLACEMENTS.includes(String(body.source)) ? String(body.source) : "footer";
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
email: String(body.email ?? "").trim(),
first_name: String(body.first_name ?? "").trim().slice(0, 80) || undefined,
consent: true,
source,
_gotcha: String(body._gotcha ?? ""),
}),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return finish("subscribed");
const emailError = result?.error?.fields?.email?.[0];
if (emailError) return finish("email", 422, emailError);
return finish("failed", response?.status ?? 502, result?.error?.message ?? "We couldn't reach the list. Try again.");
});readBodyanaliza tanto el JSON de$fetchcomo el cuerpo URL-encoded de un envío de formulario normal. Por eso el consentimiento se acepta comotrueo"true".verifyTurnstileTokenviene del módulo de Turnstile y leeruntimeConfig.turnstile.secretKey. Los tokens son de un solo uso y caducan a los cinco minutos.- La línea de
Originreenvía el origen del navegador, porque Ikiform reconoce por esa cabecera las pruebas gratuitas desdelocalhosty elfetchde un servidor no envía ninguna. useRuntimeConfig(event)obtiene los valores con las sobrescriturasNUXT_de tiempo de ejecución aplicadas, como recomienda Nuxt en las server routes.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.El componente del formulario
<script setup lang="ts">
const props = withDefaults(defineProps<{ source?: "footer" | "post" | "newsletter-page" }>(), { source: "footer" });
const turnstile = ref<{ reset: () => void }>();
const token = ref("");
const status = ref<"idle" | "sending" | "done">("idle");
const problem = ref<{ reason: string; message: string } | null>(null);
async function subscribe(event: Event) {
const fields = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
status.value = "sending";
problem.value = null;
try {
await $fetch("/api/newsletter", {
method: "POST",
headers: { Accept: "application/json" },
body: { ...fields, consent: fields.consent === "true", "cf-turnstile-response": token.value },
});
status.value = "done";
} catch (failure) {
const data = (failure as { data?: { message?: string; data?: { reason?: string } } }).data;
problem.value = { reason: data?.data?.reason ?? "failed", message: data?.message ?? "Couldn't subscribe. Try again." };
turnstile.value?.reset();
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'done'" role="status">Almost done. Open the email we sent you and confirm your address.</p>
<form v-else action="/api/newsletter" method="post" @submit.prevent="subscribe">
<label>
Email
<input name="email" type="email" required autocomplete="email" :aria-invalid="problem?.reason === 'email'" />
</label>
<label>
First name (optional)
<input name="first_name" maxlength="80" autocomplete="given-name" />
</label>
<label>
<input name="consent" type="checkbox" value="true" required :aria-invalid="problem?.reason === 'consent'" />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" :value="props.source" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<NuxtTurnstile ref="turnstile" v-model="token" />
<p v-if="problem" role="alert">{{ problem.message }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
</form>
</template>- El formulario conserva
actionymethod, así que envía de la forma normal hasta que@submit.preventtoma el control tras la hidratación. <NuxtTurnstile>renderiza el widget y pone su token entoken. Tras cualquier error el token queda gastado, así quereset()pide uno nuevo.- Los campos se leen del
FormDataantes del primerawait, mientrasevent.currentTargetaún apunta al formulario.
La página /newsletter
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
subscribed: "Almost done. Open the email we sent you and confirm your address.",
consent: "Tick the box so we know you want the emails.",
check: "The human check needs JavaScript. Turn it on and try again.",
email: "Check the email address and try again.",
failed: "We couldn't reach the list. Try again.",
};
const route = useRoute();
const message = computed(() => MESSAGES[String(route.query.result)] ?? "");
</script>
<template>
<main>
<h1>The monthly newsletter</h1>
<p v-if="message" :role="route.query.result === 'subscribed' ? 'status' : 'alert'">{{ message }}</p>
<NewsletterForm v-if="route.query.result !== 'subscribed'" source="newsletter-page" />
</main>
</template>Turnstile necesita JavaScript, así que un lector con los scripts desactivados recibe el mensaje check. Si ese equilibrio no le conviene a tus lectores, quita las líneas de Turnstile y confía en el honeypot y la casilla obligatoria.
Reenvía los suscriptores a beehiiv
Esta ruta está en server/routes, así que no tiene prefijo /api. Lee el cuerpo con readRawBody antes de que nada lo analice, porque la firma cubre los bytes exactos, y crea la suscripción con double_opt_override: "on" para que beehiiv envíe la confirmación. La guía de beehiiv añade el nombre y las propias cabeceras de límite de frecuencia de beehiiv.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
let delivery;
try {
delivery = await verifyWebhook({
payload: (await readRawBody(event)) ?? "",
headers: event.headers,
secret: config.ikiformWebhookSecret,
});
} catch (error) {
if (error instanceof WebhookVerificationError) {
setResponseStatus(event, 400);
return error.reason;
}
throw error;
}
if (delivery.type !== "submission.created") return sendNoContent(event);
const { email, consent, source } = delivery.data.fields;
if (consent !== true || typeof email !== "string") return "Skipped";
const answer = await fetch(
`https://api.beehiiv.com/v2/publications/${config.beehiivPublicationId}/subscriptions`,
{
method: "POST",
headers: { Authorization: `Bearer ${config.beehiivApiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
email,
double_opt_override: "on",
reactivate_existing: false,
utm_source: typeof source === "string" ? source : "website",
referring_site: delivery.data.meta.referrer ?? undefined,
}),
},
);
if (answer.status === 429) {
setResponseHeader(event, "retry-after", 60);
setResponseStatus(event, 503);
return "beehiiv rate limit";
}
if (!answer.ok) {
setResponseStatus(event, 502);
return `beehiiv answered ${answer.status}`;
}
return sendNoContent(event);
});Añade https://your-site.com/hooks/ikiform en Webhooks, elige el formulario de la newsletter y copia el secreto whsec_ en NUXT_IKIFORM_WEBHOOK_SECRET.
Honeypot y verificación humana
El input _gotcha se reenvía e Ikiform guarda como spam uno relleno, con una respuesta de éxito y sin webhook, así que beehiiv nunca lo ve. La clave secreta se salta la propia comprobación de Turnstile de Ikiform, por eso la ruta ejecuta una con tu propio widget.
Renderizado: híbrido, no generate
nuxt generate escribe un sitio estático sin servidor, así que /api/newsletter y /hooks/ikiform no existen después. Haz el build con nuxt build y prerenderiza las páginas que puedan ser estáticas mediante routeRules, como hace la configuración de arriba con /blog/**. Las páginas prerenderizadas conservan el formulario, y su envío sigue llegando a la server route.
Despliegue
| Host | Qué hacer |
|---|---|
| Vercel | Nitro detecta Vercel y genera las server routes como funciones. Añade las variables NUXT_ en Environment Variables. |
| Netlify | Nitro detecta Netlify de la misma forma. Añade las variables en los ajustes de entorno del sitio. |
| Cloudflare | Haz el build con nuxt build, no con nuxt generate, y define las variables NUXT_ como secretos del proyecto. Usa un dominio propio para la URL del webhook; Ikiform rechaza workers.dev y pages.dev. |
Pruébalo gratis en localhost
Un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde localhost, y el Origin reenviado hace que cada suscripción desde nuxt dev cuente como uno de ellos. Usa en local las claves de prueba de Turnstile que publica Cloudflare para que la comprobación siempre pase. Ikiform no entrega webhooks a localhost; expón el servidor de desarrollo mediante un túnel con URL HTTPS para probar /hooks/ikiform.
Comprueba que funciona
- Ejecuta
nuxt devy suscríbete desde el footer. El formulario se sustituye por la línea que pide revisar la bandeja de entrada y la URL no cambia. - Desactiva JavaScript y suscríbete. Llegas a
/newsletter?result=check, porque el widget no pudo ejecutarse. - Con JavaScript activado, quita
requiredde la casilla y envía sin marcarla. Aparece el mensaje de consentimiento y no se guarda nada. - Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que enviaste.
- Si añadiste un webhook, abre el endpoint en Webhooks. La entrega muestra
succeededcon la respuesta de tu servidor. - Abre beehiiv. La suscripción está ahí, pendiente hasta que confirmes, y el email de confirmación está en tu bandeja de entrada.
Errores comunes
| Problema | Qué hacer |
|---|---|
404 en /api/newsletter en producción | El sitio se generó con nuxt generate, que no tiene servidor. Haz el build con nuxt build y prerenderiza las páginas mediante routeRules. |
| La clave secreta está vacía en tiempo de ejecución | El nombre de la variable no coincide con la clave de configuración. ikiformSecretKey necesita NUXT_IKIFORM_SECRET_KEY. |
| La verificación humana falla uno de cada dos intentos | Se reutilizó el token. Llama a reset() en el widget después de cada error, como hace el componente. |
402 plan_required en las pruebas locales | El envío no tenía un origen localhost. Revisa la línea de Origin y abre el sitio en localhost. |
| El envío está en el panel pero no llegó ningún email ni webhook | Se guardó como spam. El spam no envía nada. Ábrelo en el filtro Spam para ver los motivos y márcalo como no spam para enviar su email y sus webhooks. |
Preguntas
Sí, si conserva un action y un method reales. Apúntalo a una server route que redirija los envíos de formulario normales y devuelva JSON a $fetch, como en esta página.
No. nuxt generate solo produce archivos estáticos, así que faltan las server routes. Usa nuxt build con routeRules para prerenderizar las páginas estáticas, o envía el formulario desde el navegador directamente a Ikiform.
La server route solo ve la respuesta de Ikiform, y el spam recibe el mismo éxito que una suscripción real. beehiiv solo debería enterarse de un lector después del filtro de spam, que es cuando se dispara el webhook.
No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.