Formulaire d'inscription à la newsletter

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.

Preet SutharFondateur, Ikiform

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 :

Ce que voit un lecteur sans JavaScript après avoir envoyé le formulaire du pied de pageaction dans une resource routeaction dans une route d'interfaceformulaire depied de pagefetcher.Form, nonhydraté/newsletteraction, sanscomposantJSON brut{"ok":true}formulaire depied de pagefetcher.Form, nonhydraté/newsletteraction +composant pardéfautpage derésultatactionDataaffichéPOST de documentréponsePOST de documentrenduCe que voit un lecteur sans JavaScript après avoir envoyé le formulaire du pied de pageaction dans une resource routeaction dans une route d'interfaceformulaire de pied de pagefetcher.Form, non hydraté/newsletteraction, sans composantJSON brut{"ok":true}formulaire de pied de pagefetcher.Form, non hydraté/newsletteraction + composant par défautpage de résultatactionData affichéPOST de documentréponsePOST de documentrendu
  1. Avant l'hydratation, fetcher.Form est un formulaire classique, donc le navigateur envoie à /newsletter et y navigue.
  2. Si /newsletter est une resource route, le navigateur affiche le JSON de l'action comme page.
  3. Si /newsletter a un composant par défaut, React Router exécute l'action, puis rend la page avec le résultat dans actionData.
  4. Une fois JavaScript chargé, les deux versions se comportent pareil : le fetcher envoie en arrière-plan et le lecteur ne bouge pas.
La correction tient en un export par défaut. L'action et le formulaire du pied de page restent identiques.

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.

Arborescence du projet
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

Cré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

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>
  );
}
  • Les erreurs sont renvoyées avec data() et un statut 4xx, 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 Origin transmet l'origine du navigateur, car c'est grâce à cet en-tête qu'Ikiform reconnaît les tests gratuits depuis localhost, et le fetch d'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.
Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore Allowed domains et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la limite de débit Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.

Le formulaire du pied de page

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 contient la réponse de l'action après un envoi en arrière-plan ; initial la contient après un POST de document. Celle qui existe l'emporte.
  • fetcher.state passe par submitting puis loading avant de revenir à idle, donc le bouton reste désactivé pendant les deux.
  • Une key par emplacement donne au formulaire du pied de page et à celui de la page /newsletter des états séparés quand les deux sont à l'écran.

L'intégrer sans recharger les données de la page

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>
    </>
  );
}

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.

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 });
}

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ébergeurQue faire
VercelSe 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.
NetlifyInstallez @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.
CloudflarePartez 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

  1. 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.
  2. Ouvrez l'onglet réseau et abonnez-vous à nouveau depuis une page fraîche. Il y a une seule requête vers /newsletter et aucune requête de loader après.
  3. Désactivez JavaScript et abonnez-vous. Vous arrivez sur /newsletter avec la même ligne, pas sur du JSON.
  4. Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
  5. Si vous avez ajouté un webhook, ouvrez l'endpoint dans Webhooks. La livraison affiche succeeded avec la réponse de votre serveur.
  6. 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èmeQue faire
Le build échoue avec une erreur sur les actionsssr 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 pageLes loaders se revalident après chaque action. Ajoutez un shouldRevalidate qui renvoie false quand formAction vaut /newsletter.
La clé secrète vaut undefined sur CloudflareWorkers 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 locauxL'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

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.