# Crea un formulario de suscripción a newsletter en Astro

> Un formulario de newsletter en Astro que se queda en páginas prerenderizadas: una Astro Action comprueba el consentimiento y envía a Ikiform, y un endpoint añade a los lectores a MailerLite.

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

Mantén tus páginas de Astro prerenderizadas y pon el formulario de suscripción en el componente del footer. Sin JavaScript, envía a una única página `/newsletter` bajo demanda que ejecuta una Astro Action; con JavaScript, un pequeño script llama a la misma action y el lector nunca sale del artículo. La action valida el email y el consentimiento con `astro/zod`, envía a tu backend de formularios con una clave secreta, y un endpoint de API reenvía las suscripciones verificadas a MailerLite.

La [pestaña de Astro de la guía principal](https://ikiform.com/es/guides/newsletter-signup.md#form) usa una Action en una página renderizada bajo demanda. La mayoría de sitios Astro son blogs y documentación donde todas las páginas están prerenderizadas, y un formulario en el footer tiene que estar en todas. Esta página mantiene esas páginas estáticas y aun así ejecuta la suscripción en el servidor. Las claves y los dominios permitidos están en la [guía de Astro de la documentación](https://ikiform.com/es/docs/guides/astro.md).

## Qué cambian las páginas estáticas

La documentación de Astro dice que una página debe renderizarse bajo demanda para llamar a una action mediante el atributo `action` de un formulario. Un artículo prerenderizado no puede gestionar ese POST, porque no hay ningún servidor detrás. Hay dos formas de sortearlo, y esta versión usa las dos:

| Camino | Cuándo se ejecuta | Qué ve el lector |
| --- | --- | --- |
| El script llama a `actions.subscribe()` | JavaScript cargado, el caso habitual | Cambia la línea de estado bajo el formulario. Se queda en el artículo |
| El formulario envía a `/newsletter?_action=...` | JavaScript desactivado o aún sin cargar | La página `/newsletter` bajo demanda, con el resultado o el error |

Solo `/newsletter` y el endpoint del webhook se ejecutan bajo demanda. Cada artículo sigue siendo un archivo estático, y el adaptador añade una función de servidor para esas dos rutas.

Diagrama: Dos formas en que una suscripción del footer de Astro llega a la misma action

1. Con JavaScript, el script del footer detiene el envío normal y llama a la action con los datos del formulario.
2. La action valida la entrada, envía a Ikiform y devuelve datos o un error al script, que actualiza la línea de estado.
3. Sin JavaScript, el navegador envía el formulario a la página /newsletter bajo demanda con la action en la query string.
4. Astro ejecuta el mismo handler de la action, y la página lee el resultado con getActionResult y lo muestra.

Un handler, dos puntos de entrada. El artículo estático nunca necesita un servidor propio.

## Configuración del proyecto

`Árbol del proyecto`:

```text
src/
  actions/index.ts
  components/SignupForm.astro
  layouts/Base.astro
  pages/
    newsletter.astro
    api/ikiform.ts
astro.config.mjs
.env
```

Instala un adaptador con `astro add vercel`, `astro add netlify` o `astro add cloudflare`. Declara los secretos con `astro:env` en lugar de leer `import.meta.env`: el esquema hace fallar el build cuando falta una variable, y el mismo import funciona en todos los adaptadores, incluido el runtime de Cloudflare.

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import vercel from "@astrojs/vercel";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      IKIFORM_WEBHOOK_SECRET: envField.string({ context: "server", access: "secret" }),
      MAILERLITE_API_KEY: envField.string({ context: "server", access: "secret" }),
      MAILERLITE_GROUP_ID: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789
```

Crea la clave secreta 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 action

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

export const server = {
  subscribe: defineAction({
    accept: "form",
    input: z.object({
      email: z.email({ error: "Enter an email address we can send the newsletter to." }),
      first_name: z.string().trim().max(80).nullish(),
      consent: z.boolean().refine((ticked) => ticked, { error: "Tick the box so we know you want the emails." }),
      source: z.enum(["footer", "post", "newsletter-page"]).catch("footer"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          email: input.email,
          first_name: input.first_name || undefined,
          consent: true,
          source: input.source,
          _gotcha: input._gotcha ?? "",
        }),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { subscribed: true };
      throw new ActionError({
        code: response?.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "We couldn't reach the list. Try again.",
      });
    },
  }),
};
```

- `accept: "form"` hace que Astro analice el `FormData`. La casilla pasa por una conversión a booleano, así que marcada es `true` y sin marcar es `false`, que `refine` convierte en el mensaje de consentimiento.
- Los inputs de texto vacíos llegan como `null`, así que los campos opcionales usan `nullish()`, no `optional()`.
- `source` recurre a `footer` para cualquier valor que no esté en la lista, así que un envío manipulado no puede inventarse ubicaciones.
- 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.
- Los errores de Ikiform se convierten en un `ActionError`. Un `429` mantiene su significado como `TOO_MANY_REQUESTS`.

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

`src/components/SignupForm.astro`:

```astro
---
import { actions } from "astro:actions";

interface Props {
  source?: "footer" | "post" | "newsletter-page";
}

const { source = "footer" } = Astro.props;
const target = new URL("/newsletter", Astro.url);
target.search = actions.subscribe.queryString;
---

<form class="signup" method="POST" action={target.pathname + target.search}>
  <label>Email <input name="email" type="email" required autocomplete="email" /></label>
  <label>First name (optional) <input name="first_name" maxlength="80" autocomplete="given-name" /></label>
  <label>
    <input name="consent" type="checkbox" required />
    Email me the newsletter. I can unsubscribe at any time.
  </label>
  <input type="hidden" name="source" value={source} />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <p class="signup-status" role="status"></p>
  <button>Subscribe</button>
</form>

<script>
  import { actions, isInputError } from "astro:actions";

  for (const form of document.querySelectorAll<HTMLFormElement>("form.signup")) {
    const status = form.querySelector(".signup-status") as HTMLElement;
    const button = form.querySelector("button") as HTMLButtonElement;

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      status.textContent = "Subscribing";
      const { error } = await actions.subscribe(new FormData(form));
      button.disabled = false;
      if (!error) {
        form.replaceChildren(status);
        status.textContent = "Almost done. Open the email we sent you and confirm your address.";
        return;
      }
      const fields = isInputError(error) ? error.fields : {};
      status.textContent = fields.email?.[0] ?? fields.consent?.[0] ?? error.message;
    });
  }
</script>
```

- `queryString` es la forma documentada de construir la URL de una action. Ponerlo en una URL `/newsletter` manda los envíos sin JavaScript a la página bajo demanda en lugar de a la página estática de la que vienen.
- Astro empaqueta el `<script>` de un componente una vez, aparezca el componente las veces que aparezca. El bucle conecta cada copia, como un formulario en el footer y otro al final de un post.
- `isInputError` separa los mensajes por campo de zod de los rechazos de Ikiform, que llegan como un mensaje normal.

### La página bajo demanda

`src/pages/newsletter.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";
import SignupForm from "../components/SignupForm.astro";
import Base from "../layouts/Base.astro";

export const prerender = false;

const result = Astro.getActionResult(actions.subscribe);
const fields = isInputError(result?.error) ? result.error.fields : {};
const problem = fields.email?.[0] ?? fields.consent?.[0] ?? result?.error?.message;
---

<Base title="Newsletter">
  <h1>The monthly newsletter</h1>
  {result?.data?.subscribed ? (
    <p role="status">Almost done. Open the email we sent you and confirm your address.</p>
  ) : (
    <>
      {problem && <p role="alert">{problem}</p>}
      <SignupForm source="newsletter-page" />
    </>
  )}
</Base>
```

Astro muestra el resultado de una action solo para ese POST. Volver a visitar la página lo borra, lo cual está bien para una suscripción. Si prefieres redirigir a una URL de agradecimiento, la documentación de Astro muestra el patrón con sesión usando `getActionContext` en el middleware.

## Reenvía los suscriptores a MailerLite

Este endpoint recibe el webhook firmado de Ikiform y añade cada suscripción a un grupo de MailerLite como `unconfirmed`, para que MailerLite envíe su propia confirmación cuando **Double opt-in for API and integrations** está activado. Primero lee el cuerpo como texto porque la firma cubre los bytes exactos. Envía cada suscripción como `unconfirmed`, lo que encaja con una lista nueva. Si tu lista ya tiene lectores confirmados, usa en su lugar el relay de la [guía de MailerLite](https://ikiform.com/es/guides/newsletter-signup/mailerlite.md): primero busca la dirección, así que un lector confirmado que se vuelve a suscribir mantiene su estado.

`src/pages/api/ikiform.ts`:

```ts
import type { APIRoute } from "astro";
import { IKIFORM_WEBHOOK_SECRET, MAILERLITE_API_KEY, MAILERLITE_GROUP_ID } from "astro:env/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  let event;
  try {
    event = await verifyWebhook({ payload: await request.text(), headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const { email, first_name, consent } = event.data.fields;
  if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

  const answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${MAILERLITE_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      email,
      fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
      groups: [MAILERLITE_GROUP_ID],
      status: "unconfirmed",
    }),
  });

  if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!answer.ok) return new Response(`MailerLite answered ${answer.status}`, { status: 502 });
  return new Response(null, { status: 204 });
};
```

`security.checkOrigin` de Astro, activado por defecto, rechaza los envíos de formulario entre sitios a las rutas bajo demanda. El webhook de Ikiform es `application/json`, un tipo al que no se aplica la comprobación, así que el endpoint lo recibe tal cual. Añade la URL en **Webhooks** y copia el secreto `whsec_` en `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 nunca llega a MailerLite. La clave secreta se salta la comprobación de Turnstile de Ikiform. Si los bots pasan, tienes dos opciones: verificar un token de Turnstile en el handler, como muestra la [guía de Turnstile](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md), o quitar la action y enviar desde el navegador con `@ikiform/sdk`, que mantiene la comprobación de Ikiform, los dominios permitidos y los límites por visitante, y no necesita ningún adaptador.

## Despliegue

| Host | Qué hacer |
| --- | --- |
| Vercel | `@astrojs/vercel` convierte las dos rutas bajo demanda en funciones. Añade las cuatro variables en **Environment Variables**. |
| Netlify | `@astrojs/netlify` hace lo mismo con Netlify Functions. Añade las variables en los ajustes de entorno del sitio. |
| Cloudflare | `@astrojs/cloudflare` apunta a Workers; el adaptador ya no admite Pages. Define los secretos con `wrangler secret put` y usa `.dev.vars` en local. Dale al Worker un dominio propio, porque Ikiform rechaza las URLs de webhook `workers.dev`. |

## Pruébalo gratis en localhost

Un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde `localhost`. Ejecuta `astro dev` y el `Origin` reenviado es `http://localhost:4321`, así que cada suscripción cuenta como prueba. Prueba los dos caminos: una vez normalmente y otra con JavaScript desactivado para ver la página `/newsletter`. Para probar el endpoint, exponlo con un túnel con URL HTTPS; Ikiform no entrega a `localhost`.

## Comprueba que funciona

1. Ejecuta `astro build` y comprueba que tus artículos siguen apareciendo como prerenderizados en la salida, con solo `/newsletter` y `/api/ikiform` bajo demanda.
2. Suscríbete desde el footer de un artículo. Cambia la línea de estado y la URL no.
3. Desactiva JavaScript y vuelve a suscribirte. Llegas a `/newsletter` con la línea que pide revisar la bandeja de entrada.
4. Envía con la casilla sin marcar desde las herramientas de desarrollo. Aparece el mensaje de consentimiento y no se guarda nada.
5. Abre **Submissions** en el panel. El nuevo envío está ahí con todos los campos que enviaste.
6. Si añadiste un webhook, abre el endpoint en **Webhooks**. La entrega muestra `succeeded` con la respuesta de tu servidor.
7. Abre MailerLite. El suscriptor está en el grupo como no confirmado, y el email de confirmación está en tu bandeja de entrada.

## Errores comunes

| Problema | Qué hacer |
| --- | --- |
| El envío sin JavaScript muestra un 404 o una página estática | El formulario envió a la página prerenderizada de la que venía. Construye el `action` a partir de `/newsletter` más `queryString`, y mantén `prerender = false` en esa página. |
| Solo funciona el primer formulario de una página | El script usó `querySelector` para un único formulario. Astro lo ejecuta una vez por página, así que recorre cada `form.signup`. |
| El build falla porque falta una variable de entorno | `astro:env` comprueba el esquema durante el build. Define la variable en el hosting, o en `.env` en local. |
| `402 plan_required` en las pruebas locales | El envío no tenía un origen `localhost`. Revisa la línea de `Origin` en el handler 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 ejecutarse una Astro Action en una página prerenderizada?

Llamada desde un script del cliente, sí, siempre que el proyecto tenga un adaptador: la action se ejecuta en su propia ruta bajo demanda. Un envío de formulario HTML normal necesita una página bajo demanda a la que llegar, por eso esta versión lo manda a /newsletter.

### ¿Necesito un adaptador para un formulario de newsletter en Astro?

Solo para la versión de servidor de esta página. Un sitio Astro totalmente estático puede enviar el formulario directamente desde el navegador a Ikiform, lo que mantiene la verificación humana de Ikiform y no necesita servidor.

### ¿Por qué reenviar las suscripciones a MailerLite mediante un webhook en lugar de en la action?

La action no puede distinguir un bot de un lector: Ikiform da a ambos la misma respuesta de éxito. Su webhook solo se dispara para las suscripciones que pasan el filtro de spam, y una caída de MailerLite retrasa entonces al suscriptor mediante los reintentos del webhook en lugar de hacer fallar el envío del visitante.

### ¿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.
- [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.
- [MailerLite](https://ikiform.com/es/guides/newsletter-signup/mailerlite.md): Conecta tu propio formulario de suscripción con MailerLite: un relay añade a los lectores nuevos como no confirmados para el doble opt-in y nunca pide a un suscriptor que confirme dos veces.
- [Formulario de newsletter en Next.js](https://ikiform.com/es/guides/newsletter-signup/nextjs.md): Un formulario de newsletter en el footer de Next.js: una server action con comprobación de consentimiento y Turnstile que envía a Ikiform, más un route handler que añade a los lectores a Kit.
- [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.
- [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.
