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.
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:
- Antes de la hidratación, fetcher.Form es un formulario normal, así que el navegador envía a /newsletter y navega hasta ahí.
- Si /newsletter es una resource route, el navegador muestra como página el JSON de la action.
- Si /newsletter tiene un componente por defecto, React Router ejecuta la action y luego renderiza la página con el resultado como actionData.
- Una vez cargado JavaScript, las dos versiones se comportan igual: el fetcher envía en segundo plano y el lector no se mueve.
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.
app/
root.tsx
routes.ts
routes/
home.tsx
newsletter.tsx
api.ikiform.ts
components/newsletter-form.tsx
react-router.config.ts
.envimport { 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;IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789Crea 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
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 estado4xx, 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
Originreenvía el origen del navegador, porque Ikiform reconoce por esa cabecera las pruebas gratuitas desdelocalhosty elfetchde 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.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.El formulario del footer
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.datacontiene la respuesta de la action tras un envío en segundo plano;initialla contiene tras un POST de documento. Gana el que exista.fetcher.stateessubmittingy luegoloadingantes de volver aidle, así que el botón sigue desactivado en ambos.- Una
keypor ubicación da al formulario del footer y al de la página/newsletterestados separados cuando están los dos en pantalla.
Móntalo sin recargar los datos de la página
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.
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
| 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
- 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. - Abre la pestaña de red y vuelve a suscribirte desde una página recién cargada. Hay una petición a
/newslettery ninguna petición de loaders después. - Desactiva JavaScript y suscríbete. Llegas a
/newslettercon la misma línea, no a JSON. - Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que enviaste.
- Si añadiste un webhook, abre el endpoint en Webhooks. La entrega muestra
succeededcon la respuesta de tu servidor. - 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
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.
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 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.
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.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.