# 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.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/newsletter-signup/nuxt

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](https://ikiform.com/es/guides/newsletter-signup.md#form) 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](https://ikiform.com/es/docs/guides/nuxt.md).

## 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:

Diagrama: Cómo responde la ruta de newsletter de Nuxt a cada tipo de llamante

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`:

```text
app/
  components/NewsletterForm.vue
  pages/newsletter.vue
server/
  api/newsletter.post.ts
  routes/hooks/ikiform.post.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

```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`:

```bash
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](https://ikiform.com/es/guides/newsletter-signup.md#fields) en la pestaña **Fields** del formulario.

## Constrúyelo

### La server route

`server/api/newsletter.post.ts`:

```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](https://ikiform.com/es/docs/rate-limits.md) 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`:

```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`:

```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](https://ikiform.com/es/guides/newsletter-signup/beehiiv.md) añade el nombre y las propias cabeceras de límite de frecuencia de beehiiv.

`server/routes/hooks/ikiform.post.ts`:

```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

| 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

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

| 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

### ¿Puede funcionar un formulario de Nuxt sin JavaScript?

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.

### ¿Admite nuxt generate server routes para un formulario de newsletter?

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.

### ¿Por qué enviar los suscriptores a beehiiv desde un webhook en lugar de desde la server route?

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.

### ¿Ikiform es gratis?

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.

## Más guías

- [Formulario de suscripción a newsletter](https://ikiform.com/es/guides/newsletter-signup.md): Crea un formulario de suscripción a tu newsletter con tu propio markup, registra el consentimiento, bloquea los registros de bots y pasa cada suscriptor a Kit, MailerLite, beehiiv o cualquier herramienta.
- [beehiiv](https://ikiform.com/es/guides/newsletter-signup/beehiiv.md): Conecta tu propio formulario de suscripción con beehiiv: un relay crea cada suscripción con el doble opt-in activado, la ubicación como fuente UTM y la página como referrer.
- [Doble opt-in](https://ikiform.com/es/guides/newsletter-signup/double-opt-in.md): Ikiform no envía emails de confirmación, así que el doble opt-in se hace en tu herramienta de email: un relay añade cada suscripción como no confirmada y la herramienta envía el enlace.
- [Formulario de newsletter en SvelteKit](https://ikiform.com/es/guides/newsletter-signup/sveltekit.md): Un formulario de newsletter en el layout de SvelteKit 3: una form action con consentimiento y un límite por IP, use:enhance que se queda en la página y una ruta de webhook para Kit.
- [Sitio estático](https://ikiform.com/es/guides/newsletter-signup/static-site.md): Añade un formulario de suscripción a tu newsletter en un sitio estático con Hugo, Jekyll, Eleventy o Astro, deja la API key fuera de la página y confirma en la herramienta.
- [Kit (ConvertKit)](https://ikiform.com/es/guides/newsletter-signup/kit.md): Conecta tu propio formulario de newsletter con Kit mediante un relay que crea el suscriptor, lo añade a un formulario de Kit para el incentive email y etiqueta la ubicación.
