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.
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:
| 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.
- Con JavaScript, el script del footer detiene el envío normal y llama a la action con los datos del formulario.
- La action valida la entrada, envía a Ikiform y devuelve datos o un error al script, que actualiza la línea de estado.
- Sin JavaScript, el navegador envía el formulario a la página /newsletter bajo demanda con la action en la query string.
- Astro ejecuta el mismo handler de la action, y la página lee el resultado con getActionResult y lo muestra.
Configuración del proyecto
src/
actions/index.ts
components/SignupForm.astro
layouts/Base.astro
pages/
newsletter.astro
api/ikiform.ts
astro.config.mjs
.envInstala 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.
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" }),
},
},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789Crea 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
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 elFormData. La casilla pasa por una conversión a booleano, así que marcada estruey sin marcar esfalse, querefineconvierte en el mensaje de consentimiento.- Los inputs de texto vacíos llegan como
null, así que los campos opcionales usannullish(), nooptional(). sourcerecurre afooterpara cualquier valor que no esté en la lista, así que un envío manipulado no puede inventarse ubicaciones.- 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. - Los errores de Ikiform se convierten en un
ActionError. Un429mantiene su significado comoTOO_MANY_REQUESTS.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.El componente del footer
---
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>queryStringes la forma documentada de construir la URL de una action. Ponerlo en una URL/newslettermanda 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. isInputErrorsepara los mensajes por campo de zod de los rechazos de Ikiform, que llegan como un mensaje normal.
La página bajo demanda
---
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.
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
| 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
- Ejecuta
astro buildy comprueba que tus artículos siguen apareciendo como prerenderizados en la salida, con solo/newslettery/api/ikiformbajo demanda. - Suscríbete desde el footer de un artículo. Cambia la línea de estado y la URL no.
- Desactiva JavaScript y vuelve a suscribirte. Llegas a
/newslettercon la línea que pide revisar la bandeja de entrada. - Envía con la casilla sin marcar desde las herramientas de desarrollo. 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 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
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.
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.
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.
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.