# Crea una lista de espera en React Router

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

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

Dale a la lista de espera su propia ruta con una `action` que valide el email y lo reenvíe a tu backend de formularios con una clave secreta. Renderiza el formulario en tu layout con `useFetcher`, para que un registro desde cualquier página se quede en esa página y se etiquete con su ruta; sin JavaScript, el mismo formulario envía a la ruta de la lista de espera y su página muestra el resultado.

La [guía principal](https://ikiform.com/es/guides/waitlist.md#form) tiene una pestaña de React Router con el formulario y su `action` en la ruta de inicio. Eso encaja con un sitio de una sola página. La mayoría de los sitios SaaS antes del lanzamiento tienen varias páginas, como precios, un post del blog y la página de inicio, y quieres el registro en todas y saber cuál funcionó. Esta página pone el formulario en el layout y envía todas las copias a una sola action.

## Qué cambia en React Router

En modo framework, una action pertenece a una ruta, y un `<Form>` envía a la ruta en la que está. Un formulario en el layout está en todas las rutas, así que el componente que elijas decide qué pasa al enviar:

|   | `<Form method="post">` | `<fetcher.Form action="/waitlist">` |
| --- | --- | --- |
| Adónde envía | La ruta actual, salvo que configures `action` | La action de la ruta `/waitlist` |
| Después de enviar | Una navegación. La URL y el historial cambian | Sin navegación. El visitante se queda en la página |
| El resultado | El `actionData` de la ruta que se ejecutó | `fetcher.data`, compartido por todos los fetchers con la misma `key` |
| Sin JavaScript | Un envío normal | Un envío normal a `/waitlist`, que renderiza su propia página |

Un fetcher encaja con un registro en el footer: nadie quiere salir de la página de precios para unirse a una lista. Además se degrada bien. Antes de la hidratación, `fetcher.Form` es un `<form>` corriente que envía a `/waitlist`, así que esa ruta necesita una página propia para mostrar el resultado.

Diagrama: Los registros del footer desde cualquier página de React Router pasan por una sola action de ruta

1. El layout renderiza el formulario de lista de espera en todas las páginas, con un ref oculto formado a partir de la ruta de la página.
2. Con JavaScript, fetcher.Form envía a /waitlist sin salir de la página. La key del fetcher es compartida, así que una nota en el header ve el mismo resultado.
3. Sin JavaScript, el mismo formulario envía a /waitlist como una navegación normal.
4. En ambos casos, la action de /waitlist comprueba el email y reenvía el registro a Ikiform con la clave secreta.
5. Un fetcher lee el resultado de fetcher.data. Un envío normal llega a la página /waitlist, que lo muestra desde actionData.

Una action, cualquier número de formularios. La ruta te dice qué página consiguió el registro.

## Prepara el proyecto

La [guía de servidor](https://ikiform.com/es/docs/guides/server.md) cubre cómo crear el formulario y una clave secreta. Los archivos de esta página:

`Proyecto`:

```text
app/
  root.tsx
  routes.ts
  routes/home.tsx
  routes/pricing.tsx
  routes/waitlist.tsx
  components/waitlist-form.tsx
react-router.config.ts
.env
```

Pon la clave en `.env` y léela desde `process.env` en la action. Los módulos de ruta se dividen para el navegador, pero `action` solo se ejecuta en el servidor, así que la clave nunca llega al cliente. Nunca la importes en un componente.

`.env`:

```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 action envía `email` y `ref`. Si el formulario tiene una definición de campos en su pestaña **Fields**, debe incluir 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

### Registra la ruta

`app/routes.ts`:

```ts
import { index, type RouteConfig, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("pricing", "routes/pricing.tsx"),
  route("waitlist", "routes/waitlist.tsx"),
] satisfies RouteConfig;
```

### La ruta de la lista de espera

`app/routes/waitlist.tsx`:

```tsx
import { data } from "react-router";
import { WaitlistForm } from "../components/waitlist-form";
import type { Route } from "./+types/waitlist";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const email = String(form.get("email") ?? "").trim();
  if (!EMAIL.test(email)) {
    return data({ ok: false, message: "Enter the email we should send your invite to.", email }, { status: 400 });
  }
  const ref = String(form.get("ref") ?? "");

  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: SOURCE.test(ref) ? ref : "direct",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  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.", email };
  return data(
    {
      ok: false,
      message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      email,
    },
    { status: response?.status === 429 ? 429 : 400 },
  );
}

export default function Waitlist({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>Join the Acme waitlist</h1>
      <WaitlistForm initial={actionData} />
    </main>
  );
}
```

- `data()` establece el estado de un error sin convertirlo en una respuesta lanzada, así que el componente sigue renderizando el mensaje.
- Cada respuesta devuelve el email, para que el input pueda volver a mostrarlo tras un error.
- 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 action no envía ninguna.
- `ref` debe contener letras minúsculas, dígitos, `-` o `_`, hasta 40 caracteres, el mismo `max_length` que la definición. Cualquier otra cosa se guarda como `direct`.
- El export por defecto es el plan B sin JavaScript. Renderiza el mismo formulario con el resultado de la action.

### El formulario compartido

`app/components/waitlist-form.tsx`:

```tsx
import { useFetcher, useLocation } from "react-router";

interface Outcome {
  ok: boolean;
  message: string;
  email: string;
}

export function sourceFor(pathname: string) {
  const slug = pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "-").toLowerCase();
  return (slug || "home").slice(0, 40);
}

