Lista de espera para un nuevo SaaS

Lista de espera para un nuevo SaaS7 min de lectura

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.

Preet SutharFundador, Ikiform

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

ItemEnvíos desde el navegador a IkiformServer action con una clave secreta
A quién ve IkiformLa IP de cada visitanteLa IP de tu servidor, para todos los visitantes
Límite de frecuencia por visitanteEl de Ikiform, por IPLo añades tú, abajo
Dominios permitidos y la verificación humanaSe aplicanSe omiten, porque la clave demuestra que el envío es tuyo
Quién puede llamarlaCualquiera, desde tus dominios permitidosCualquiera 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.

Cómo avanza un registro por la server action de Next.jsformulariouseOptimisticmuestra elagradecimientoserveractioncomprueba elemail, limitapor IPIkiformclavesecreta,guarda elregistroestado dela actionregistrado, oun errorestado deerrorno se envíanada, vuelveelformularioFormDataJSONresultadopor encima del límiteCómo avanza un registro por la server action de Next.jsformulariouseOptimistic muestrael agradecimientoserver actioncomprueba el email,limita por IPIkiformclave secreta, guardael registroestado de laactionregistrado, o un errorestado deerrorno se envíanada, vuelveelformularioFormDataJSONresultadopor encima del límite
  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
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.

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

ProblemaQué hacer
403 forbidden de IkiformLa clave es de otro espacio de trabajo, está revocada o le falta submissions:write. Crea una clave secreta con ese permiso.
429 rate_limitedUn 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 vezFalta 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 mensajeLa 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ónSe llamó a showJoined desde un event handler. Llámala dentro de la action del formulario, como hace join.

Preguntas

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