Formulario de suscripción a newsletter

Formulario de suscripción a newsletter9 min de lectura

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.

Preet SutharFundador, Ikiform

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

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:

CaminoCuándo se ejecutaQué ve el lector
El script llama a actions.subscribe()JavaScript cargado, el caso habitualCambia 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 cargarLa 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.

Dos formas en que una suscripción del footer de Astro llega a la misma actioncon JavaScriptsin JavaScriptartículoHTMLprerenderizadoactions.subscribe()desdeelscriptdelclientehandlerdelaactionzodyluegofetchlíneadeestadomismapáginaartículoenvíodeformularionormal/newsletterprerender=falsehandlerdelaactionmismocódigopáginaderesultadogetActionResultFormData/_actions/subscribe{ data, error }POST?_actionresultadoDos formas en que una suscripción del footer de Astro llega a la misma actioncon JavaScriptsin JavaScriptartículoHTML prerenderizadoactions.subscribe()desde el script del clientehandler de la actionzod y luego fetchlínea de estadomisma páginaartículoenvío de formulario normal/newsletterprerender = falsehandler de la actionmismo códigopágina de resultadogetActionResultFormData/_actions/subscribe{ data, error }POST?_actionresultado
  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
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
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
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 en la pestaña Fields del formulario.

Constrúyelo

La action

src/actions/index.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 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
---
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
---
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: primero busca la dirección, así que un lector confirmado que se vuelve a suscribir mantiene su estado.

src/pages/api/ikiform.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, 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

HostQué 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

ProblemaQué hacer
El envío sin JavaScript muestra un 404 o una página estáticaEl 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áginaEl 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 entornoastro:env comprueba el esquema durante el build. Define la variable en el hosting, o en .env en local.
402 plan_required en las pruebas localesEl 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 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.