export function WaitlistForm({ initial }: { initial?: Outcome }) {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  const { pathname } = useLocation();
  const outcome = fetcher.data ?? initial;
  const pending = fetcher.state !== "idle";

  if (outcome?.ok) return <p role="status">{outcome.message}</p>;

  return (
    <fetcher.Form method="post" action="/waitlist">
      <label>
        Email
        <input name="email" type="email" autoComplete="email" required defaultValue={outcome?.email} />
      </label>
      <input type="hidden" name="ref" value={sourceFor(pathname)} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <p role="alert">{outcome?.message}</p>
      <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
    </fetcher.Form>
  );
}

export function JoinedNote() {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  return fetcher.data?.ok ? <span>You're on the list</span> : <a href="#waitlist">Join the waitlist</a>;
}
```

- `key: "waitlist"` hace que todos los fetchers con esa key sean un único fetcher compartido. `JoinedNote` en el header lee el mismo `fetcher.data`, así que cambia en cuanto el formulario del footer tiene éxito.
- `sourceFor` convierte `/blog/launch` en `blog-launch` y `/` en `home`. La ruta se conoce en el servidor, así que también funciona en páginas prerenderizadas.
- `fetcher.state` vale `submitting` mientras se hace el envío y luego `loading` mientras React Router revalida, así que el botón sigue desactivado hasta que terminan ambos.
- La etiqueta envuelve el input, así que las copias del hero y del footer en una misma página no comparten un `id`.

Renderiza `<WaitlistForm />` en el `Layout` de `app/root.tsx`, dentro de un footer con `id="waitlist"`, y `<JoinedNote />` en tu header. El honeypot viaja con el resto y llega a Ikiform sin tocar. Los envíos con la clave secreta se saltan la verificación humana de Ikiform, así que activar **Spam check** no aporta nada a este formulario.

### Evita recargar todos los loaders

Después de una action, React Router recarga los datos de todas las rutas de la página. Un registro no cambia ninguno. Añade esto a las rutas cuyos loaders sean lentos o cuesten dinero:

`app/routes/pricing.tsx`:

```ts
import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/waitlist") return false;
  return defaultShouldRevalidate;
}
```

## Renderizado y despliegue

Las páginas de marketing no cambian según el visitante, así que prerenderízalas y reserva el servidor para la action:

`react-router.config.ts`:

```ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
  prerender: ["/", "/pricing"],
} satisfies Config;
```

Deja `ssr: true`. Con `ssr: false` no hay servidor en tiempo de ejecución, y React Router hace fallar la build de cualquier ruta que exporte una `action`. Con `ssr: true`, `/` y `/pricing` se escriben como HTML en el build, y `/waitlist` sigue ejecutándose en el servidor.

| Hosting | Qué añadir | Leer la clave |
| --- | --- | --- |
| Vercel | `@vercel/react-router`, con `presets: [vercelPreset()]` en `react-router.config.ts` | `process.env`, configurada en **Environment Variables** |
| Netlify | `@netlify/vite-plugin-react-router`, como `netlifyReactRouter()` en los plugins de Vite | `process.env`, configurada en **Environment variables** |
| Cloudflare Workers | `@cloudflare/vite-plugin`, como `cloudflare()` antes de `reactRouter()` | `env.IKIFORM_SECRET_KEY` de `cloudflare:workers`, configurada con `wrangler secret put` |

En Cloudflare, haz `import { env } from "cloudflare:workers"` y lee la clave desde ahí en lugar de `process.env`. En React Router 8, el `context` de la action es un `RouterContextProvider`, así que el patrón `context.cloudflare.env` de la v7 ya no sirve. El resto de la action no cambia.

## Comprueba que funciona

1. Ejecuta `react-router dev`, abre `http://localhost:5173/pricing` y regístrate en el footer. La línea de agradecimiento sustituye al formulario, y la nota del header cambia con ella.
2. Abre **Submissions** en el panel. El registro tiene `ref` en `pricing`.
3. Desactiva JavaScript y regístrate desde la página de inicio. Llegas a `/waitlist` con la línea de agradecimiento, y el registro tiene `ref` en `home`.
4. Envía `a@b` desde el footer. El error aparece bajo el input con la dirección todavía escrita.
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 `react-router 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 registro en el footer navega a `/waitlist` incluso con JavaScript activo | El formulario es un `<Form>` o un `<form>` normal, no `fetcher.Form`. Usa el fetcher y no devuelvas un `redirect()` desde la action; una redirección desde la action de un fetcher provoca una navegación. |
| La build falla con un error sobre exports de `action` | `ssr` está en `false` en `react-router.config.ts`. Ponlo en `true`, o la lista de espera no tendrá servidor en el que ejecutarse. |
| El `ref` de todos los registros es `waitlist` | Los envíos vienen de la propia página `/waitlist`. Esa es la página de plan B; los enlaces deberían apuntar a tus páginas reales. |
| `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

### ¿Cómo envío un formulario de React Router sin navegar?

Usa useFetcher y renderiza fetcher.Form con la action de la ruta que lo gestiona. El visitante se queda en la página y el resultado llega en fetcher.data. Antes de la hidratación, envía como un formulario normal.

### ¿Las páginas prerenderizadas de React Router pueden tener un formulario que funcione?

Sí, cuando ssr es true. Las páginas prerenderizadas se sirven como HTML, y sus formularios envían a una action que se ejecuta en el servidor. Con ssr en false, no se permiten actions.

### ¿Cómo sé de qué página vino un registro en React Router?

Pon la ruta de la página en un input oculto ref, como hace sourceFor arriba. La ruta se conoce en el servidor y en el cliente, así que funciona en páginas prerenderizadas y antes de la hidratación.

### ¿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 TanStack Start](https://ikiform.com/es/guides/waitlist/tanstack-start.md): 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.
- [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.
