# Créer un formulaire d'inscription à la newsletter avec TanStack Start

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

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

Écrivez la logique d'inscription une fois dans un module serveur, puis appelez-la depuis deux endroits : une fonction POST `createServerFn` que le formulaire appelle dans la page, et une server route vers laquelle le même formulaire envoie quand JavaScript n'est pas chargé. Les deux vérifient la case de consentement et transmettent l'inscription à votre backend de formulaires avec une clé secrète. Une seconde server route reçoit le webhook signé du backend et crée l'abonnement dans beehiiv.

L'[onglet TanStack Start du guide principal](https://ikiform.com/fr/guides/newsletter-signup.md#form) se compose d'une server function et d'un composant. Cela couvre les lecteurs dont le JavaScript est chargé. Cette page ajoute ce dont a besoin un site largement partagé : un chemin pour le lecteur qui envoie avant l'hydratation, une page de résultat pour ce chemin, et une route de webhook qui transmet les vraies inscriptions à beehiiv. La configuration du produit est dans le [guide côté serveur de la documentation](https://ikiform.com/fr/docs/guides/server.md).

## Ce que change TanStack Start

Une server function est appelée par une requête RPC que fait le client : un formulaire qui ne connaît que `onSubmit` ne fait donc rien tant que le bundle ne s'exécute pas. La documentation de TanStack mentionne une propriété `.url` pour envoyer un formulaire vers une server function sans JavaScript, mais ne documente pas comment cette réponse s'affiche. Une server route est plus simple : elle accepte un envoi de formulaire normal et renvoie une redirection normale. Le formulaire reçoit donc une vraie `action` en plus de son `onSubmit` :

Schéma : Comment un formulaire TanStack Start atteint le même code d'inscription avec ou sans JavaScript

1. Le lecteur envoie le formulaire. Le chargement ou non de JavaScript décide du chemin.
2. Après hydratation : onSubmit bloque l'envoi et appelle la server function avec les données du formulaire.
3. La server function exécute forwardSignup, qui vérifie le consentement et envoie à Ikiform, puis renvoie le résultat au composant.
4. Sans hydratation : le navigateur envoie à la route serveur /api/newsletter, qui exécute le même forwardSignup.
5. La route répond 303 avec un statut ou un code d'erreur, et la page /newsletter affiche le message correspondant.

forwardSignup est le seul code qui parle à Ikiform, donc les deux chemins valident et signalent les erreurs de la même façon.

## Mise en place du projet

`Arborescence du projet`:

```text
src/
  server/newsletter.ts
  components/NewsletterForm.tsx
  routes/
    __root.tsx
    newsletter.tsx
    api/newsletter.ts
    api/ikiform.ts
.env
```

Le code serveur lit les secrets depuis `process.env`. Ne leur donnez jamais le préfixe `VITE_` : Vite place les variables `VITE_` dans le bundle client. 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.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
BEEHIIV_API_KEY=your_beehiiv_api_key
BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000
```

## La construire

### Le module d'inscription

`src/server/newsletter.ts`:

```ts
export interface Signup {
  email: string;
  first_name?: string;
  consent: boolean;
  source: string;
  _gotcha: string;
}

export type Outcome = { ok: true } | { ok: false; field: "email" | "consent" | "failed"; message: string };

const PLACEMENTS = new Set(["footer", "post", "newsletter-page"]);

export function readSignup(form: FormData): Signup {
  const source = String(form.get("source") ?? "");
  return {
    email: String(form.get("email") ?? "").trim(),
    first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
    consent: form.get("consent") === "true",
    source: PLACEMENTS.has(source) ? source : "footer",
    _gotcha: String(form.get("_gotcha") ?? ""),
  };
}

export async function forwardSignup(signup: Signup, origin: string | undefined): Promise<Outcome> {
  if (!signup.consent) {
    return { ok: false, field: "consent", message: "Tick the box so we know you want the emails." };
  }
  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 ? { Origin: origin } : {}),
    },
    body: JSON.stringify({ ...signup, consent: true }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true };
  const email = result?.error?.fields?.email?.[0];
  if (email) return { ok: false, field: "email", message: email };
  return { ok: false, field: "failed", message: result?.error?.message ?? "We couldn't reach the list. Try again." };
}
```

- `readSignup` transforme `FormData` en objet simple. Les deux points d'entrée l'utilisent, donc chaque nom de champ n'est défini qu'à un seul endroit.
- `source` doit être l'un des trois emplacements, pour qu'un envoi forgé ne puisse pas en inventer d'autres.
- 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.
- Chaque échec renvoie un `Outcome` au lieu de lever une exception, pour que le composant et la route puissent tous deux l'afficher.

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

### La server function et le formulaire

`src/components/NewsletterForm.tsx`:

```tsx
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { forwardSignup, type Outcome, readSignup } from "../server/newsletter";

