Formulaire d'inscription à la newsletter8 min de lecture
Créer un formulaire d'inscription à une newsletter avec React Router
Un formulaire de newsletter en pied de page avec React Router : useFetcher envoie à une route /newsletter qui fonctionne aussi sans JavaScript, et une resource route alimente MailerLite.
Placez l'action sur une vraie route /newsletter avec sa propre page, et envoyez-y les données depuis un formulaire de pied de page construit avec useFetcher. Avec JavaScript, le fetcher envoie en arrière-plan et le lecteur reste sur l'article ; sans JavaScript, le même formulaire est un POST classique qui arrive sur la page /newsletter avec le résultat. L'action vérifie le consentement et transmet l'inscription à votre backend de formulaires, et une resource route transforme son webhook signé en abonné MailerLite.
L'onglet React Router du guide principal envoie à une resource route avec useFetcher. Cela fonctionne une fois JavaScript chargé. Cette page règle ce qui se passe avant, empêche une inscription de recharger tous les loaders de la page et ajoute la route de webhook pour MailerLite. La configuration du produit se trouve dans le guide de la documentation côté serveur.
Pourquoi l'action a besoin d'une page
La documentation de React Router décrit fetcher.Form comme un formulaire HTML normal tant que JavaScript n'est pas chargé. Un envoi à ce moment-là est un POST de document vers l'URL action du formulaire, et le navigateur affiche ce que cette route rend. Une resource route ne rend rien ; elle renvoie ses données. Le lecteur qui clique trop tôt tombe donc sur du JSON brut. Donnez à l'action une route avec un composant par défaut et le même POST arrive sur une page :
- Avant l'hydratation, fetcher.Form est un formulaire classique, donc le navigateur envoie à /newsletter et y navigue.
- Si /newsletter est une resource route, le navigateur affiche le JSON de l'action comme page.
- Si /newsletter a un composant par défaut, React Router exécute l'action, puis rend la page avec le résultat dans actionData.
- Une fois JavaScript chargé, les deux versions se comportent pareil : le fetcher envoie en arrière-plan et le lecteur ne bouge pas.
Mise en place du projet
Ce guide utilise le framework mode avec le rendu serveur activé, ssr: true dans react-router.config.ts, qui est la valeur par défaut.
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=123456789Créez la clé Ikiform sous API keys avec le scope submissions:write et collez la définition des champs du guide principal dans l'onglet Fields du formulaire.
La construire
La route /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>
);
}- Les erreurs sont renvoyées avec
data()et un statut4xx, pas levées, pour qu'elles atteignent le formulaire au lieu de l'error boundary. - La vérification du consentement s'exécute avant l'appel réseau. Une case manquante ne coûte rien.
- La ligne
Origintransmet l'origine du navigateur, car c'est grâce à cet en-tête qu'Ikiform reconnaît les tests gratuits depuislocalhost, et lefetchd'un serveur n'en envoie aucun. - Le composant par défaut affiche le même formulaire, alimenté par
actionData, donc le résultat sans JavaScript et le résultat sur la page se ressemblent.
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Le formulaire du pied de page
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.datacontient la réponse de l'action après un envoi en arrière-plan ;initialla contient après un POST de document. Celle qui existe l'emporte.fetcher.statepasse parsubmittingpuisloadingavant de revenir àidle, donc le bouton reste désactivé pendant les deux.- Une
keypar emplacement donne au formulaire du pied de page et à celui de la page/newsletterdes états séparés quand les deux sont à l'écran.
L'intégrer sans recharger les données de la page
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>
</>
);
}Après l'envoi d'un fetcher à une action, React Router revalide par défaut les loaders de toutes les routes correspondantes. Une inscription à la newsletter ne change rien à ce qu'ils chargent, donc shouldRevalidate sur la racine évite ce travail quand l'envoi est allé à /newsletter. Ajoutez le même export à toute route dont le loader est coûteux. Le formulaire va dans le composant par défaut App plutôt que dans Layout, car Layout enveloppe aussi l'error boundary.
Transmettre les abonnés à MailerLite
Les webhooks viennent de l'extérieur de votre app, donc celui-ci est une resource route : une action sans composant, qui renvoie de simples objets Response, comme le conseille la documentation de React Router pour les appelants externes. Elle lit d'abord le corps comme texte, car la signature couvre les octets exacts. Elle envoie chaque inscription comme unconfirmed, ce qui convient à une nouvelle liste. Si votre liste compte déjà des lecteurs confirmés, utilisez plutôt le relais du guide MailerLite : il recherche d'abord l'adresse, pour qu'un lecteur confirmé qui s'inscrit à nouveau garde son statut.
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 });
}Ajoutez https://your-site.com/api/ikiform sous Webhooks, choisissez le formulaire de newsletter et copiez le secret whsec_.
Honeypot et vérification humaine
L'input _gotcha est transmis et Ikiform enregistre un champ rempli comme spam, avec une réponse de succès et sans webhook, donc MailerLite ne le voit jamais. La clé secrète contourne la vérification Turnstile d'Ikiform, et chaque envoi vient désormais de l'IP de votre serveur. Si des bots passent, vérifiez un jeton Turnstile dans l'action avant le fetch, comme le montre le guide Turnstile.
Rendu et prérendu
Vous pouvez prérendre les articles avec l'option prerender tout en gardant ssr: true ; le serveur exécute toujours /newsletter et la route de webhook à la demande. Le mode SPA, ssr: false, ne fonctionne pas ici : la documentation de React Router indique que les actions sont interdites sans serveur d'exécution, et le build échoue si une route en a une. Un site React Router entièrement statique doit plutôt envoyer depuis le navigateur avec @ikiform/sdk.
Déployer
| Hébergeur | Que faire |
|---|---|
| Vercel | Se déploie sans aucune configuration. Vercel recommande d'ajouter vercelPreset() depuis @vercel/react-router/vite à presets dans react-router.config.ts. Définissez les variables sous Environment Variables. |
| Netlify | Installez @netlify/vite-plugin-react-router et ajoutez-le aux plugins Vite ; le SSR s'exécute en Netlify Functions. Définissez les variables dans les paramètres d'environnement du site. |
| Cloudflare | Partez du modèle React Router pour Workers. Lisez les secrets avec import { env } from "cloudflare:workers" au lieu de process.env ; React Router 8 ne les place plus sur context.cloudflare.env. Utilisez un domaine personnalisé pour le webhook ; Ikiform refuse workers.dev. |
Tester gratuitement sur localhost
Un espace de travail sans offre accepte 50 soumissions de test par mois depuis localhost, et l'en-tête Origin transmis fait de chaque inscription depuis le serveur de développement l'une d'elles. Testez le chemin du clic précoce en désactivant JavaScript dans les outils de développement. Ikiform ne livre pas de webhooks à localhost ; exposez le serveur de développement via un tunnel avec une URL HTTPS pour tester /api/ikiform.
Vérifiez que tout fonctionne
- Lancez
react-router dev, ouvrez un article et abonnez-vous depuis le pied de page. La ligne vous invitant à consulter votre boîte de réception apparaît et l'URL ne change pas. - Ouvrez l'onglet réseau et abonnez-vous à nouveau depuis une page fraîche. Il y a une seule requête vers
/newsletteret aucune requête de loader après. - Désactivez JavaScript et abonnez-vous. Vous arrivez sur
/newsletteravec la même ligne, pas sur du JSON. - Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
- Si vous avez ajouté un webhook, ouvrez l'endpoint dans Webhooks. La livraison affiche
succeededavec la réponse de votre serveur. - Ouvrez MailerLite. L'abonné est dans le groupe en non confirmé, et l'e-mail de confirmation est dans votre boîte.
Pièges
| Problème | Que faire |
|---|---|
| Le build échoue avec une erreur sur les actions | ssr vaut false. Les actions ont besoin d'un serveur d'exécution ; activez le SSR et utilisez prerender pour les pages statiques. |
| Chaque inscription recharge les données de la page | Les loaders se revalident après chaque action. Ajoutez un shouldRevalidate qui renvoie false quand formAction vaut /newsletter. |
La clé secrète vaut undefined sur Cloudflare | Workers ne remplit pas process.env par défaut. Lisez le secret depuis env dans cloudflare:workers au sein de l'action. |
402 plan_required sur les tests locaux | L'envoi n'avait pas d'origine localhost. Vérifiez la ligne Origin dans l'action, et ouvrez l'app sur localhost. |
| La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivé | Elle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre Spam pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks. |
Questions
Oui. fetcher.Form rend un formulaire HTML normal, donc avant l'hydratation il envoie et navigue vers l'URL de son action. Faites de cette URL une route avec un composant, sinon le lecteur voit les données brutes de l'action.
Pas pour un formulaire que des personnes envoient. Utilisez une route avec un composant par défaut pour que l'envoi sans JavaScript affiche une page. Gardez les resource routes pour les webhooks et les autres appelants qui ne sont pas des navigateurs.
Pour deux raisons. L'action reçoit la même réponse de succès pour le spam que pour une vraie inscription, elle ajouterait donc aussi les bots. Et une erreur MailerLite dans l'action afficherait un échec pour une inscription qu'Ikiform a déjà enregistrée, alors qu'une livraison de webhook échouée est retentée.
Non, il n'y a pas d'offre gratuite. Les tests sont gratuits : vous pouvez envoyer 50 soumissions de test par mois depuis localhost avant de payer. Pour passer en production, Pro commence à 6 $/mois pour 500 soumissions, ou vous pouvez payer une seule fois 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.