Lista de espera para un nuevo SaaS

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.

Preet SutharFundador, Ikiform

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:

ItemLanding page prerenderizadaPágina renderizada bajo demanda
Se sirve desdeUna CDN, como archivoEl servidor o la función de tu adaptador
<form action={actions.x}>No permitido. Astro necesita la página bajo demandaFunciona sin JavaScript
?ref= en la URLInvisible para el servidor. Se genera una vez, sin querySe lee desde Astro.url
Astro.clientAddressNo disponibleDisponible

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.

Dos caminos hacia una misma Astro Action desde una landing page estáticaCon JavaScriptSin JavaScriptpáginaestáticael scriptrellena refdesde laURLactions.joinWaitlist()se ejecutaen elservidorIkiformclavesecreta, loguardalínea deagradecimientomismapáginapáginaestáticaelformularioenvía a/join/joinbajodemanda,Astro.callActionIkiformla mismaaction/thanksredirección303FormDataJSONresultadoPOSTJSON303Dos caminos hacia una misma Astro Action desde una landing page estáticaCon JavaScriptSin JavaScriptpágina estáticael script rellena ref desde laURLactions.joinWaitlist()se ejecuta en el servidorIkiformclave secreta, lo guardalínea de agradecimientomisma páginapágina estáticael formulario envía a /join/joinbajo demanda, Astro.callActionIkiformla misma action/thanksredirección 303FormDataJSONresultadoPOSTJSON303
  1. La landing page es HTML puro desde la CDN. Cuando carga su script, copia ?ref= o ?utm_source= en el input oculto ref.
  2. 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.
  3. El script cambia el formulario por una línea de agradecimiento, o muestra el error bajo el input.
  4. Sin JavaScript, el navegador envía el formulario a /join, que se renderiza bajo demanda.
  5. /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.
Una action, dos entradas. Solo /join y la propia action necesitan un servidor.

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:

Proyecto
src/
  actions/index.ts
  components/WaitlistForm.astro
  pages/index.astro
  pages/join.astro
  pages/thanks.astro
astro.config.mjs
.env

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

Terminal
npx astro add node
astro.config.mjs
import { 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.

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
Las versiones de servidor envían con una clave secreta, así que Ikiform se salta Allowed domains y su verificación humana. Todos los envíos llegan entonces desde 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.

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:

Campos
{
  "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

src/actions/index.ts
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 el FormData. Los inputs vacíos llegan como null, así que ref y el honeypot son nullish(), y un ref ausente pasa a ser direct.
  • ref debe contener letras minúsculas, dígitos, - o _, hasta 40 caracteres. Cualquier otra cosa se guarda como direct, así que un enlace fabricado no puede llenar tu exportación de basura. El límite coincide con el max_length de la definición.
  • z.email() es el esquema de email de nivel superior de Zod 4, exportado desde astro/zod. Una dirección incorrecta falla aquí y nunca llega a Ikiform.
  • El navegador envía una cabecera Origin tanto en la llamada del script como en el envío normal. Ikiform cuenta los envíos de prueba gratuitos por esa cabecera y el fetch propio 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 ActionError con un código que corresponde a lo que salió mal. El script y /join leen error.message.

El componente del formulario

src/components/WaitlistForm.astro
---
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 action del 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=newsletter queda etiquetado como newsletter.
  • 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

src/pages/index.astro
---
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:

src/pages/join.astro
---
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

HostingAdaptadorEl secreto
Vercelnpx astro add vercelAñade IKIFORM_SECRET_KEY en las Environment Variables del proyecto
Netlifynpx astro add netlifyAñádelo en Site configuration › Environment variables
Cloudflarenpx astro add cloudflareAñá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

  1. Ejecuta astro dev y abre http://localhost:4321/?ref=test. Regístrate. El formulario se convierte en la línea de agradecimiento sin recargar.
  2. Abre Submissions en el panel. El registro está ahí con ref en test.
  3. Desactiva JavaScript en el navegador, recarga y vuelve a registrarte. Llegas a /thanks, y el nuevo registro tiene ref en direct.
  4. Envía not-an-email con JavaScript desactivado. /join vuelve a mostrar el formulario con el error del email.
  5. 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.
  6. Los envíos desde astro dev llevan un origen localhost, 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 desde localhost. 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

ProblemaQué hacer
403 desde tu propio sitio en el envío normalsecurity.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 prerenderizadaUn 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 directEl 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_fieldLa definición de campos del formulario no incluye ref. Añádelo como en la definición de arriba.
402 plan_requiredEl 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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.