Lista de espera para un nuevo SaaS8 min de lectura
Crea una lista de espera en TanStack Start
Una 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.
Añade una ruta de servidor en /api/waitlist que valide el email, reenvíe el registro a tu backend de formularios con una clave secreta y responda con JSON a fetch o con una redirección 303 a un envío de formulario normal. La ruta de la landing lee ?ref=, ?joined= y ?problem= con validateSearch, así que la etiqueta de origen y el resultado se conservan con o sin JavaScript.
La guía principal tiene una pestaña de TanStack Start basada en createServerFn y useServerFn. Es corta, pero el formulario solo funciona cuando React se ha hidratado, y el origen de cada registro es un texto fijo. Esta página construye la versión que compartirías el día del lanzamiento: envía antes de la hidratación, etiqueta cada registro según el enlace del que vino y guarda el resultado en la URL.
Qué cambia en TanStack Start
Start te da dos formas de ejecutar código en el servidor, y se comportan distinto con un formulario:
| Item | Función de servidor | Ruta de servidor |
|---|---|---|
| Se define con | createServerFn().handler() | createFileRoute() con server.handlers |
| La llama | Tus componentes, mediante una llamada RPC | Cualquier cliente HTTP, incluido un <form> normal |
| Respuesta | Serializada para quien llama | Un Response que construyes tú |
| Sin JavaScript | Posible con su .url, pero no controlas la respuesta | Tú eliges: JSON, o una redirección de vuelta a la página |
Un envío de lista de espera viene de dos tipos de cliente: un navegador que ejecuta tu script y un navegador que todavía no lo ha cargado. Una ruta de servidor puede responder a cada uno como espera, desde un solo handler. La cabecera Accept los distingue: el script pide application/json y un envío de formulario normal no.
- La ruta de la landing lee ?ref= con validateSearch y lo renderiza en un input oculto.
- El formulario envía su FormData a la ruta de servidor, con fetch cuando el script ha cargado, o como envío normal cuando no.
- La ruta comprueba el email y la etiqueta de origen, y luego reenvía el registro a Ikiform con la clave secreta.
- Un envío con fetch pidió JSON, así que recibe { ok } o un mensaje, y el componente muestra la línea de agradecimiento o el error.
- Un envío normal recibe un 303 de vuelta a la landing page, con ?joined=yes o un código ?problem=, y el mismo ref. validateSearch los lee y la página renderiza el resultado.
Prepara el proyecto
Primero crea el formulario y una clave secreta; la guía de servidor cubre ambas cosas. Estos son los archivos que tocas:
src/
routes/
__root.tsx
index.tsx
api/waitlist.ts
waitlist-messages.ts
vite.config.ts
.env.localGuarda la clave en .env.local, que Start carga en desarrollo y que queda fuera de git. El código de servidor la lee desde process.env. Nunca le pongas el prefijo VITE_; eso la empaqueta en el código del navegador.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY en variables de entorno solo de servidor.La ruta envía email y ref. Si añades una definición de campos en la pestaña Fields del formulario, incluye ambos:
{
"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
Códigos de error, no texto de error
export type Problem = "email" | "busy" | "failed";
export const MESSAGES: Record<Problem, string> = {
email: "Enter the email we should send your invite to.",
busy: "Lots of people are joining. Try again in a minute.",
failed: "Couldn't join the list. Try again.",
};
export function isProblem(value: unknown): value is Problem {
return typeof value === "string" && value in MESSAGES;
}La redirección pone un código corto en la URL, nunca el mensaje en sí. Así, un enlace con ?problem= solo puede mostrar una de tus tres frases, no cualquier texto que un desconocido escriba en una URL.
La ruta de servidor
import { createFileRoute } from "@tanstack/react-router";
import { MESSAGES, type Problem } from "../../waitlist-messages";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;
async function forward(request: Request, form: FormData): Promise<Problem | null> {
const email = String(form.get("email") ?? "").trim();
if (!EMAIL.test(email)) return "email";
const ref = String(form.get("ref") ?? "");
const origin = 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 ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
email,
ref: SOURCE.test(ref) ? ref : "direct",
_gotcha: String(form.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
if (!response) return "failed";
if (response.status === 429) return "busy";
const result = await response.json().catch(() => null);
if (result?.ok) return null;
return result?.error?.fields?.email ? "email" : "failed";
}
export const Route = createFileRoute("/api/waitlist")({
server: {
handlers: {
POST: async ({ request }) => {
const form = await request.formData();
const problem = await forward(request, form);
if (request.headers.get("accept")?.includes("application/json")) {
return problem
? Response.json({ ok: false, message: MESSAGES[problem] }, { status: problem === "busy" ? 429 : 400 })
: Response.json({ ok: true });
}
const back = new URL("/", request.url);
back.searchParams.set("ref", String(form.get("ref") ?? ""));
if (problem) back.searchParams.set("problem", problem);
else back.searchParams.set("joined", "yes");
return Response.redirect(back, 303);
},
},
},
});request.formData()lee ambas codificaciones: elmultipart/form-dataque envíafetchcon un cuerpoFormDatay el cuerpo urlencoded de un envío normal.process.envse lee dentro del handler, en el momento de la petición. En Cloudflare Workers solo contiene tus secretos connodejs_compatactivo y una fecha de compatibilidad 2025-04-01 o posterior, como en elwrangler.jsoncde abajo.- La cabecera
Origindel navegador se reenvía porque Ikiform cuenta con ella los envíos de prueba gratuitos desdelocalhost, y elfetchpropio de la ruta no envía ninguna. refdebe contener letras minúsculas, dígitos,-o_, hasta 40 caracteres, igual que elmax_lengthde la definición. Cualquier otra cosa se guarda comodirect.Response.redirect(back, 303)le dice al navegador que cargue la landing page con un GET, para que recargar no envíe el registro dos veces.
La ruta de la landing
import { createFileRoute } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";
import { isProblem, MESSAGES, type Problem } from "../waitlist-messages";
interface Search {
ref?: string;
joined?: boolean;
problem?: Problem;
}
export const Route = createFileRoute("/")({
validateSearch: (search: Record<string, unknown>): Search => ({
ref: typeof search.ref === "string" ? search.ref.toLowerCase().slice(0, 40) : undefined,
joined: search.joined === "yes" ? true : undefined,
problem: isProblem(search.problem) ? search.problem : undefined,
}),
component: Home,
});
function Home() {
const search = Route.useSearch();
const [joined, setJoined] = useState(Boolean(search.joined));
const [error, setError] = useState(search.problem ? MESSAGES[search.problem] : "");
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setError("");
try {
const response = await fetch("/api/waitlist", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.currentTarget),
});
const outcome = await response.json();
if (outcome.ok) setJoined(true);
else setError(outcome.message);
} catch {
setError("Couldn't reach the server. Try again.");
} finally {
setPending(false);
}
}
if (joined) return <p role="status">You're on the list. We'll email you on launch day.</p>;
return (
<form method="post" action="/api/waitlist" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value={search.ref ?? "direct"} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<p role="alert">{error}</p>
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}validateSearchconvierte la query en bruto en un objeto tipado. Los valores desconocidos pasan aundefined, así que un enlace roto renderiza el formulario normal.- El input oculto
refse rellena en el servidor a partir de los search params. Un envío normal lo lleva sin necesidad de script. - El formulario tiene un
methody unactionreales. Si el bundle no ha cargado, el navegador envía a la ruta de servidor y sigue la redirección. - Una vez hidratado,
onSubmittoma el control y envíaAccept: application/json, así que el visitante se queda en la página.
El honeypot viaja en el mismo FormData y llega a Ikiform sin tocar, así que los bots que lo rellenan se guardan como spam. Los envíos con la clave secreta se saltan la verificación humana de Ikiform, así que activar Spam check no aporta nada aquí; esta ruta es la barrera.
Renderizado y despliegue
Deja la ruta de la landing renderizada en el servidor, que es lo que Start hace por defecto. Lee la query string para renderizar ref y el resultado, y una página prerenderizada se genera una vez sin query. Si activas prerender en el plugin tanstackStart() para otras páginas, deja fuera /.
| Hosting | Qué añadir | El secreto |
|---|---|---|
| Vercel | El plugin de Vite nitro() del paquete nitro | Environment Variables en los ajustes del proyecto |
| Netlify | @netlify/vite-plugin-tanstack-start, como netlify() en los plugins de Vite | En Site configuration › Environment variables |
| Cloudflare Workers | @cloudflare/vite-plugin con viteEnvironment: { name: "ssr" } y un wrangler.jsonc | wrangler secret put IKIFORM_SECRET_KEY |
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "acme-waitlist",
"compatibility_date": "2025-09-02",
"compatibility_flags": ["nodejs_compat"],
"main": "@tanstack/react-start/server-entry"
}nodejs_compat hace que process.env esté disponible en Workers. Sin él, falta la clave y todos los envíos fallan con failed.
Comprueba que funciona
- Ejecuta
vite dev, abrehttp://localhost:3000/?ref=testy regístrate. La línea de agradecimiento sustituye al formulario sin recargar. - Abre Submissions en el panel. El registro está ahí con
refentest. - Desactiva JavaScript, recarga la misma URL y vuelve a registrarte. La barra de direcciones muestra
?joined=yes, y el segundo registro también tienerefentest. - Con JavaScript desactivado, envía
a@by confirma que la página vuelve con el mensaje del email y?problem=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. - Los envíos desde
vite devllevan un origenlocalhost, 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 desdelocalhost. 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.
Problemas habituales
| Problema | Qué hacer |
|---|---|
| Un envío normal muestra JSON en bruto | El navegador envió application/json en su cabecera Accept, algo que los envíos de formulario normales nunca hacen. Comprueba que ninguna extensión o proxy la reescriba, o haz que fetch envíe una cabecera propia y compara con esa. |
Todos los envíos responden failed en producción | IKIFORM_SECRET_KEY no está configurada en el hosting. En Workers, comprueba también que nodejs_compat está activo y que la fecha de compatibilidad es 2025-04-01 o posterior, o process.env se queda vacío. |
Dos archivos reclaman /api/waitlist | Start permite un archivo de handler por ruta. api/waitlist.ts y api/waitlist/index.ts resuelven a la misma; quédate con uno. |
422 con unknown_field | La definición de campos 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
Ambas pueden reenviar un registro. Una ruta de servidor te deja elegir la respuesta a un envío de formulario normal, como una redirección de vuelta a la página, así que el formulario funciona antes de la hidratación. Una función de servidor encaja con formularios que solo se ejecutan cuando React ha cargado.
Lee ?ref= con validateSearch en la ruta de la landing, renderízalo en un input oculto y vuelve a comprobarlo en el servidor antes de reenviarlo. La redirección tras un envío normal lo vuelve a poner en la URL.
No si renderiza el origen o el resultado desde la query string, porque una página prerenderizada se genera sin ella. Deja esa ruta renderizada en el servidor y prerenderiza el resto.
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.