# Cómo crear una lista de espera en Next.js con una server action

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

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

Escribe una server action que compruebe el email, aplique un límite por visitante y reenvíe el registro a tu backend de formularios con una clave secreta. Llámala con `useActionState` para que los errores vuelvan como estado, y envuélvela con `useOptimistic` para que la línea de agradecimiento aparezca en cuanto se pulsa el botón y se revierta si el envío falla.

La [guía principal](https://ikiform.com/es/guides/waitlist.md#form) tiene una pestaña corta de Next.js: una server action y `useActionState`. Basta para una landing page tranquila. Esta página va más allá, para una lista de espera que estás a punto de compartir a lo grande: un agradecimiento instantáneo, mensajes de error honestos y un límite por visitante que un envío desde el servidor pierde de otro modo.

## Qué cambia con una server action

Una server action mantiene la clave secreta en tu servidor y te deja comprobar el email antes de que nada salga de tu app. También hace que cada envío salga desde la dirección IP de tu servidor, y eso tiene tres efectos:

|   | Envíos desde el navegador a Ikiform | Server action con una clave secreta |
| --- | --- | --- |
| A quién ve Ikiform | La IP de cada visitante | La IP de tu servidor, para todos los visitantes |
| Límite de frecuencia por visitante | El de Ikiform, por IP | Lo añades tú, abajo |
| Dominios permitidos y la verificación humana | Se aplican | Se omiten, porque la clave demuestra que el envío es tuyo |
| Quién puede llamarla | Cualquiera, desde tus dominios permitidos | Cualquiera que pueda hacer un POST a tu página |

Esa última fila importa. Next.js trata una server action como un endpoint público: cualquiera puede llamarla con un POST fabricado, no solo tu formulario. Como la clave secreta se salta las comprobaciones de origen de Ikiform, tu action es la única barrera, así que necesita su propio límite.

Diagrama: Cómo avanza un registro por la server action de Next.js

1. El visitante pulsa el botón. useOptimistic cambia al instante el formulario por la línea de agradecimiento.
2. La server action comprueba el email y cuenta los intentos recientes del visitante por IP.
3. Dentro del límite, envía el registro a Ikiform con la clave secreta.
4. El resultado pasa a ser el estado de la action. Si va bien, el agradecimiento se queda; si hay un error, el formulario vuelve con el mensaje y el email escrito.
5. Por encima del límite, no se envía nada y el estado de error vuelve enseguida.

La línea optimista solo dura mientras se ejecuta la action. El estado de la action decide qué se queda en pantalla.

## Constrúyelo

### La clave secreta

En el panel, abre **API keys**, crea una clave secreta con el permiso `submissions:write` y guárdala como variable solo de servidor. Nunca le pongas el prefijo `NEXT_PUBLIC_`.

`.env.local`:

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

### Un límite por visitante

Cinco registros por minuto por IP es generoso para una persona e inútil para un script. Este contador vive en la memoria del servidor. `server-only` hace que la build falle si un componente de cliente lo importa.

`lib/rate-limit.ts`:

```ts
import "server-only";

const WINDOW_MS = 60_000;
const LIMIT = 5;
const hits = new Map<string, number[]>();

export function allow(key: string, now = Date.now()) {
  const recent = (hits.get(key) ?? []).filter((time) => now - time < WINDOW_MS);
  const allowed = recent.length < LIMIT;
  if (allowed) recent.push(now);
  if (hits.size > 10_000) hits.clear();
  hits.set(key, recent);
  return allowed;
}
```

- La memoria no se comparte entre instancias del servidor. En un único servidor Node o contenedor es exacto. En hosting serverless, cada instancia lleva su propia cuenta, así que tómalo como un reductor de velocidad y mueve el contador a un almacén compartido como Redis cuando crezca el tráfico.
- Confía solo en la cabecera de IP que pone tu host. Vercel pone `x-forwarded-for`; detrás de Cloudflare, lee `cf-connecting-ip`. Una cabecera que el propio visitante puede poner no es ningún límite.

### La server action

`app/waitlist/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { allow } from "@/lib/rate-limit";

export interface JoinState {
  status: "idle" | "joined" | "error";
  message: string;
  email?: string;
}

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
  const email = String(formData.get("email") ?? "").trim();
  if (!EMAIL.test(email)) return { status: "error", message: "Enter the email we should send your invite to.", email };

  const incoming = await headers();
  const visitor = incoming.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
  if (!allow(visitor)) return { status: "error", message: "Too many tries from this connection. Wait a minute.", email };

  let response: Response;
  try {
    response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        Origin: incoming.get("origin") ?? "",
      },
      body: JSON.stringify({
        email,
        ref: String(formData.get("ref") ?? "homepage").slice(0, 40),
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { status: "error", message: "We couldn't reach the list. Try again.", email };
  }

  if (response.status === 429) {
    const wait = response.headers.get("Retry-After") ?? "60";
    return { status: "error", message: `The list is busy. Try again in ${wait} seconds.`, email };
  }
  const result = await response.json().catch(() => null);
  if (result?.ok) return { status: "joined", message: "You're on the list. We'll email you on launch day." };
  return {
    status: "error",
    message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
    email,
  };
}
```

- La comprobación barata va primero. Un email incorrecto nunca cuesta una petición.
- `AbortSignal.timeout` evita que una red lenta deje el botón en estado pendiente para siempre.
- El `429` de Ikiform se lee por separado, con su `Retry-After`, porque con todos los visitantes detrás de una IP es todo el formulario el que está ocupado, no esta persona.
- Cada error devuelve el email, para que el input pueda volver a mostrarlo. React reinicia el formulario tras una action, y `defaultValue` vuelve a poner la dirección.
- La cabecera `Origin` del navegador se reenvía porque Ikiform cuenta con ella los envíos de prueba gratuitos, y el `fetch` propio de un servidor no envía ninguna.
- El `submit()` del SDK no envía la cabecera `Authorization`, así que no puede saltarse los dominios permitidos. Usa aquí `fetch` con la clave.

### El formulario, con un agradecimiento optimista

`app/waitlist/waitlist-form.tsx`:

```tsx
"use client";

import { useActionState, useOptimistic } from "react";
import { type JoinState, joinWaitlist } from "./actions";

const initial: JoinState = { status: "idle", message: "" };

export function WaitlistForm({ source }: { source: string }) {
  const [state, dispatch, pending] = useActionState(joinWaitlist, initial);
  const [shown, showJoined] = useOptimistic(
    state,
    (_current, email: string): JoinState => ({ status: "joined", message: `Adding ${email} to the list` }),
  );

  function join(formData: FormData) {
    showJoined(String(formData.get("email") ?? ""));
    dispatch(formData);
  }

  if (shown.status === "joined") {
    return (
      <p role="status" aria-busy={pending}>
        {shown.message}
      </p>
    );
  }

  return (
    <form action={join}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.email} />
      <input type="hidden" name="ref" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {shown.status === "error" ? <p role="alert">{shown.message}</p> : null}
      <button disabled={pending}>Join the waitlist</button>
    </form>
  );
}
```

- `useOptimistic` debe establecerse dentro de una action. `join` es la action del formulario, así que React la ejecuta en una transición y la línea optimista se mantiene hasta que termina `dispatch`.
- Si va bien, el estado de la action pasa a `joined` y la línea se queda, ya con el mensaje final. Si hay un error, el valor optimista desaparece y el formulario vuelve con `state.email` y el mensaje.
- `aria-busy` indica a los lectores de pantalla que la línea aún se está confirmando.

> Envolver la action en `join` la convierte en una función de cliente, así que este formulario necesita JavaScript. Si enviar antes de la hidratación te importa más que el agradecimiento instantáneo, pasa `dispatch` directamente a `action` y quita `useOptimistic`; el resto del código no cambia.

### La página

`app/waitlist/page.tsx`:

```tsx
import { WaitlistForm } from "./waitlist-form";

export default async function Page({ searchParams }: PageProps<"/waitlist">) {
  const { ref } = await searchParams;
  const source = typeof ref === "string" ? ref.slice(0, 40) : "homepage";

  return (
    <main>
      <h1>Acme is almost here</h1>
      <p>Leave your email and we'll send you an invite the day it opens.</p>
      <WaitlistForm source={source} />
    </main>
  );
}
```

La página lee `?ref=` de la URL y se lo pasa al formulario, así que un enlace como `/waitlist?ref=newsletter` etiqueta cada registro con su origen. `PageProps` es el tipo que Next.js genera según la ruta; leer `searchParams` renderiza la página en cada petición.

## Comprueba que funciona

1. Ejecuta `next dev`, abre `/waitlist?ref=test` y regístrate. La línea de agradecimiento aparece antes de que termine la petición.
2. Abre **Submissions** en el panel. El registro está ahí con `ref` en `test`.
3. Envía seis veces en un minuto. El sexto muestra el mensaje de límite y solo se guardan cinco envíos.
4. Pon un valor incorrecto en `IKIFORM_SECRET_KEY` y envía. La línea optimista se revierte y el formulario muestra el error con tu email todavía escrito.
5. Revisa la bandeja de entrada de cada destinatario de las notificaciones. Cada uno recibe un email con los campos, y **Reply** responde a quien envió el formulario cuando este tiene un campo `email`.
6. 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.

## Problemas habituales

| Problema | Qué hacer |
| --- | --- |
| `403 forbidden` de Ikiform | La clave es de otro espacio de trabajo, está revocada o le falta `submissions:write`. Crea una clave secreta con ese permiso. |
| `429 rate_limited` | Un visitante envió más de lo que permite el límite de frecuencia del formulario. Standard permite 5 por minuto por visitante. Espera lo que indique `Retry-After`, o elige Relaxed o Custom para formularios que envían desde una IP compartida. |
| Todos los visitantes llegan al límite a la vez | Falta la cabecera de IP, así que todo el mundo cuenta como `unknown`. Lee la cabecera que pone tu host, como `cf-connecting-ip` detrás de Cloudflare. |
| El agradecimiento parpadea y el formulario vuelve sin mensaje | La action lanzó una excepción en lugar de devolver un estado. Haz que cada camino de error devuelva un `JoinState`, como en el ejemplo. |
| Un aviso de que una actualización optimista ocurrió fuera de una transición | Se llamó a `showJoined` desde un event handler. Llámala dentro de la action del formulario, como hace `join`. |

## Preguntas

### ¿Una lista de espera en Next.js debe enviar desde el navegador o desde una server action?

Ambas opciones funcionan. Un envío desde el navegador conserva los límites por visitante, los dominios permitidos y la verificación humana de Ikiform. Una server action oculta el endpoint, comprueba antes los datos y funciona antes de la hidratación, pero tienes que añadir tu propio límite por visitante porque todos los envíos salen de tu servidor.

### ¿useOptimistic funciona con useActionState?

Sí. Establece el valor optimista y llama a la función dispatch de useActionState dentro de la misma action, como la prop action del formulario. El valor optimista se muestra mientras se ejecuta la action y deja paso al estado de la action cuando termina.

### ¿Necesito una base de datos para una lista de espera en Next.js?

No. La server action reenvía cada registro a Ikiform, que lo guarda, filtra el spam, te avisa por email y envía webhooks. Tu app no guarda ningún estado aparte del contador del límite de frecuencia.

### ¿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 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.
- [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 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.
- [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.
- [Lista de espera en Framer](https://ikiform.com/es/guides/waitlist/framer.md): Dos formas de recoger registros de lista de espera en Framer: el formulario de Framer con un webhook a Ikiform, o un code component que conserva los límites por visitante y el antispam.
