Lista de espera para un nuevo SaaS8 min de lectura
Crea una lista de espera en Astro
Una lista de espera en Astro sobre una landing page estática: una Action reenvía cada registro, un script la llama y una página bajo demanda recoge los envíos sin JavaScript.
Mantén la landing page prerenderizada y pon el trabajo de servidor en una Astro Action que valide el email y lo reenvíe a tu backend de formularios con una clave secreta. Un script breve llama a la action para que el visitante se quede en la página, y el action normal del formulario apunta a una página bajo demanda que ejecuta la misma action cuando JavaScript no ha cargado.
La guía principal tiene una pestaña corta de Astro: una action, un formulario y una página que se renderiza bajo demanda. Funciona, pero renuncia a lo que hace que la mayoría elija Astro. Una landing page es justo la página que más quieres servir como HTML estático desde una CDN, y una form action de Astro la obliga a renderizarse en cada petición. Esta página mantiene estática la landing page y lleva al servidor solo el registro.
Qué cambia en Astro
Astro prerenderiza todas las páginas por defecto. Eso tiene dos efectos en una lista de espera que los demás frameworks no comparten:
| Item | Landing page prerenderizada | Página renderizada bajo demanda |
|---|---|---|
| Se sirve desde | Una CDN, como archivo | El servidor o la función de tu adaptador |
<form action={actions.x}> | No permitido. Astro necesita la página bajo demanda | Funciona sin JavaScript |
?ref= en la URL | Invisible para el servidor. Se genera una vez, sin query | Se lee desde Astro.url |
Astro.clientAddress | No disponible | Disponible |
Así que la landing page no puede usar el patrón de form action, y no puede leer ?ref= en el build. La solución es repartir el trabajo: la página estática lleva el formulario y un script, la action hace el trabajo de servidor, y una pequeña página, /join, se renderiza bajo demanda para recoger un envío que llega sin JavaScript.
- La landing page es HTML puro desde la CDN. Cuando carga su script, copia ?ref= o ?utm_source= en el input oculto ref.
- Con JavaScript, el script detiene el envío normal y llama a actions.joinWaitlist con los datos del formulario. La action se ejecuta en el servidor y reenvía el registro a Ikiform.
- El script cambia el formulario por una línea de agradecimiento, o muestra el error bajo el input.
- Sin JavaScript, el navegador envía el formulario a /join, que se renderiza bajo demanda.
- /join llama a la misma action con Astro.callAction. Si va bien, redirige a /thanks con un 303, para que recargar no vuelva a enviar. Si hay un error, muestra el formulario con el mensaje.
Prepara el proyecto
La configuración del producto, como crear el formulario y la clave secreta, está en la guía de Astro de la documentación. Los archivos de esta página:
src/
actions/index.ts
components/WaitlistForm.astro
pages/index.astro
pages/join.astro
pages/thanks.astro
astro.config.mjs
.envUn adaptador y un secreto tipado
Las actions se ejecutan en el servidor en el momento de la petición, así que el proyecto necesita un adaptador aunque la mayoría de las páginas sigan siendo estáticas. astro add instala uno y edita la configuración. Cambia node por vercel, netlify o cloudflare según tu hosting.
npx astro add nodeimport { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
adapter: node({ mode: "standalone" }),
env: {
schema: {
IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
},
},
});envField con access: "secret" hace que IKIFORM_SECRET_KEY solo se pueda importar desde astro:env/server. Si lo importas en un script de cliente, la build falla, que es justo lo que se busca. Crea la clave en API keys con el permiso submissions:write.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY en variables de entorno solo de servidor.La definición de campos
La action envía email y ref. Si añades una definición de campos en la pestaña Fields del formulario, debe incluir ambos, o Ikiform rechazará el envío con 422 y unknown_field:
{
"version": 1,
"title": "Waitlist",
"fields": [
{ "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
{ "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
]
}Constrúyelo
La action
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
const SOURCE = /^[a-z0-9_-]{1,40}$/;
export const server = {
joinWaitlist: defineAction({
accept: "form",
input: z.object({
email: z.email("Enter the email we should send your invite to."),
ref: z
.string()
.nullish()
.transform((value) => (value && SOURCE.test(value) ? value : "direct")),
_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,
ref: input.ref,
_gotcha: input._gotcha ?? "",
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
if (!response) {
throw new ActionError({ code: "GATEWAY_TIMEOUT", message: "We couldn't reach the list. Try again." });
}
if (response.status === 429) {
throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Lots of people are joining. Try again in a minute." });
}
const result = await response.json().catch(() => null);
if (result?.ok) return { joined: true };
throw new ActionError({
code: "BAD_REQUEST",
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
});
},
}),
};accept: "form"analiza elFormData. Los inputs vacíos llegan comonull, así querefy el honeypot sonnullish(), y unrefausente pasa a serdirect.refdebe contener letras minúsculas, dígitos,-o_, hasta 40 caracteres. Cualquier otra cosa se guarda comodirect, así que un enlace fabricado no puede llenar tu exportación de basura. El límite coincide con elmax_lengthde la definición.z.email()es el esquema de email de nivel superior de Zod 4, exportado desdeastro/zod. Una dirección incorrecta falla aquí y nunca llega a Ikiform.- El navegador envía una cabecera
Origintanto en la llamada del script como en el envío normal. Ikiform cuenta los envíos de prueba gratuitos por esa cabecera y elfetchpropio de la action no envía ninguna, así que la action la reenvía. - El honeypot pasa sin tocar, así que Ikiform sigue marcando como spam a los bots que lo rellenan. Los envíos con la clave secreta se saltan la verificación humana de Ikiform, así que activar Spam check no aporta nada aquí; la action es la barrera.
- Cada fallo lanza un
ActionErrorcon un código que corresponde a lo que salió mal. El script y/joinleenerror.message.
El componente del formulario
---
interface Props {
error?: string;
}
const { error = "" } = Astro.props;
---
<form method="POST" action="/join" data-waitlist>
<label>
Email
<input name="email" type="email" autocomplete="email" required placeholder="you@company.com" />
</label>
<input type="hidden" name="ref" value="direct" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p role="alert">{error}</p>
<button>Join the waitlist</button>
</form>
<script>
import { actions, isInputError } from "astro:actions";
const query = new URLSearchParams(location.search);
const source = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase().slice(0, 40);
for (const form of document.querySelectorAll<HTMLFormElement>("form[data-waitlist]")) {
const ref = form.elements.namedItem("ref") as HTMLInputElement;
const alert = form.querySelector<HTMLElement>("[role=alert]")!;
const button = form.querySelector("button")!;
if (source) ref.value = source;
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
button.textContent = "Joining";
alert.textContent = "";
const { error } = await actions.joinWaitlist(new FormData(form));
if (!error) {
const done = document.createElement("p");
done.setAttribute("role", "status");
done.textContent = "You're on the list. We'll email you on launch day.";
form.replaceWith(done);
return;
}
alert.textContent = isInputError(error) ? (error.fields.email?.[0] ?? "Check the address.") : error.message;
button.disabled = false;
button.textContent = "Join the waitlist";
});
}
</script>- El
actiondel formulario es/join, una URL normal. Sin JavaScript, el navegador envía ahí, así que el formulario funciona antes de que cargue el script o si falla. - El script lee
?ref=en el navegador, porque una página prerenderizada se generó sin query string. Un visitante que llega desde/?ref=newsletterqueda etiquetado comonewsletter. - Astro empaqueta el
<script>de un componente una vez por página, así que el bucle conecta todos los formularios de lista de espera que haya. Pon uno en el hero y otro en el footer y los dos funcionan. - La etiqueta envuelve el input en lugar de apuntar a un
id, así que dos copias en una página no chocan.
La landing page y /join
---
import WaitlistForm from "../components/WaitlistForm.astro";
---
<main>
<h1>Acme is almost here</h1>
<p>Leave your email and we'll send you an invite the day it opens.</p>
<WaitlistForm />
</main>Sin export prerender, así que sigue siendo estática. /join se excluye y gestiona el envío normal:
---
import { actions, isInputError } from "astro:actions";
import WaitlistForm from "../components/WaitlistForm.astro";
export const prerender = false;
let error = "";
if (Astro.request.method === "POST") {
const result = await Astro.callAction(actions.joinWaitlist, await Astro.request.formData());
if (!result.error) return Astro.redirect("/thanks", 303);
error = isInputError(result.error) ? (result.error.fields.email?.[0] ?? "Check the address.") : result.error.message;
Astro.response.status = 400;
}
---
<main>
<h1>Join the Acme waitlist</h1>
<WaitlistForm error={error} />
</main>Astro.callAction ejecuta la action con la misma validación, así que hay un único sitio que cambiar. El 303 lleva el navegador a /thanks con un GET, que es una página estática que escribes con la misma línea de agradecimiento.
Notas de despliegue
| Hosting | Adaptador | El secreto |
|---|---|---|
| Vercel | npx astro add vercel | Añade IKIFORM_SECRET_KEY en las Environment Variables del proyecto |
| Netlify | npx astro add netlify | Añádelo en Site configuration › Environment variables |
| Cloudflare | npx astro add cloudflare | Añádelo como secreto en los ajustes del Worker, o con wrangler secret put IKIFORM_SECRET_KEY |
En cualquier hosting, la landing page y /thanks salen como archivos estáticos, y la action junto con /join se convierten en una única función de servidor. Un día tranquilo casi no cuesta nada, y un pico de lanzamiento lo absorbe sobre todo la CDN.
Comprueba que funciona
- Ejecuta
astro devy abrehttp://localhost:4321/?ref=test. Regístrate. El formulario se convierte en la línea de agradecimiento sin recargar. - Abre Submissions en el panel. El registro está ahí con
refentest. - Desactiva JavaScript en el navegador, recarga y vuelve a registrarte. Llegas a
/thanks, y el nuevo registro tienerefendirect. - Envía
not-an-emailcon JavaScript desactivado./joinvuelve a mostrar el formulario con el error del email. - Envía con el honeypot relleno,
_gotcha=bot. Sigue recibiendo una respuesta de éxito, pero el envío se guarda como spam y no manda email ni webhook. - Los envíos desde
astro devllevan un origenlocalhost, así que cuentan como envíos de prueba. Ikiform no tiene plan gratuito. Probar es gratis: un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desdelocalhost. Para pasar a producción, elige Pro desde 6 $/mes por 500 envíos, o paga una sola vez: 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan. Consulta los precios.
Problemas habituales
| Problema | Qué hacer |
|---|---|
403 desde tu propio sitio en el envío normal | security.checkOrigin de Astro, activo por defecto, rechaza los envíos de formularios a páginas bajo demanda desde otro origen. Envía desde el mismo host en el que se ejecuta el sitio, como localhost:4321 en desarrollo, no a través de otro dominio de vista previa. |
| Un error de build sobre form actions en una página prerenderizada | Un formulario usa action={actions.joinWaitlist} en una página estática. Deja la action como /join, como arriba, o añade export const prerender = false a esa página. |
ref siempre es direct | El enlace usa un valor con mayúsculas o espacios, o el script no cargó. El script pasa el valor a minúsculas; la action descarta cualquier cosa que quede fuera de a-z, 0-9, - y _. |
422 con unknown_field | La definición de campos del formulario no incluye ref. Añádelo como en la definición de arriba. |
402 plan_required | El espacio de trabajo no tiene plan y el envío vino de un sitio en producción. Queda retenido, no se pierde. Elige un plan o compra un pack en Settings › Plan and usage y los envíos retenidos llegarán. |
Preguntas
Sí. Deja la landing page prerenderizada, llama a la action desde un script de cliente y apunta el action normal del formulario a una página que se renderice bajo demanda. Solo la action y esa página necesitan un servidor.
Sí. Las actions se ejecutan en el servidor cuando se llaman, así que el proyecto necesita un adaptador como Node, Vercel, Netlify o Cloudflare, aunque todas las páginas estén prerenderizadas.
Puedes, y así conservas el límite de frecuencia por visitante y la verificación humana de Ikiform. La action merece la pena cuando quieres validar y limpiar antes la etiqueta de origen, o mantener el endpoint fuera del HTML de tu página.
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 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.