# Crea un formulario de suscripción a newsletter en React Router

> Un formulario de newsletter en el footer con React Router: useFetcher envía a una ruta /newsletter que también funciona sin JavaScript, y una resource route alimenta MailerLite.

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

Pon la action en una ruta `/newsletter` real con su propia página y envía a ella desde un formulario en el footer construido con `useFetcher`. Con JavaScript, el fetcher envía en segundo plano y el lector se queda en el artículo; sin él, el mismo formulario es un POST normal que llega a la página `/newsletter` con el resultado. La action comprueba el consentimiento y reenvía la suscripción a tu backend de formularios, y una resource route convierte su webhook firmado en un suscriptor de MailerLite.

La [pestaña de React Router de la guía principal](https://ikiform.com/es/guides/newsletter-signup.md#form) envía a una resource route con `useFetcher`. Eso funciona una vez cargado JavaScript. Esta página arregla lo que pasa antes, evita que una suscripción recargue todos los loaders de la página y añade la ruta del webhook para MailerLite. La configuración del producto está en la [guía de servidor de la documentación](https://ikiform.com/es/docs/guides/server.md).

## Por qué la action necesita una página

La documentación de React Router describe `fetcher.Form` como un formulario HTML normal hasta que carga JavaScript. Un envío en ese momento es un POST de documento a la URL del `action` del formulario, y el navegador muestra lo que renderice esa ruta. Una resource route no renderiza nada; devuelve sus datos. Así que el lector que hace clic pronto acaba viendo JSON en bruto. Dale a la action una ruta con un componente por defecto y el mismo POST llega a una página:

Diagrama: Qué ve un lector sin JavaScript después de enviar el formulario del footer

1. Antes de la hidratación, fetcher.Form es un formulario normal, así que el navegador envía a /newsletter y navega hasta ahí.
2. Si /newsletter es una resource route, el navegador muestra como página el JSON de la action.
3. Si /newsletter tiene un componente por defecto, React Router ejecuta la action y luego renderiza la página con el resultado como actionData.
4. Una vez cargado JavaScript, las dos versiones se comportan igual: el fetcher envía en segundo plano y el lector no se mueve.

La solución es un export por defecto. La action y el formulario del footer no cambian.

## Configuración del proyecto

Esto usa el modo framework con el renderizado en servidor activado, `ssr: true` en `react-router.config.ts`, que es el valor por defecto.

`Árbol del proyecto`:

```text
app/
  root.tsx
  routes.ts
  routes/
    home.tsx
    newsletter.tsx
    api.ikiform.ts
  components/newsletter-form.tsx
react-router.config.ts
.env
```

`app/routes.ts`:

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

export default [
  index("routes/home.tsx"),
  route("newsletter", "routes/newsletter.tsx"),
  route("api/ikiform", "routes/api.ikiform.ts"),
] satisfies RouteConfig;
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789
```

Crea la clave de Ikiform en **API keys** con el scope `submissions:write` y pega la [definición de campos de la guía principal](https://ikiform.com/es/guides/newsletter-signup.md#fields) en la pestaña **Fields** del formulario.

## Constrúyelo

### La ruta /newsletter

`app/routes/newsletter.tsx`:

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

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  if (form.get("consent") !== "true") {
    return data(
      { ok: false as const, field: "consent", message: "Tick the box so we know you want the emails." },
      { status: 422 },
    );
  }

  const source = String(form.get("source") ?? "");
  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: String(form.get("email") ?? "").trim(),
      first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
      consent: true,
      source: PLACEMENTS.includes(source) ? source : "footer",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true as const };
  const email = result?.error?.fields?.email?.[0];
  return data(
    {
      ok: false as const,
      field: email ? "email" : "form",
      message: String(email ?? result?.error?.message ?? "We couldn't reach the list. Try again."),
    },
    { status: response?.status ?? 502 },
  );
}

export function meta() {
  return [{ title: "Newsletter" }];
}

export default function Newsletter({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>The monthly newsletter</h1>
      <NewsletterForm source="newsletter-page" initial={actionData} />
    </main>
  );
}
```

- Los errores se devuelven con `data()` y un estado `4xx`, no se lanzan, así que llegan al formulario en lugar de al error boundary.
- La comprobación del consentimiento se hace antes de la llamada de red. Que falte la casilla no cuesta nada.
- La línea de `Origin` reenvía el origen del navegador, porque Ikiform reconoce por esa cabecera las pruebas gratuitas desde `localhost` y el `fetch` de un servidor no envía ninguna.
- El componente por defecto renderiza el mismo formulario, alimentado con `actionData`, así que el resultado sin JavaScript y el resultado en la página se ven igual.

> Las versiones de servidor envían con una clave secreta, así que Ikiform se salta **Allowed domains** (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de 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.

### El formulario del footer

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

```tsx
import { useFetcher } from "react-router";
import type { Route } from "../routes/+types/newsletter";
import type { action } from "../routes/newsletter";

interface Props {
  source: "footer" | "post" | "newsletter-page";
  initial?: Route.ComponentProps["actionData"];
}

export function NewsletterForm({ source, initial }: Props) {
  const fetcher = useFetcher<typeof action>({ key: `newsletter-${source}` });
  const result = fetcher.data ?? initial;
  const busy = fetcher.state !== "idle";

  if (result?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  const error = result?.ok === false ? result : null;

  return (
    <fetcher.Form method="post" action="/newsletter">
      <label>
        Email
        <input name="email" type="email" required autoComplete="email" aria-invalid={error?.field === "email"} />
      </label>
      <label>
        First name (optional)
        <input name="first_name" maxLength={80} autoComplete="given-name" />
      </label>
      <label>
        <input name="consent" type="checkbox" value="true" required aria-invalid={error?.field === "consent"} />
        Email me the newsletter. I can unsubscribe at any time.
      </label>
      <input type="hidden" name="source" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error.message}</p> : null}
      <button disabled={busy}>{busy ? "Subscribing" : "Subscribe"}</button>
    </fetcher.Form>
  );
}
```

- `fetcher.data` contiene la respuesta de la action tras un envío en segundo plano; `initial` la contiene tras un POST de documento. Gana el que exista.
- `fetcher.state` es `submitting` y luego `loading` antes de volver a `idle`, así que el botón sigue desactivado en ambos.
- Una `key` por ubicación da al formulario del footer y al de la página `/newsletter` estados separados cuando están los dos en pantalla.

### Móntalo sin recargar los datos de la página

`app/root.tsx`:

```tsx
import type { ShouldRevalidateFunctionArgs } from "react-router";
import { Outlet } from "react-router";
import { NewsletterForm } from "~/components/newsletter-form";

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

export default function App() {
  return (
    <>
      <Outlet />
      <footer>
        <p>One email a month. Unsubscribe from any issue.</p>
        <NewsletterForm source="footer" />
      </footer>
    </>
  );
}
```

Después de que un fetcher envíe a una action, React Router revalida por defecto los loaders de todas las rutas que coinciden. Una suscripción a la newsletter no cambia nada de lo que cargan, así que `shouldRevalidate` en la raíz se salta ese trabajo cuando el envío fue a `/newsletter`. Añade el mismo export a cualquier ruta cuyo loader sea costoso. El formulario va en el componente por defecto `App` y no en `Layout`, porque `Layout` también envuelve el error boundary.

## Reenvía los suscriptores a MailerLite

Los webhooks vienen de fuera de tu app, así que este es una resource route: un `action` sin componente que devuelve objetos `Response` normales, como sugiere la documentación de React Router para los llamantes externos. Primero lee el cuerpo como texto porque la firma cubre los bytes exactos. Envía cada suscripción como `unconfirmed`, lo que encaja con una lista nueva. Si tu lista ya tiene lectores confirmados, usa en su lugar el relay de la [guía de MailerLite](https://ikiform.com/es/guides/newsletter-signup/mailerlite.md): primero busca la dirección, así que un lector confirmado que se vuelve a suscribir mantiene su estado.

`app/routes/api.ikiform.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { Route } from "./+types/api.ikiform";

export async function action({ request }: Route.ActionArgs) {
  let event;
  try {
    event = await verifyWebhook({
      payload: await request.text(),
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET ?? "",
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const { email, first_name, consent } = event.data.fields;
  if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

  const answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MAILERLITE_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      email,
      fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
      groups: [process.env.MAILERLITE_GROUP_ID],
      status: "unconfirmed",
    }),
  });

  if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!answer.ok) return new Response(`MailerLite answered ${answer.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

Añade `https://your-site.com/api/ikiform` en **Webhooks**, elige el formulario de la newsletter y copia el secreto `whsec_`.

## Honeypot y verificación humana

El input `_gotcha` se reenvía e Ikiform guarda como spam uno relleno, con una respuesta de éxito y sin webhook, así que MailerLite nunca lo ve. La clave secreta se salta la comprobación de Turnstile de Ikiform, y ahora todos los envíos vienen de la IP de tu servidor. Si los bots pasan, verifica un token de Turnstile en la action antes del `fetch`, como muestra la [guía de Turnstile](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md).

## Renderizado y prerenderizado

Puedes prerenderizar los artículos con la opción `prerender` manteniendo `ssr: true`; el servidor sigue ejecutando bajo demanda `/newsletter` y la ruta del webhook. El modo SPA, `ssr: false`, no funciona aquí: la documentación de React Router dice que las actions están prohibidas sin un servidor en tiempo de ejecución, y el build falla si una ruta tiene una. Un sitio React Router totalmente estático debería enviar desde el navegador con `@ikiform/sdk`.

## Despliegue

| Host | Qué hacer |
| --- | --- |
| Vercel | Se despliega sin configuración. Vercel recomienda añadir `vercelPreset()` de `@vercel/react-router/vite` a `presets` en `react-router.config.ts`. Define las variables en **Environment Variables**. |
| Netlify | Instala `@netlify/vite-plugin-react-router` y añádelo a los plugins de Vite; el SSR se ejecuta como Netlify Functions. Define las variables en los ajustes de entorno del sitio. |
| Cloudflare | Parte de la plantilla de React Router para Workers. Lee los secretos con `import { env } from "cloudflare:workers"` en lugar de `process.env`; React Router 8 ya no los pone en `context.cloudflare.env`. Usa un dominio propio para el webhook; Ikiform rechaza `workers.dev`. |

## Pruébalo gratis en localhost

Un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde `localhost`, y el `Origin` reenviado hace que cada suscripción desde el servidor de desarrollo cuente como uno de ellos. Prueba el camino del clic temprano desactivando JavaScript en las herramientas de desarrollo. Ikiform no entrega webhooks a `localhost`; expón el servidor de desarrollo mediante un túnel con URL HTTPS para probar `/api/ikiform`.

## Comprueba que funciona

1. Ejecuta `react-router dev`, abre un artículo y suscríbete desde el footer. Aparece la línea que pide revisar la bandeja de entrada y la URL no cambia.
2. Abre la pestaña de red y vuelve a suscribirte desde una página recién cargada. Hay una petición a `/newsletter` y ninguna petición de loaders después.
3. Desactiva JavaScript y suscríbete. Llegas a `/newsletter` con la misma línea, no a JSON.
4. Abre **Submissions** en el panel. El nuevo envío está ahí con todos los campos que enviaste.
5. Si añadiste un webhook, abre el endpoint en **Webhooks**. La entrega muestra `succeeded` con la respuesta de tu servidor.
6. Abre MailerLite. El suscriptor está en el grupo como no confirmado, y el email de confirmación está en tu bandeja de entrada.

## Errores comunes

| Problema | Qué hacer |
| --- | --- |
| El build falla con un error sobre actions | `ssr` es `false`. Las actions necesitan un servidor en tiempo de ejecución; activa el SSR y usa `prerender` para las páginas estáticas. |
| Cada suscripción recarga los datos de la página | Los loaders se revalidan después de cualquier action. Añade un `shouldRevalidate` que devuelva `false` cuando `formAction` sea `/newsletter`. |
| La clave secreta es `undefined` en Cloudflare | Workers no rellena `process.env` por defecto. Lee el secreto de `env` en `cloudflare:workers` dentro de la action. |
| `402 plan_required` en las pruebas locales | El envío no tenía un origen `localhost`. Revisa la línea de `Origin` en la action y abre la app en `localhost`. |
| 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 en el filtro **Spam** para ver los motivos y márcalo como no spam para enviar su email y sus webhooks. |

## Preguntas

### ¿Funciona useFetcher sin JavaScript?

Sí. fetcher.Form renderiza un formulario HTML normal, así que antes de la hidratación envía y navega a la URL de su action. Haz que esa URL sea una ruta con componente, o el lector verá los datos en bruto de la action.

### ¿Debe ser la action de la newsletter una resource route?

No para un formulario que envían personas. Usa una ruta con un componente por defecto para que el envío sin JavaScript muestre una página. Deja las resource routes para los webhooks y otros llamantes que no son navegadores.

### ¿Por qué añadir los suscriptores a MailerLite desde un webhook en lugar de desde la action?

Por dos motivos. La action recibe la misma respuesta de éxito para el spam que para una suscripción real, así que también añadiría bots. Y un error de MailerLite dentro de la action mostraría un fallo en una suscripción que Ikiform ya guardó, mientras que una entrega de webhook fallida se reintenta.

### ¿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 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.

## Más guías

- [Formulario de suscripción a newsletter](https://ikiform.com/es/guides/newsletter-signup.md): Crea un formulario de suscripción a tu newsletter con tu propio markup, registra el consentimiento, bloquea los registros de bots y pasa cada suscriptor a Kit, MailerLite, beehiiv o cualquier herramienta.
- [MailerLite](https://ikiform.com/es/guides/newsletter-signup/mailerlite.md): Conecta tu propio formulario de suscripción con MailerLite: un relay añade a los lectores nuevos como no confirmados para el doble opt-in y nunca pide a un suscriptor que confirme dos veces.
- [Doble opt-in](https://ikiform.com/es/guides/newsletter-signup/double-opt-in.md): Ikiform no envía emails de confirmación, así que el doble opt-in se hace en tu herramienta de email: un relay añade cada suscripción como no confirmada y la herramienta envía el enlace.
- [Formulario de newsletter en TanStack Start](https://ikiform.com/es/guides/newsletter-signup/tanstack-start.md): Un formulario de newsletter en TanStack Start: una server function para suscribirse sin salir de la página, una server route como alternativa sin JavaScript y las suscripciones reales enviadas a beehiiv.
- [Sitio estático](https://ikiform.com/es/guides/newsletter-signup/static-site.md): Añade un formulario de suscripción a tu newsletter en un sitio estático con Hugo, Jekyll, Eleventy o Astro, deja la API key fuera de la página y confirma en la herramienta.
- [Kit (ConvertKit)](https://ikiform.com/es/guides/newsletter-signup/kit.md): Conecta tu propio formulario de newsletter con Kit mediante un relay que crea el suscriptor, lo añade a un formulario de Kit para el incentive email y etiqueta la ubicación.
