Guías19 min de lectura
Cómo crear una lista de espera para un nuevo SaaS
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.
Pon un formulario HTML sencillo en tu landing page que envíe el email del visitante a un backend de formularios, así no necesitas un servidor antes del lanzamiento. Reenvía cada registro por webhook a la herramienta de email con la que vas a lanzar y deja que esa herramienta mande la confirmación. El día del lanzamiento, exporta la lista como CSV, quita los duplicados por email y envía un único email de lanzamiento.
Una lista de espera es la prueba más barata de una idea de SaaS. Si desconocidos dejan su email por algo que todavía no existe, tienes un motivo para construirlo y una lista de personas a las que avisar cuando salga. Esta guía crea una en tu propia landing page, sin base de datos ni servidor, y la deja lista para el día del lanzamiento.
Cómo funciona una lista de espera
Toda lista de espera tiene las mismas tres partes: un formulario en tu página, un lugar que guarda cada registro y la herramienta desde la que enviarás emails a la lista. Aquí, el formulario envía a Ikiform. Ikiform revisa el envío en busca de spam, lo guarda, te avisa por email y manda un webhook firmado. Un pequeño relay o una herramienta de automatización pasa la dirección a tu herramienta de email.
- Un visitante escribe su email en tu landing page y el formulario lo envía a tu endpoint de Ikiform.
- Ikiform comprueba el honeypot, la puntuación de spam y el límite de frecuencia, y luego guarda el registro.
- El registro aparece en el panel, donde puedes buscarlo y exportar la lista como CSV.
- Ikiform te envía un email a ti y a tu equipo. Nunca escribe a la persona que se registró.
- Ikiform envía un webhook submission.created firmado.
- Un relay o una herramienta de automatización añade la dirección a tu herramienta de email, que envía la confirmación y, más adelante, el email de lanzamiento.
Cuatro formas de gestionar una lista de espera
No hay una única herramienta correcta. La tabla compara las cuatro configuraciones habituales según en qué destacan y qué te cuestan más adelante.
| Enfoque | Destaca en | Contrapartidas |
|---|---|---|
| Un SaaS de listas de espera, como LaunchList o Waitlister | Enlaces de referidos, rankings y posición en la fila, listos para usar. Una página alojada si todavía no tienes sitio. | Tu lista vive en otro producto. El widget tiene su estilo salvo que pagues o lo rediseñes. El precio suele crecer con los registros. |
| Un formulario incrustado de un constructor, como Typeform o Tally | Flujos pulidos de una pregunta por pantalla, sin nada de código. | Un iframe que no encaja con tu página y carga sus propios scripts. La marca, los límites de respuestas y los webhooks dependen del plan. |
| Tu propia base de datos y ruta de API | Control total sobre los datos y sobre cualquier lógica, como códigos de referido. | Tienes que construir el antispam, los límites de frecuencia, las notificaciones y la exportación, y necesitas un servidor aunque la landing page sea estática. |
| Un backend de formularios, como Ikiform | Tu propio HTML en cualquier sitio. Filtro de spam, almacenamiento, avisos por email, webhooks firmados y exportación CSV sin servidor. | Sin ranking de referidos y sin emails para la persona que se registra. Eso lo pone tu herramienta de email. |
Elige un SaaS de listas de espera si toda tu estrategia son los referidos. Elige un backend de formularios si quieres que el formulario se vea como el resto de tu sitio, que la lista sea portable y enviar los emails desde la herramienta que vas a usar después del lanzamiento de todos modos. El resto de esta guía usa el backend de formularios.
El formulario
Para empezar, pide un email y nada más. Cada campo extra te cuesta registros, y puedes hacer preguntas más adelante, por email, a quienes ya dijeron que sí. Elige tu stack abajo; la elección se aplica a todos los ejemplos de código de estas guías.
Pega el formulario donde deba aparecer el registro. Envía de la forma clásica y redirige, así que funciona sin nada de JavaScript.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="email">Email</label>
<input id="email" name="email" type="email" placeholder="you@company.com" autocomplete="email" required />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Join the waitlist</button>
</form>Para que los visitantes se queden en la página, añade este script después del formulario. Envía con fetch, cambia el formulario por la línea de agradecimiento y muestra los errores de campo del servidor.
const form = document.querySelector('form[action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx"]');
const status = document.createElement("p");
status.setAttribute("role", "alert");
form.append(status);
const labels = {
email: "Email",
};
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
status.textContent = "";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.replaceWith("You're on the list. We'll email you at launch.");
return;
}
const fields = Object.entries(result.error.fields ?? {});
status.textContent = fields.length
? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`).join(" ")
: result.error.message;
} catch {
status.textContent = "Couldn't reach the server. Try again.";
}
button.disabled = false;
});¿Quieres profundizar con HTML? Lee Cómo añadir un formulario de lista de espera en HTML puro a cualquier landing page.
Una server action reenvía el registro con tu clave secreta, y useActionState muestra el resultado sin recargar la página. El formulario sigue enviando aunque JavaScript aún no haya cargado.
"use server";
import { headers } from "next/headers";
export interface JoinState {
ok: boolean;
message: string;
email?: string;
}
export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
const email = String(formData.get("email") ?? "").trim();
if (!email.includes("@")) return { ok: false, message: "Enter the email we should send your invite to.", email };
const 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: (await headers()).get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: String(formData.get("ref") ?? "homepage"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return {
ok: false,
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
email,
};
}"use client";
import { useActionState } from "react";
import { type JoinState, joinWaitlist } from "./actions";
const initial: JoinState = { ok: false, message: "" };
export function WaitlistForm() {
const [state, action, pending] = useActionState(joinWaitlist, initial);
if (state.ok) return <p role="status">{state.message}</p>;
return (
<form action={action}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required defaultValue={state.email} placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}¿Quieres profundizar con Next.js? Lee Cómo crear una lista de espera en Next.js con una server action.
Una Astro Action con accept: "form" valida el email con Zod y lo reenvía. El formulario envía sin JavaScript de cliente, así que la página debe renderizarse bajo demanda: añade un adaptador y deja prerender = false.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
joinWaitlist: defineAction({
accept: "form",
input: z.object({
email: z.email(),
ref: z.string().default("homepage"),
_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 ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(input),
});
const result = await response.json();
if (!result.ok) {
throw new ActionError({
code: "BAD_REQUEST",
message: result.error.fields?.email?.[0] ?? result.error.message,
});
}
return { joined: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.joinWaitlist);
---
{result?.data?.joined ? (
<p role="status">You're on the list. We'll email you on launch day.</p>
) : (
<form method="POST" action={actions.joinWaitlist}>
<label for="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && (
<p role="alert">{isInputError(result.error) ? "Enter a valid email address." : result.error.message}</p>
)}
<button>Join the waitlist</button>
</form>
)}¿Quieres profundizar con Astro? Lee Crea una lista de espera en Astro.
Una función POST de createServerFn recibe el FormData, lo comprueba en .validator() y lo reenvía desde el servidor. La ruta la llama mediante useServerFn.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
const joinWaitlist = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const email = String(data.get("email") ?? "").trim();
if (!email.includes("@")) throw new Error("Enter the email we should send your invite to.");
return {
email,
ref: String(data.get("ref") ?? "homepage"),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }) => {
const origin = getRequestHeader("origin");
const 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 ? { Origin: origin } : {}),
},
body: JSON.stringify(data),
});
const result = await response.json();
if (result.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return { ok: false, message: String(result.error.fields?.email?.[0] ?? result.error.message) };
});
export const Route = createFileRoute("/")({ component: Home });
function Home() {
const join = useServerFn(joinWaitlist);
const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setState(await join({ data }));
} catch (error) {
setState({ ok: false, message: error instanceof Error ? error.message : "Couldn't join the list. Try again." });
} finally {
setPending(false);
}
}
if (state?.ok) return <p role="status">{state.message}</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state?.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}¿Quieres profundizar con TanStack Start? Lee Crea una lista de espera en TanStack Start.
La action de la ruta reenvía el envío, y actionData devuelve el resultado al componente. data() conserva el código de estado de Ikiform en los errores.
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/home";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email") ?? "").trim();
const 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: request.headers.get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: String(formData.get("ref") ?? "homepage"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return data(
{ ok: false, message: String(result.error.fields?.email?.[0] ?? result.error.message) },
{ status: response.status },
);
}
export default function Home({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.ok) return <p role="status">{actionData.message}</p>;
return (
<Form method="post">
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData?.message ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</Form>
);
}¿Quieres profundizar con React Router? Lee Crea una lista de espera en React Router.
Una form action por defecto reenvía el envío y devuelve fail() con el mensaje cuando hay errores. use:enhance envía sin recargar y conserva el email escrito en el input. En SvelteKit 3, declara IKIFORM_SECRET_KEY en src/env.ts con defineEnvVars para que $app/env/private pueda leerla.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
const email = String(data.get("email") ?? "").trim();
if (!email.includes("@")) return fail(400, { email, message: "Enter the email we should send your invite to." });
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: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({
email,
ref: String(data.get("ref") ?? "homepage"),
_gotcha: String(data.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (!result.ok) {
return fail(response.status, {
email,
message: String(result.error.fields?.email?.[0] ?? result.error.message),
});
}
return { joined: true };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
</script>
{#if form?.joined}
<p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update();
pending = false;
};
}}
>
<label for="email">Email</label>
<input id="email" name="email" type="email" required value={form?.email ?? ""} placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message}
<p role="alert">{form.message}</p>
{/if}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
{/if}¿Quieres profundizar con SvelteKit? Lee Crea una lista de espera en SvelteKit.
Una ruta de servidor mantiene la clave secreta fuera del navegador. Define NUXT_IKIFORM_SECRET_KEY en el entorno y Nuxt rellena runtimeConfig.ikiformSecretKey con ella.
export default defineEventHandler(async (event) => {
const body = await readBody<{ email?: string; ref?: string; _gotcha?: string }>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
email: body.email?.trim() ?? "",
ref: body.ref ?? "homepage",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.fields?.email?.[0] ?? result.error.message),
});
}
return { joined: true };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const email = ref("");
const trap = ref("");
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
async function join() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/waitlist", {
method: "POST",
body: { email: email.value, ref: "homepage", _gotcha: trap.value },
});
status.value = "joined";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't join the list. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
<form v-else @submit.prevent="join">
<label for="email">Email</label>
<input id="email" v-model="email" name="email" type="email" required placeholder="you@company.com" />
<input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>¿Quieres profundizar con Nuxt? Lee Crea una lista de espera en Nuxt.
Una single-page app no tiene servidor propio, así que el formulario envía directamente a Ikiform desde el navegador. Nunca pongas una clave secreta en este código; en su lugar, Allowed domains (dominios permitidos) protege el formulario.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function WaitlistForm() {
const [status, setStatus] = useState<"idle" | "sending" | "joined">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus("sending");
setError("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setStatus("joined");
return;
}
setError(result.error.fields?.email?.[0] ?? result.error.message);
} catch {
setError("Couldn't reach the server. Try again.");
}
setStatus("idle");
}
if (status === "joined") return <p role="status">You're on the list. We'll email you on launch day.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Joining" : "Join the waitlist"}</button>
</form>
);
}El mismo envío desde el navegador en un componente de un solo archivo de Vue. El formulario conserva su action, así que sigue funcionando si el script no carga.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
async function join(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
status.value = "sending";
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
status.value = "joined";
return;
}
error.value = result.error.fields?.email?.[0] ?? result.error.message;
} catch {
error.value = "Couldn't reach the server. Try again.";
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="join">
<label for="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>- El endpoint es la URL de tu formulario. Sustituye
frm_xxxxxxxxxxxxxxxxpor el id del panel. name="email"es el id del campo. Déjalo comoemail: Ikiform lo usa como dirección de respuesta de los emails de notificación y lo compara con los dominios de email desechables.- El input oculto
_gotchaes el honeypot. Las personas nunca lo ven. Los bots que rellenan todos los inputs se marcan solos como spam, y las versiones de servidor lo reenvían sin tocarlo. - El input oculto
refregistra de dónde vino el registro. Cambia su valor por página o por campaña. - Con una definición de campos en la pestaña Fields, un input que no aparece en ella se rechaza con
422y un errorunknown_field, así que incluyerefademás deemail, como abajo. - Un envío HTML normal redirige el navegador tras el éxito. Configura Redirect after submit en los ajustes del formulario con tu propia página de agradecimiento, o Ikiform mostrará la suya alojada.
{
"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 } }
]
}IKIFORM_SECRET_KEY en variables de entorno solo de servidor.Las versiones de servidor reenvían la cabecera Origin del navegador, porque Ikiform detecta con ella las pruebas gratuitas desde localhost y el fetch propio de un servidor no envía ninguna.
Las guías por framework de la documentación profundizan más: Next.js, Astro, SvelteKit, Nuxt, React, Vue y HTML. El SDK envuelve la petición del navegador como submit(), y la plantilla de formulario de lista de espera genera la versión de cuatro campos.
Campos que vale la pena pedir
El email es el único campo que necesitas. Si quieres ordenar la lista antes del lanzamiento, añade como mucho dos más. Prueba abajo la versión de cuatro campos: ejecuta el validador de Ikiform en tu navegador y muestra lo que se guardaría, o el 422 que recibirías.
Vista previa
Ejecuta el validador de Ikiform en tu navegador. No se envía nada.
ref, que tu página rellena con un input oculto en vez de una casilla visible. Pégalo en la pestaña Fields del formulario e Ikiform lo aplicará en el servidor.| Campo | Por qué lo pedirías | Ten en cuenta |
|---|---|---|
email | La lista en sí | Obligatorio. Usa type="email" para que los móviles muestren el teclado adecuado |
name | Un email de lanzamiento personal | Opcional. Basta con el nombre de pila |
role | Invitar primero a fundadores, o escribir un email distinto a los ingenieros | Un select mantiene las respuestas limpias para filtrar |
source | Saber qué canal funciona antes de invertir en él | El texto libre es sincero pero desordenado. Un input oculto ref es más limpio, mira abajo |
ref | Contar registros por página o campaña sin preguntar | Un input oculto. text opcional con max_length 40 |
Para saber de dónde vienen los registros sin preguntar, añade un input oculto por página o por campaña. Los nombres que empiezan con _ son instrucciones para Ikiform y nunca se guardan, así que usa un nombre normal como ref:
<input type="hidden" name="ref" value="homepage" />
<input type="hidden" name="_redirect" value="https://example.com/thanks" />Cada envío también registra el origen y el referrer de la página, así que puedes ver de qué página vino un registro incluso sin ref. El input _redirect lleva el navegador a tu propia página de agradecimiento; solo funciona con una URL https: en uno de los Allowed domains del formulario.
true con la fecha y hora del envío, lo que registra cuándo aceptó la persona. La plantilla de newsletter incluye una.Mantén los bots fuera de la lista
Un formulario público con un campo de email atrae bots en cuestión de días. Los registros falsos inflan tus números y, peor aún, tu email de lanzamiento cae en trampas de spam que dañan tu reputación como remitente. Ikiform aplica cuatro capas de defensa; las dos primeras están activas sin configurar nada.
| Defensa | Qué frena | Configuración |
|---|---|---|
| Honeypot | Bots simples que rellenan todos los inputs | El input oculto _gotcha del formulario de arriba |
| Puntuación de spam | Direcciones desechables (+2), repeticiones idénticas (+3), enlaces y frases de spam conocidas. Con 5, el registro se guarda como spam | Ninguna |
| Límites de frecuencia | Scripts que envían en bucle | Activos por defecto: Standard, 5 por minuto por visitante y 300 por minuto para todo el formulario |
| Verificación humana | Bots que usan un navegador real | Pon Spam check en Ikiform. Usa Cloudflare Turnstile sin claves que gestionar |
El spam se guarda con una respuesta de éxito normal, así que un bot no aprende nada. No envía email ni webhook y no cuenta para tu uso. Revisa el filtro Spam del panel una vez por semana durante el primer mes; si un registro real acaba ahí, márcalo como no spam y su webhook saldrá en ese momento.
Añade también tu dominio en Allowed domains. Los envíos desde otros sitios se marcan entonces como spam, o se rechazan con 403 origin_not_allowed si eliges Reject. Con la verificación humana activa, el formulario HTML no necesita cambios. La versión con fetch debe pedir antes un token con challenge() de @ikiform/sdk; la documentación antispam muestra las cuatro líneas.
La misma persona, dos veces
La gente se vuelve a registrar desde otra pestaña, desde un segundo dispositivo o una semana después porque se le olvidó. Ikiform guarda cada envío aceptado y no fusiona ni rechaza repeticiones por email. Esto es lo que pasa con una repetición:
| Caso | Resultado |
|---|---|
| El mismo email, con otros campos o más de 24 horas después | Un segundo envío normal |
| Los mismos campos otra vez dentro de 24 horas | +3 a la puntuación de spam, por debajo de 5, así que un segundo envío normal |
| Una repetición idéntica desde un dominio de email desechable | +3 y +2, que llegan a 5: se guarda como spam |
Así que cuenta con algunos duplicados y quítalos donde acabe la lista. Hay tres sitios donde hacerlo, de menos a más esfuerzo:
- En la herramienta de email. La mayoría de las herramientas de email identifican los contactos por dirección, así que añadir la misma dirección dos veces actualiza un contacto en lugar de crear dos.
- Al exportar. Quita los emails duplicados del CSV antes de importarlo en cualquier sitio. Abajo tienes dos formas.
- En tu relay. Pasa la dirección a minúsculas y quítale los espacios, y luego insértala con una restricción de unicidad o un upsert, para que una repetición no haga nada.
¿Quieres que un doble clic no cree dos filas en absoluto? Envía una clave de idempotencia por carga de página con la versión fetch. Eso frena las repeticiones accidentales; no impide que una persona se registre dos veces a propósito.
Confirmar los registros
Ikiform no envía emails a la persona que rellena el formulario. Los emails de notificación van solo a ti y a tu equipo, así que no hay respuesta automática, email de confirmación ni doble opt-in. Envíalos desde tu herramienta de email, activados por un webhook. Es intencionado: la herramienta que envía tu email de lanzamiento también debería enviar la confirmación, para que las bajas y los rebotes se registren en un solo lugar.
Dos formas de conectar Ikiform con ella:
- Sin código. Añade un endpoint de webhook que apunte a una herramienta de automatización y usa su acción para tu herramienta de email. Consulta Zapier, Make o n8n.
- Un pequeño relay. Un route handler en cualquier host de Node verifica la firma y llama a la API de tu herramienta de email. El ejemplo de abajo es una ruta de Next.js; el mismo código funciona en un Cloudflare Worker.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export async function POST(request: Request) {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const email = String(event.data.fields.email ?? "").trim().toLowerCase();
if (!email) return new Response(null, { status: 204 });
const response = await fetch(process.env.LIST_API_URL!, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.LIST_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
email,
name: event.data.fields.name ?? null,
tags: ["waitlist", String(event.data.fields.role ?? "unknown")],
}),
});
if (response.status === 409) return new Response(null, { status: 204 });
if (!response.ok) return new Response(`List answered ${response.status}`, { status: 502 });
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}verifyWebhookcomprueba la firma y la marca de tiempo, así que nadie más puede añadir direcciones a través de esta URL.- Cambia el
fetchpor la llamada para añadir contactos de tu herramienta de email. La mayoría piden un email y una lista o etiqueta, y envían su propio email de confirmación cuando el doble opt-in está activo en esa herramienta. - Responder
204le dice a Ikiform que la entrega tuvo éxito. Un502hace que Ikiform reintente, con el mismowebhook-id, así que una caída breve de tu herramienta de email no hace perder registros.
Añade el endpoint en Webhooks en el panel y copia su secreto whsec_ en IKIFORM_WEBHOOK_SECRET. Un espacio de trabajo puede tener 3 endpoints. La documentación de webhooks cubre los reintentos y el payload, y tu propio servidor tiene una versión que guarda cada entrega una sola vez.
El día del lanzamiento
- La página se publica con el formulario enviando a Ikiform.
- Cada semana, comparte la página y lee los nuevos registros y sus respuestas.
- Una semana antes del lanzamiento, exporta la lista como CSV, quita los duplicados y divídela por rol.
- El día del lanzamiento, envía un email a la lista desde tu herramienta de email.
Exporta la lista
Abre Submissions en el panel, elige el formulario de la lista de espera, pon el estado en Received y el rango de fechas en All time, y luego elige Export CSV. Las columnas son id, created_at, form y status, y después una columna por campo. El archivo es UTF-8 con BOM, así que los acentos sobreviven a Excel. Una exportación tiene como máximo 100.000 filas; por encima de eso, pagina con la API REST.
Para quitar duplicados en Google Sheets, importa el CSV y usa esta fórmula en una hoja vacía, con el email en la columna E:
=SORTN(A2:F, 9^9, 2, LOWER(TRIM(E2:E)), TRUE)Conserva una fila por dirección, sin distinguir mayúsculas ni espacios, ordenada por email. O ejecuta esto con Node en la carpeta que contiene la exportación. Conserva la primera fila de cada dirección y supone que no hay comas dentro de los valores, lo que se cumple en un formulario que solo pide el email:
import { readFileSync, writeFileSync } from "node:fs";
const [header = "", ...rows] = readFileSync("submissions.csv", "utf8").replace(/^\uFEFF/, "").trim().split(/\r?\n/);
const columns = header.split(",");
const emailAt = columns.indexOf("email");
const seen = new Set();
const unique = rows.filter((row) => {
const email = row.split(",")[emailAt]?.trim().toLowerCase();
if (!email || seen.has(email)) return false;
seen.add(email);
return true;
});
writeFileSync("waitlist.csv", [header, ...unique].join("\n"));
console.log(`${rows.length} rows, ${unique.length} unique emails`);Envía el email de lanzamiento
Envíalo desde tu herramienta de email, no desde tu bandeja de entrada personal. Una bandeja personal que escribe a cientos de direcciones a la vez parece spam para los proveedores de correo, y querrás gestionar las bajas desde el momento en que envíes. Que sea breve:
Subject: Acme is live. You're in.
Hi Ada,
You joined the Acme waitlist in the spring. Today it's open.
Sign in here: https://acme.example/start
You get the first 30 days free because you waited.
Reply to this email if anything breaks. It comes to me.
Ada, founder of Acme- Di qué está disponible y enlaza directamente a la página de registro o de inicio de sesión.
- Premia la espera, por ejemplo con una prueba más larga o un descuento, y dilo una sola vez.
- Envíalo desde una persona. Las respuestas son el mejor feedback temprano que vas a recibir.
- Envía primeras líneas distintas a fundadores e ingenieros si recogiste
role. Para eso sirve recogerlo.
Elige tu caso
Los pasos de arriba funcionan en cualquier sitio. Estas páginas cubren lo que cambia para un lanzamiento o un creador de sitios concreto.
- Lista para lanzar en Product HuntReú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 filtroUn 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óvilUna 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 Next.jsUna 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 FramerDos 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.
- Lista de espera en WebflowApunta un Form Block de Webflow a Ikiform, etiqueta los registros por origen con un campo oculto y conserva el estado de éxito de Webflow con un script breve para todos los formularios.
- Lista de espera en CarrdRecoge registros de lista de espera en una página de Carrd: un formulario Send to URL en Pro Plus con seguimiento UTM, o un formulario HTML incrustado con honeypot en Pro Standard.
- Lista de espera en HTML puroUna 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 de espera en AstroUna 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.
- Lista de espera en TanStack StartUna lista de espera en TanStack Start: una ruta de servidor responde tanto a un envío de formulario normal como a fetch, y validateSearch lleva el origen y el resultado en la URL.
- Lista de espera en React RouterUna lista de espera de React Router en el footer de cada página: un fetcher envía a una sola action de ruta, etiqueta cada registro con su página y funciona sin JavaScript.
- Lista de espera en SvelteKitUna lista de espera en SvelteKit 3: un hook guarda el primer ?ref= en una cookie, una form action con nombre reenvía el registro y quien vuelve ve que ya se unió.
- Lista de espera en NuxtUna lista de espera en Nuxt sobre una landing page prerenderizada: una ruta de Nitro acepta $fetch y envíos normales, los valida con Zod y redirige sin JavaScript.
Comprueba que funciona
Repasa esto antes de compartir la página. 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.
- En Allowed domains del formulario, añade el dominio de tu sitio, y
localhostmientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier sitio. - Rellena el formulario en tu página y envíalo. Llegarás a tu página de agradecimiento, o a la alojada por Ikiform.
- Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que mandaste.
- 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. - Si añadiste un webhook, abre el endpoint en Webhooks. La entrega muestra
succeededcon la respuesta de tu servidor. - 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. - ¿No llegó nada? Cambia el filtro de estado del panel a Spam. El spam recibe la misma respuesta de éxito que un envío real.
- Regístrate dos veces con la misma dirección y confirma que tu herramienta de email sigue mostrando un solo contacto.
Solución de problemas
| Problema | Qué hacer |
|---|---|
403 origin_not_allowed, o todos los envíos acaban en spam | El dominio de la página no está en los Allowed domains del formulario. Añádelo, y también localhost para las pruebas locales. Los subdominios de un dominio de la lista están permitidos. |
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. |
403 challenge_required | La verificación antispam del formulario está en Ikiform y un envío con fetch no llevaba token. Consigue uno con challenge(formId) de @ikiform/sdk antes de cada envío, o desactiva la verificación. |
| El envío está en el panel pero no llegó ningún email ni webhook | Se guardó como spam. El spam no envía nada. Ábrelo con el filtro Spam para ver los motivos y márcalo como no spam para que se envíen su email y sus webhooks. |
| El navegador llega a la página de agradecimiento de Ikiform en vez de a la tuya | _redirect se ignora salvo que su dominio esté en Allowed domains y sea una URL https:. Añade el dominio, o configura Redirect after submit en los ajustes del formulario. |
| El mismo email aparece dos veces | Es lo esperado. Ikiform no deduplica por email. Quita las repeticiones al exportar, o haz un upsert por email en tu relay o tu herramienta de email. |
| Los registros llegan a Ikiform pero no a la herramienta de email | Abre el endpoint en Webhooks y lee el registro de entregas. Un 400 significa que el secreto es incorrecto; un 502 con el estado de tu herramienta significa que la herramienta rechazó el contacto. |
402 test_limit_reached | El espacio de trabajo no tiene plan y ya usó sus 50 pruebas gratuitas desde localhost este mes. Elige un plan, o usa Send test en el panel. |
Preguntas
No necesitas un servidor propio. El formulario necesita un sitio al que enviar, y eso es un backend de formularios: apunta el action del formulario a tu endpoint de Ikiform y este guarda, filtra y reenvía cada registro. Tu landing page puede seguir siendo estática.
No. Ikiform te envía emails a ti y a tu equipo, no a la persona que se registra, así que no hay email de confirmación ni de doble opt-in. Envía cada registro por webhook a tu herramienta de email y activa allí el doble opt-in.
Conserva el honeypot oculto _gotcha, deja activo el límite de frecuencia por defecto, añade tu dominio en Allowed domains y pon la verificación antispam en Ikiform para tener una comprobación de Cloudflare Turnstile sin claves que gestionar. Las direcciones desechables también suman a la puntuación de spam.
Puede pasar. Ikiform conserva cada envío aceptado y no fusiona repeticiones por email. Deduplica donde viva la lista: tu herramienta de email, una columna única en tu base de datos o el CSV antes de importarlo.
No solo con Ikiform. No tiene enlaces de referidos ni rankings. Puedes pasar un código de referido en un campo oculto y contarlo tú mismo, o usar un producto de listas de espera centrado en referidos si eso es clave para tu lanzamiento.
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.