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

Publicado el 2026-10-25 · https://ikiform.com/es/guides/waitlist/tanstack-start

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](https://ikiform.com/es/guides/waitlist.md#form) 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:

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

Diagrama: Cómo responde la ruta de servidor de TanStack Start a cada tipo de envío

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](https://ikiform.com/es/docs/guides/server.md) cubre ambas cosas. Estos son los archivos que tocas:

`Proyecto`:

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

```bash
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](https://ikiform.com/es/docs/rate-limits.md) 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`:

```json
{
  "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`:

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

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

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

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

`wrangler.jsonc`:

```json
{
  "$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](https://ikiform.com/es/pricing).

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

### ¿Un formulario de TanStack Start debe usar una función de servidor o una ruta de servidor?

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.

### ¿Cómo conservo el origen del registro en TanStack Start?

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.

### ¿Se puede prerenderizar la landing page de TanStack Start?

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.

### ¿Ikiform es gratis?

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.

## Más guías

- [Lista de espera para un nuevo SaaS](https://ikiform.com/es/guides/waitlist.md): 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.
- [Lista de espera en Next.js](https://ikiform.com/es/guides/waitlist/nextjs.md): 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.
- [Lista de espera en React Router](https://ikiform.com/es/guides/waitlist/react-router.md): Una 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 HTML puro](https://ikiform.com/es/guides/waitlist/html.md): Una 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 para lanzar en Product Hunt](https://ikiform.com/es/guides/waitlist/product-hunt.md): Reú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 filtro](https://ikiform.com/es/guides/waitlist/beta-signup.md): Un 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.
