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

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

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](https://ikiform.com/es/guides/waitlist.md#form) 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:

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

Diagrama: Dos caminos hacia una misma Astro Action desde una landing page estática

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](https://ikiform.com/es/docs/guides/astro.md). Los archivos de esta página:

`Proyecto`:

```text
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`:

```bash
npx astro add node
```

`astro.config.mjs`:

```js
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`:

```bash
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](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.

### 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`:

```json
{
  "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`:

```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`:

```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`:

```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`:

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

| 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

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](https://ikiform.com/es/pricing).

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

### ¿Una página de lista de espera en Astro puede seguir siendo estática?

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.

### ¿Las Astro Actions necesitan un adaptador?

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.

### ¿Por qué no enviar el formulario de Astro directamente a Ikiform?

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.

### ¿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 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan.

## Más guías

- [Lista de espera para un nuevo SaaS](https://ikiform.com/es/guides/waitlist.md): Crea una lista de espera previa al lanzamiento en tu propia landing page: el formulario, el antispam, los duplicados, los emails de confirmación y una exportación limpia el día del lanzamiento.
- [Lista de espera en Next.js](https://ikiform.com/es/guides/waitlist/nextjs.md): Una lista de espera con el App Router de Next.js: una server action que reenvía registros con una clave secreta, useActionState, un agradecimiento optimista y un límite por visitante.
- [Lista de espera en HTML puro](https://ikiform.com/es/guides/waitlist/html.md): Una página de lista de espera completa en HTML puro y sin build que funciona con JavaScript desactivado, muestra un agradecimiento en línea cuando carga y recurre a un plan B cuando hace falta.
- [Lista para lanzar en Product Hunt](https://ikiform.com/es/guides/waitlist/product-hunt.md): Reúne una lista semanas antes de Product Hunt, etiqueta cada registro por canal y envía el email del lanzamiento en dos tandas según el reinicio en hora del Pacífico.
- [Registro a una beta con filtro](https://ikiform.com/es/guides/waitlist/beta-signup.md): Un formulario de solicitud para una beta con preguntas de filtro que aplica el servidor, y un script que puntúa a los candidatos y elige cada grupo de la lista.
- [Lista de espera para app móvil](https://ikiform.com/es/guides/waitlist/mobile-app.md): Una lista de espera previa al lanzamiento para una app de iOS o Android que pregunta qué teléfono se usa, lleva a los testers de iPhone a TestFlight y llena una prueba cerrada de Play.