const subscribe = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return readSignup(data);
  })
  .handler(({ data }) => forwardSignup(data, getRequestHeader("origin")));

export function NewsletterForm({ source = "footer" }: { source?: string }) {
  const send = useServerFn(subscribe);
  const [outcome, setOutcome] = useState<Outcome | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setPending(true);
    try {
      setOutcome(await send({ data }));
    } catch {
      setOutcome({ ok: false, field: "failed", message: "Couldn't subscribe. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (outcome?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  const error = outcome?.ok === false ? outcome : null;

  return (
    <form method="post" action="/api/newsletter" onSubmit={onSubmit}>
      <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={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
    </form>
  );
}
```

- `.validator` reçoit tout ce que le client a envoyé. En y vérifiant la présence de `FormData`, un appel JSON forgé échoue avant l'exécution du handler.
- `getRequestHeader` vient de `@tanstack/react-start/server` et lit la requête qui a porté l'appel RPC, que le navigateur a envoyée avec l'origine de votre page.
- Le `FormData` est construit avant le premier `await`, tant que `event.currentTarget` pointe encore vers le formulaire.
- `useServerFn` permet à l'appel de suivre les redirections et les erreurs à travers le routeur, ce qui compte si vous levez plus tard `redirect()` depuis le handler.

### La route et la page sans JavaScript

`src/routes/api/newsletter.ts`:

```ts
import { createFileRoute } from "@tanstack/react-router";
import { forwardSignup, readSignup } from "../../server/newsletter";

export const Route = createFileRoute("/api/newsletter")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const signup = readSignup(await request.formData());
        const outcome = await forwardSignup(signup, request.headers.get("origin") ?? undefined);
        const target = new URL("/newsletter", request.url);
        target.searchParams.set(outcome.ok ? "status" : "error", outcome.ok ? "subscribed" : outcome.field);
        return Response.redirect(target, 303);
      },
    },
  },
});
```

`src/routes/newsletter.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { NewsletterForm } from "../components/NewsletterForm";

const MESSAGES = {
  consent: "Tick the box so we know you want the emails.",
  email: "Check the email address and try again.",
  failed: "We couldn't reach the list. Try again.",
};

export const Route = createFileRoute("/newsletter")({
  validateSearch: (search: Record<string, unknown>) => ({
    status: search.status === "subscribed" ? ("subscribed" as const) : undefined,
    error: typeof search.error === "string" && search.error in MESSAGES ? (search.error as keyof typeof MESSAGES) : undefined,
  }),
  component: NewsletterPage,
});

