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

Publié le 2026-10-25 · https://ikiform.com/fr/guides/newsletter-signup/react-router

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](https://ikiform.com/fr/guides/newsletter-signup.md#form) 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](https://ikiform.com/fr/docs/guides/server.md).

## 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 :

Schéma : Ce que voit un lecteur sans JavaScript après avoir envoyé le formulaire du pied de page

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`:

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

Créez la clé Ikiform sous **API keys** avec le scope `submissions:write` et collez la [définition des champs du guide principal](https://ikiform.com/fr/guides/newsletter-signup.md#fields) dans l'onglet **Fields** du formulaire.

## La construire

### La route /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>
  );
}
```

- 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](https://ikiform.com/fr/docs/rate-limits.md) 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`:

```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`:

```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](https://ikiform.com/fr/guides/newsletter-signup/mailerlite.md) : il recherche d'abord l'adresse, pour qu'un lecteur confirmé qui s'inscrit à nouveau garde son statut.

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

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](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md).

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

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

### useFetcher fonctionne-t-il sans JavaScript ?

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.

### L'action de newsletter doit-elle être une resource route ?

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.

### Pourquoi ajouter les abonnés à MailerLite depuis un webhook plutôt que depuis l'action ?

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.

### Ikiform est-il gratuit ?

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.

## Autres guides

- [Formulaire d'inscription à la newsletter](https://ikiform.com/fr/guides/newsletter-signup.md): Créez un formulaire d'inscription à la newsletter avec votre propre markup, enregistrez le consentement, bloquez les inscriptions de bots et transmettez chaque abonné à Kit, MailerLite, beehiiv ou n'importe quel outil.
- [MailerLite](https://ikiform.com/fr/guides/newsletter-signup/mailerlite.md): Reliez votre propre formulaire d'inscription à MailerLite : un relais ajoute les nouveaux lecteurs comme non confirmés pour le double opt-in, sans jamais demander deux fois à un abonné de confirmer.
- [Double opt-in](https://ikiform.com/fr/guides/newsletter-signup/double-opt-in.md): Ikiform n'envoie pas d'e-mails de confirmation : le double opt-in se fait donc dans votre outil d'e-mailing. Un relais ajoute chaque inscription comme non confirmée et l'outil envoie le lien.
- [Formulaire de newsletter TanStack Start](https://ikiform.com/fr/guides/newsletter-signup/tanstack-start.md): Un formulaire de newsletter TanStack Start : une server function pour les inscriptions dans la page, une server route en repli sans JavaScript, et les vraies inscriptions envoyées à beehiiv.
- [Site statique](https://ikiform.com/fr/guides/newsletter-signup/static-site.md): Ajoutez un formulaire d'inscription à la newsletter à un site statique Hugo, Jekyll, Eleventy ou Astro, gardez la clé d'API de l'outil d'e-mailing hors de la page et confirmez dans l'outil.
- [Kit (ConvertKit)](https://ikiform.com/fr/guides/newsletter-signup/kit.md): Reliez votre propre formulaire de newsletter à Kit avec un relais qui crée l'abonné, l'ajoute à un formulaire Kit pour l'incentive email et étiquette l'emplacement.
