Formulario de suscripción a newsletter

Formulario de suscripción a newsletter8 min de lectura

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.

Preet SutharFundador, Ikiform

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

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:

Qué ve un lector sin JavaScript después de enviar el formulario del footeraction en una resource routeaction en una ruta de UIformulariodel footerfetcher.Form, sinhidratar/newsletteraction, sincomponenteJSON enbruto{"ok":true}formulariodel footerfetcher.Form, sinhidratar/newsletteraction +componente pordefectopágina deresultadoactionDatarenderizadoPOST de documentorespuestaPOST de documentorenderizarQué ve un lector sin JavaScript después de enviar el formulario del footeraction en una resource routeaction en una ruta de UIformulario del footerfetcher.Form, sin hidratar/newsletteraction, sin componenteJSON en bruto{"ok":true}formulario del footerfetcher.Form, sin hidratar/newsletteraction + componente por defectopágina de resultadoactionData renderizadoPOST de documentorespuestaPOST de documentorenderizar
  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
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
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
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 en la pestaña Fields del formulario.

Constrúyelo

La ruta /newsletter

app/routes/newsletter.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 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
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
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: primero busca la dirección, así que un lector confirmado que se vuelve a suscribir mantiene su estado.

app/routes/api.ikiform.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.

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

HostQué hacer
VercelSe 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.
NetlifyInstala @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.
CloudflareParte 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

ProblemaQué hacer
El build falla con un error sobre actionsssr 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áginaLos 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 CloudflareWorkers no rellena process.env por defecto. Lee el secreto de env en cloudflare:workers dentro de la action.
402 plan_required en las pruebas localesEl 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 webhookSe 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

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