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.
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:
| Item | 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.
- El visitante pulsa el botón. useOptimistic cambia al instante el formulario por la línea de agradecimiento.
- La server action comprueba el email y cuenta los intentos recientes del visitante por IP.
- Dentro del límite, envía el registro a Ikiform con la clave secreta.
- 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.
- Por encima del límite, no se envía nada y el estado de error vuelve enseguida.
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_.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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.
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, leecf-connecting-ip. Una cabecera que el propio visitante puede poner no es ningún límite.
La server action
"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.timeoutevita que una red lenta deje el botón en estado pendiente para siempre.- El
429de Ikiform se lee por separado, con suRetry-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
defaultValuevuelve a poner la dirección. - La cabecera
Origindel navegador se reenvía porque Ikiform cuenta con ella los envíos de prueba gratuitos, y elfetchpropio de un servidor no envía ninguna. - El
submit()del SDK no envía la cabeceraAuthorization, así que no puede saltarse los dominios permitidos. Usa aquífetchcon la clave.
El formulario, con un agradecimiento optimista
"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>
);
}useOptimisticdebe establecerse dentro de una action.joines la action del formulario, así que React la ejecuta en una transición y la línea optimista se mantiene hasta que terminadispatch.- Si va bien, el estado de la action pasa a
joinedy la línea se queda, ya con el mensaje final. Si hay un error, el valor optimista desaparece y el formulario vuelve constate.emaily el mensaje. aria-busyindica a los lectores de pantalla que la línea aún se está confirmando.
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
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
- Ejecuta
next dev, abre/waitlist?ref=testy regístrate. La línea de agradecimiento aparece antes de que termine la petición. - Abre Submissions en el panel. El registro está ahí con
refentest. - Envía seis veces en un minuto. El sexto muestra el mensaje de límite y solo se guardan cinco envíos.
- Pon un valor incorrecto en
IKIFORM_SECRET_KEYy envía. La línea optimista se revierte y el formulario muestra el error con tu email todavía escrito. - 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. - 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
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.
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.
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.
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.