Formulario de suscripción a newsletter

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.

Preet SutharFundador, Ikiform

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:

Cómo responde la ruta de newsletter de Nuxt a cada tipo de llamanteNewsletterFormaction="/api/newsletter"newsletter.post.tsconsentimiento,Turnstile,Ikiform¿Accept:JSON?quiénpreguntóJSONoerror$fetch,mismapágina303/newsletter?result=...newsletter.vuemuestraelmensaje$fetch o POST de formularioresultadosínoCómo responde la ruta de newsletter de Nuxt a cada tipo de llamanteNewsletterFormaction="/api/newsletter"newsletter.post.tsconsentimiento,Turnstile, Ikiform¿Accept:JSON?quién preguntóJSON o error$fetch, misma página303/newsletter?result=...newsletter.vuemuestra elmensaje$fetch o POST de formularioresultadosíno
  1. El formulario envía a /api/newsletter, mediante $fetch una vez hidratado o como envío de formulario normal antes.
  2. La ruta comprueba el consentimiento y el token de Turnstile, y luego reenvía la suscripción a Ikiform.
  3. Lee la cabecera Accept para decidir cómo responder.
  4. $fetch pidió JSON: si todo va bien devuelve JSON, y si hay un problema lanza un error con un motivo que muestra el componente.
  5. 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.
La redirección lleva un código, nunca un mensaje, así que un enlace manipulado no puede poner su propio texto en tu página.

Configuración del proyecto

Instala el módulo de Turnstile con npx nuxi@latest module add turnstile y luego:

Árbol del proyecto
app/
  components/NewsletterForm.vue
  pages/newsletter.vue
server/
  api/newsletter.post.ts
  routes/hooks/ikiform.post.ts
nuxt.config.ts
.env
nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@nuxtjs/turnstile"],
  turnstile: { siteKey: "your_turnstile_site_key" },
  runtimeConfig: {
    ikiformSecretKey: "",
    ikiformWebhookSecret: "",
    beehiivApiKey: "",
    beehiivPublicationId: "",
    turnstile: { secretKey: "" },
  },
  routeRules: {
    "/blog/**": { prerender: true },
  },
});
.env
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_key

Cada 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

server/api/newsletter.post.ts
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.");
});
  • readBody analiza tanto el JSON de $fetch como el cuerpo URL-encoded de un envío de formulario normal. Por eso el consentimiento se acepta como true o "true".
  • verifyTurnstileToken viene del módulo de Turnstile y lee runtimeConfig.turnstile.secretKey. Los tokens son de un solo uso y caducan a los cinco minutos.
  • La línea de Origin reenvía el origen del navegador, porque Ikiform reconoce por esa cabecera las pruebas gratuitas desde localhost y el fetch de un servidor no envía ninguna.
  • useRuntimeConfig(event) obtiene los valores con las sobrescrituras NUXT_ de tiempo de ejecución aplicadas, como recomienda Nuxt en las server routes.
Las versiones de servidor envían con una clave secreta, así que Ikiform se salta Allowed domains (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda IKIFORM_SECRET_KEY en variables de entorno solo de servidor.

El componente del formulario

app/components/NewsletterForm.vue
<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 action y method, así que envía de la forma normal hasta que @submit.prevent toma el control tras la hidratación.
  • <NuxtTurnstile> renderiza el widget y pone su token en token. Tras cualquier error el token queda gastado, así que reset() pide uno nuevo.
  • Los campos se leen del FormData antes del primer await, mientras event.currentTarget aún apunta al formulario.

La página /newsletter

app/pages/newsletter.vue
<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.

server/routes/hooks/ikiform.post.ts
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

HostQué hacer
VercelNitro detecta Vercel y genera las server routes como funciones. Añade las variables NUXT_ en Environment Variables.
NetlifyNitro detecta Netlify de la misma forma. Añade las variables en los ajustes de entorno del sitio.
CloudflareHaz 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

  1. Ejecuta nuxt dev y 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.
  2. Desactiva JavaScript y suscríbete. Llegas a /newsletter?result=check, porque el widget no pudo ejecutarse.
  3. Con JavaScript activado, quita required de la casilla y envía sin marcarla. Aparece el mensaje de consentimiento y no se guarda nada.
  4. Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que enviaste.
  5. Si añadiste un webhook, abre el endpoint en Webhooks. La entrega muestra succeeded con la respuesta de tu servidor.
  6. 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

ProblemaQué hacer
404 en /api/newsletter en producciónEl 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ónEl 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 intentosSe reutilizó el token. Llama a reset() en el widget después de cada error, como hace el componente.
402 plan_required en las pruebas localesEl 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 webhookSe 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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.