function NewsletterPage() {
  const { status, error } = Route.useSearch();
  if (status === "subscribed") return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  return (
    <main>
      <h1>The newsletter</h1>
      {error ? <p role="alert">{MESSAGES[error]}</p> : null}
      <NewsletterForm source="newsletter-page" />
    </main>
  );
}
```

La route redirige avec `303`, donc recharger la page de résultat ne renvoie pas le formulaire. Elle transmet un code d'erreur, jamais le message d'Ikiform, et `validateSearch` écarte tout ce qui sort des trois codes, pour que personne ne puisse afficher son propre texte sur votre page via un lien.

## Transférer les abonnés vers beehiiv

Cette route serveur reçoit le webhook signé d'Ikiform et crée l'abonnement beehiiv avec `double_opt_override: "on"`, pour que beehiiv envoie l'e-mail de confirmation. Elle lit le corps avec `request.text()` avant toute autre chose, car la signature couvre les octets exacts. Le [guide beehiiv](https://ikiform.com/fr/guides/newsletter-signup/beehiiv.md) ajoute le prénom, le référent et les en-têtes de limite de débit de beehiiv.

`src/routes/api/ikiform.ts`:

```ts
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const Route = createFileRoute("/api/ikiform")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        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, consent, source } = event.data.fields;
        if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

        const answer = await fetch(
          `https://api.beehiiv.com/v2/publications/${process.env.BEEHIIV_PUBLICATION_ID}/subscriptions`,
          {
            method: "POST",
            headers: { Authorization: `Bearer ${process.env.BEEHIIV_API_KEY}`, "Content-Type": "application/json" },
            body: JSON.stringify({
              email,
              double_opt_override: "on",
              reactivate_existing: false,
              utm_source: typeof source === "string" ? source : "website",
              utm_medium: "signup_form",
            }),
          },
        );
        if (answer.status === 429) return new Response("beehiiv rate limit", { status: 503, headers: { "retry-after": "60" } });
        if (!answer.ok) return new Response(`beehiiv 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_` dans `IKIFORM_WEBHOOK_SECRET`.

## Honeypot et vérification humaine

L'input `_gotcha` accompagne l'inscription, et Ikiform enregistre un champ rempli comme spam avec une réponse de succès et sans webhook, donc beehiiv ne le voit jamais. La clé secrète contourne la vérification Turnstile d'Ikiform. Si des bots passent le honeypot et la case obligatoire, vérifiez un jeton Turnstile dans `forwardSignup`, comme le montre le [guide Turnstile](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md), pour que les deux points d'entrée en profitent.

## Rendu

Start rend les routes sur le serveur par défaut, donc le HTML du formulaire, `action` compris, figure dans la première réponse. Si vous prérendez des pages en fichiers statiques, le formulaire y fonctionne quand même : la server function et les deux routes serveur s'exécutent à la demande chez l'hébergeur.

## Déployer

| Hébergeur | Que faire |
| --- | --- |
| Vercel | Ajoutez Nitro : installez `nitro` et placez `nitro()` depuis `nitro/vite` dans les plugins Vite. Définissez les quatre variables sous **Environment Variables**. |
| Netlify | Ajoutez `@netlify/vite-plugin-tanstack-start` et placez `netlify()` dans les plugins Vite. Définissez les variables dans les paramètres d'environnement du site. |
| Cloudflare | Ajoutez `@cloudflare/vite-plugin` en premier dans les plugins, avec un `wrangler.jsonc` qui active `nodejs_compat`. Avec ce flag et une date de compatibilité récente, Workers expose les variables et les secrets sur `process.env`. Utilisez un domaine personnalisé pour l'URL du 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 les deux chemins : une fois normalement, et une fois avec JavaScript désactivé dans les outils de développement. Ikiform ne livre pas de webhooks à `localhost`, donc testez `/api/ikiform` via un tunnel avec une URL HTTPS.

## Vérifiez que tout fonctionne

1. Lancez le serveur de développement et abonnez-vous depuis le pied de page. Le formulaire est remplacé par la ligne vous invitant à consulter votre boîte de réception et l'URL ne change pas.
2. Désactivez JavaScript et abonnez-vous à nouveau. Vous arrivez sur `/newsletter?status=subscribed`.
3. Avec JavaScript toujours désactivé, envoyez sans `required` et avec la case décochée. Vous arrivez sur `/newsletter?error=consent` avec le message de consentement.
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 beehiiv. L'abonnement y figure, en attente jusqu'à votre confirmation, et l'e-mail de confirmation est dans votre boîte de réception.

## Pièges

| Problème | Que faire |
| --- | --- |
| La clé secrète vaut `undefined` en production | L'hébergeur n'a pas reçu la variable, ou elle a été lue dans du code qui s'exécute dans le navigateur. Ne lisez les secrets que dans les server functions et les routes serveur. |
| Une page d'erreur JSON après un envoi sans JavaScript | L'`action` du formulaire pointe vers la server function au lieu de la route serveur. Envoyez vers `/api/newsletter`. |
| `402 plan_required` sur les tests locaux | L'envoi a atteint Ikiform sans origine `localhost`. Vérifiez que `forwardSignup` reçoit l'origine depuis les deux appelants. |
| Les livraisons de webhook échouent avec `400` | Le secret ne correspond pas à celui de l'endpoint, ou quelque chose a analysé le corps avant `request.text()`. Lisez d'abord le texte brut. |
| 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

### Les server functions de TanStack Start fonctionnent-elles sans JavaScript ?

La documentation de TanStack mentionne une propriété .url pour envoyer un formulaire à une server function, mais une route serveur est la solution de repli la plus simple : elle accepte un POST de formulaire classique et redirige. Cette page utilise les deux, avec un seul module partagé derrière.

### Où placer les secrets dans TanStack Start ?

Dans des variables d'environnement réservées au serveur, lues avec process.env dans les server functions et les routes serveur. Les variables avec le préfixe VITE_ finissent dans le bundle du navigateur.

### Pourquoi ne pas ajouter l'abonné à beehiiv dans la server function ?

Ikiform répond au spam avec le même succès qu'à une vraie inscription. Son webhook n'est envoyé que pour les inscriptions qui ne sont pas du spam, donc créer l'abonnement depuis le webhook tient les bots à l'écart de votre liste.

### 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.
- [beehiiv](https://ikiform.com/fr/guides/newsletter-signup/beehiiv.md): Reliez votre propre formulaire d'inscription à beehiiv : un relais crée chaque abonnement avec le double opt-in activé, l'emplacement comme source UTM et la page comme referrer.
- [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 React Router](https://ikiform.com/fr/guides/newsletter-signup/react-router.md): 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.
- [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.
