Lista de espera para un nuevo SaaS

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.

Preet SutharFundador, Ikiform

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:

ItemFunción de servidorRuta de servidor
Se define concreateServerFn().handler()createFileRoute() con server.handlers
La llamaTus componentes, mediante una llamada RPCCualquier cliente HTTP, incluido un <form> normal
RespuestaSerializada para quien llamaUn Response que construyes tú
Sin JavaScriptPosible con su .url, pero no controlas la respuestaTú 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.

Cómo responde la ruta de servidor de TanStack Start a cada tipo de envíoruta delalandingvalidateSearchlee ?ref=POST/api/waitlistcompruebael email,reenvíaIkiformclavesecreta, loguardaJSONel scriptmuestra elresultado303 a /?joined=yeso?problem=emailruta delalandingrenderizaelresultadoFormDataJSONAccept: jsonenvío normalCómo responde la ruta de servidor de TanStack Start a cada tipo de envíoruta de lalandingvalidateSearch lee?ref=POST/api/waitlistcomprueba el email,reenvíaIkiformclave secreta, loguardaJSONel script muestra elresultado303 a /?joined=yeso?problem=emailruta de lalandingrenderiza elresultadoFormDataJSONAccept: jsonenvío normal
  1. La ruta de la landing lee ?ref= con validateSearch y lo renderiza en un input oculto.
  2. 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.
  3. La ruta comprueba el email y la etiqueta de origen, y luego reenvía el registro a Ikiform con la clave secreta.
  4. 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.
  5. 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.
Un handler, dos respuestas. La URL lleva el resultado para el visitante sin JavaScript.

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:

Proyecto
src/
  routes/
    __root.tsx
    index.tsx
    api/waitlist.ts
  waitlist-messages.ts
vite.config.ts
.env.local

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

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

La ruta envía email y ref. Si añades una definición de campos en la pestaña Fields del formulario, incluye ambos:

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

src/waitlist-messages.ts
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

src/routes/api/waitlist.ts
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: el multipart/form-data que envía fetch con un cuerpo FormData y el cuerpo urlencoded de un envío normal.
  • process.env se lee dentro del handler, en el momento de la petición. En Cloudflare Workers solo contiene tus secretos con nodejs_compat activo y una fecha de compatibilidad 2025-04-01 o posterior, como en el wrangler.jsonc de abajo.
  • La cabecera Origin del navegador se reenvía porque Ikiform cuenta con ella los envíos de prueba gratuitos desde localhost, y el fetch propio de la ruta no envía ninguna.
  • ref debe contener letras minúsculas, dígitos, - o _, hasta 40 caracteres, igual que el max_length de la definición. Cualquier otra cosa se guarda como direct.
  • 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

src/routes/index.tsx
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>
  );
}
  • validateSearch convierte la query en bruto en un objeto tipado. Los valores desconocidos pasan a undefined, así que un enlace roto renderiza el formulario normal.
  • El input oculto ref se rellena en el servidor a partir de los search params. Un envío normal lo lleva sin necesidad de script.
  • El formulario tiene un method y un action reales. Si el bundle no ha cargado, el navegador envía a la ruta de servidor y sigue la redirección.
  • Una vez hidratado, onSubmit toma el control y envía Accept: 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 /.

HostingQué añadirEl secreto
VercelEl plugin de Vite nitro() del paquete nitroEnvironment Variables en los ajustes del proyecto
Netlify@netlify/vite-plugin-tanstack-start, como netlify() en los plugins de ViteEn Site configuration › Environment variables
Cloudflare Workers@cloudflare/vite-plugin con viteEnvironment: { name: "ssr" } y un wrangler.jsoncwrangler secret put IKIFORM_SECRET_KEY
wrangler.jsonc
{
  "$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

  1. Ejecuta vite dev, abre http://localhost:3000/?ref=test y regístrate. La línea de agradecimiento sustituye al formulario sin recargar.
  2. Abre Submissions en el panel. El registro está ahí con ref en test.
  3. Desactiva JavaScript, recarga la misma URL y vuelve a registrarte. La barra de direcciones muestra ?joined=yes, y el segundo registro también tiene ref en test.
  4. Con JavaScript desactivado, envía a@b y confirma que la página vuelve con el mensaje del email y ?problem=email.
  5. 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.
  6. Los envíos desde vite dev llevan un origen localhost, 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 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.

Problemas habituales

ProblemaQué hacer
Un envío normal muestra JSON en brutoEl 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ónIKIFORM_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/waitlistStart 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_fieldLa definición de campos no incluye ref. Añádelo como en la definición de arriba.
402 plan_requiredEl 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

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