# Comment fonctionne l'envoi d'un formulaire HTML

> Ce que fait un navigateur quand un formulaire est envoyé : quels champs il transmet, comment il les encode, ce que le serveur doit répondre, et comment Ikiform répond.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/html-form-submission

Quand quelqu'un appuie sur le bouton d'envoi, le navigateur vérifie les contraintes du formulaire, rassemble chaque contrôle nommé et activé, les encode selon l'`enctype` du formulaire et les envoie à l'URL `action` avec la `method` du formulaire. Le serveur répond comme pour n'importe quel chargement de page, et un serveur bien conçu répond à un POST réussi par une redirection `303`, pour qu'un rafraîchissement n'envoie pas le formulaire deux fois. Faites pointer `action` vers un backend de formulaires comme Ikiform et vous obtenez cette redirection, la validation des champs et le filtrage du spam sans serveur à vous.

Les formulaires HTML sont plus anciens que JavaScript et restent le moyen le plus fiable d'envoyer des données depuis une page web. Ils fonctionnent avec les scripts désactivés, sur des téléphones lents et dans tous les créateurs de sites qui vous laissent coller du balisage. La plupart des bugs de formulaire viennent d'une méconnaissance de ce que fait le navigateur de lui-même, donc ce guide décrit un envoi étape par étape, puis montre où s'insère un backend de formulaires.

## Ce qui se passe quand vous appuyez sur envoyer

Un envoi est une navigation normale accompagnée d'un corps. Le navigateur fait quatre choses avant que quoi que ce soit ne quitte la page, puis confie la requête au réseau et charge ce qui revient.

Schéma : Ce que fait un navigateur entre le clic sur envoyer et la page suivante

1. Un bouton d'envoi, la touche Entrée dans un champ texte ou form.requestSubmit() démarre l'envoi et déclenche un événement submit.
2. Sauf si le formulaire a novalidate, le navigateur vérifie les contraintes de chaque contrôle. Le premier contrôle invalide reçoit un message et rien n'est envoyé.
3. Le navigateur construit le jeu de données du formulaire à partir de chaque contrôle qui a un nom et n'est pas désactivé.
4. Il encode ce jeu selon l'enctype du formulaire, en urlencoded sauf indication contraire.
5. Il navigue vers l'URL action avec la méthode du formulaire, en envoyant le corps.
6. Le serveur répond. Un 303 indique au navigateur de charger une autre URL en GET, qui est la page sur laquelle le visiteur arrive.

Tout ce qui est au-dessus de la ligne se passe sans aucun réseau. Un écouteur de l'événement submit peut arrêter le processus à la première étape et prendre le relais avec fetch.

## Les attributs qui déterminent la requête

Cinq attributs de `<form>` déterminent où vont les données et comment. Chacun peut être remplacé pour un seul bouton avec l'attribut `form*` correspondant : c'est ainsi qu'un formulaire peut avoir des boutons **Send** et **Save draft** au comportement différent.

| Attribut | Par défaut | Ce qu'il fait | Remplacement par bouton |
| --- | --- | --- | --- |
| `action` | L'URL de la page elle-même | L'URL qui reçoit les données. Les valeurs relatives sont résolues par rapport à la page. Voir [l'attribut action](https://ikiform.com/fr/guides/html-form-submission/action-attribute.md) | `formaction` |
| `method` | `get` | `get` place les champs dans les paramètres d'URL. `post` les envoie comme corps. `dialog` ferme un `<dialog>` sans rien envoyer | `formmethod` |
| `enctype` | `application/x-www-form-urlencoded` | La façon dont un corps `post` est encodé. `multipart/form-data` est nécessaire pour les fichiers. `text/plain` est destiné au débogage | `formenctype` |
| `novalidate` | Désactivée | Ignore la vérification des contraintes du navigateur à l'envoi | `formnovalidate` |
| `target` | `_self` | L'endroit où la réponse se charge. `_blank` ouvre un nouvel onglet, une iframe nommée garde la page en place | `formtarget` |

Deux boutons, deux comportements. Le second ignore la validation, et le `name` et la `value` du bouton cliqué sont ajoutés aux données, pour que le serveur sache lequel a été pressé :

`Deux boutons d'envoi`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <input name="email" type="email" required />
  <textarea name="details" required></textarea>
  <button name="intent" value="report">Send report</button>
  <button name="intent" value="draft" formnovalidate>Save as draft</button>
</form>
```

### Quels contrôles sont envoyés

Les navigateurs n'envoient que ce que la spécification HTML appelle le jeu de données du formulaire. La plupart des surprises dans un journal de soumissions viennent de ces règles :

- Un contrôle a besoin d'un `name`. Un `id` seul n'envoie rien.
- Les contrôles `disabled` sont ignorés. Utilisez `readonly` si vous voulez une valeur affichée, verrouillée et envoyée.
- Une case à cocher ou un bouton radio non coché n'envoie rien du tout, pas `false`. Une case cochée sans `value` envoie `on`.
- Seul le bouton qui a envoyé le formulaire est inclus, avec son `name` et sa `value`.
- `<select multiple>` et les groupes de cases à cocher qui partagent un nom envoient une paire par valeur sélectionnée.
- Les inputs en dehors de l'élément `<form>` peuvent le rejoindre avec un attribut `form` qui désigne l'`id` du formulaire.

### Comment le corps est encodé

Avec l'`enctype` par défaut, le corps est une seule ligne de paires `name=value` jointes par `&`, avec les espaces en `+` et tout ce qui sort d'un ensemble sûr encodé en pourcentage. Voici la vraie requête du formulaire de signalement plus bas, telle que le navigateur l'envoie :

`application/x-www-form-urlencoded`:

```http
POST /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Host: ikiform.com
Content-Type: application/x-www-form-urlencoded
Origin: https://example.com
Sec-Fetch-Mode: navigate

email=ada%40example.com&page=https%3A%2F%2Fexample.com%2Fpricing&severity=broken&details=The+annual+toggle+does+nothing&_redirect=https%3A%2F%2Fexample.com%2Freport-sent&_gotcha=
```

`multipart/form-data` enveloppe chaque champ dans sa propre partie avec une chaîne de délimitation choisie par le navigateur. C'est plus volumineux, mais cela peut transporter des fichiers. Ikiform lit les deux. `text/plain` n'a aucun moyen fiable d'indiquer où se termine une valeur, donc Ikiform le refuse avec `415 unsupported_media_type`.

`multipart/form-data`:

```http
POST /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Host: ikiform.com
Content-Type: multipart/form-data; boundary=----formdata7f3a

------formdata7f3a
Content-Disposition: form-data; name="email"

ada@example.com
------formdata7f3a
Content-Disposition: form-data; name="severity"

broken
------formdata7f3a--
```

> Quel que soit l'encodage, gardez la page en UTF-8. Ikiform décode chaque corps en UTF-8 et refuse les autres jeux de caractères avec `415 unsupported_charset`. L'envoi de fichiers n'est pas encore pris en charge. Un envoi avec un fichier reçoit `422 files_not_supported`.

## Quatre façons d'envoyer un formulaire

Un envoi classique n'est qu'une option. Chaque approche ci-dessous est un choix valable dans certains cas ; ce qui change, c'est qui traite la réponse et ce qui se passe avec les scripts désactivés.

| Approche | Points forts | Compromis |
| --- | --- | --- |
| `method="post"` classique avec une redirection | Fonctionne sans JavaScript, dans n'importe quel créateur de sites. Le navigateur gère l'état de chargement, le retour et le rafraîchissement | Un chargement de page complet. Les erreurs de champ nécessitent une page rendue côté serveur, ou la page d'erreur du backend |
| `fetch` avec `FormData` | Pas de rechargement. Vous décidez de l'apparence du succès et de chaque erreur de champ | L'état de chargement, les erreurs réseau et le focus sont à votre charge. Sans précaution, cela casse avec les scripts désactivés. Voir [sans rechargement de page](https://ikiform.com/fr/guides/html-form-submission/without-page-reload.md) |
| Les actions de formulaire des frameworks, comme les server actions Next.js ou les form actions SvelteKit | Votre serveur voit l'envoi en premier, et le même formulaire fonctionne avant et après l'hydratation | Il vous faut un runtime serveur, et la requête vers le backend part de votre serveur, ce qui change les limites de débit et les vérifications d'origine |
| `method="get"` | Formulaires de recherche et de filtre. Le résultat a une URL partageable | Les champs se retrouvent dans l'URL, l'historique et les journaux du serveur. Jamais pour des données personnelles ni pour une action qui modifie un état |

Commencez par l'envoi classique et ajoutez `fetch` par-dessus. Ainsi, le formulaire fonctionne encore quand un script ne se charge pas, ce qui arrive plus souvent que ne le montrent la plupart des outils d'analyse.

## Ce que le serveur doit répondre

Le navigateur traite la réponse à un POST comme une nouvelle page. Si cette page est la réponse elle-même, avec le statut `200`, le navigateur retient qu'elle vient d'un POST. Rafraîchissez et il propose de renvoyer le formulaire ; faites retour puis avancer et certains navigateurs font de même. C'est ainsi que des gens paient deux fois et que le support reçoit trois fois le même message.

La solution est le pattern Post/Redirect/Get. Le serveur traite le POST, puis répond `303 See Other` avec un en-tête `Location`. Le navigateur charge cette URL en GET, et la page que voit le visiteur vient d'un GET, donc un rafraîchissement est sans conséquence.

Schéma : Répondre à un POST avec une page comparé à une réponse par une redirection 303

1. Avec une réponse 200, le navigateur envoie le formulaire et affiche le corps de la réponse comme page.
2. Cette page est liée au POST, donc un rafraîchissement demande de renvoyer le formulaire et une confirmation renvoie les données.
3. Avec une réponse 303, le navigateur envoie le formulaire et reçoit l'instruction de charger une autre URL.
4. Il charge cette URL en GET. Un rafraîchissement ne répète que le GET, donc rien n'est envoyé deux fois.

Ikiform répond toujours à un envoi de navigateur par un 303, donc votre formulaire obtient ce comportement sans effort.

| Statut | Ce que fait ensuite le navigateur | À utiliser pour |
| --- | --- | --- |
| `303 See Other` | GET de la `Location`, corps abandonné | Après tout envoi de formulaire réussi |
| `302 Found` | Les navigateurs passent en GET en pratique, mais la spécification permet de conserver la méthode | Les serveurs plus anciens. Préférez `303` |
| `307` et `308` | Répète le même POST avec le même corps à la nouvelle URL | Déplacer un endpoint, jamais une page de remerciement |
| `200` avec du HTML | Affiche la page ; un rafraîchissement propose de renvoyer | Réafficher le formulaire avec les erreurs |
| `4xx` avec du HTML | Affiche la page d'erreur | Les erreurs que le visiteur peut corriger en revenant en arrière |

`Post/Redirect/Get`:

```http
HTTP/1.1 303 See Other
Location: https://example.com/report-sent
X-Request-Id: req_01k7v2c9t3e8m5q4x6z0a1b2c3

GET /report-sent HTTP/1.1
Host: example.com
```

## Comment Ikiform répond

Un seul endpoint, `https://ikiform.com/f/<formId>`, répond à la fois aux navigateurs et au code. Il décide à chaque requête s'il répond par des pages ou en JSON, d'après les en-têtes que le navigateur définit de lui-même :

| Requête | Réponse |
| --- | --- |
| `Accept` inclut `application/json` | JSON |
| Le corps est en JSON | JSON |
| `Sec-Fetch-Mode: navigate`, ce qui est le cas de tout envoi de formulaire classique | Redirection ou page hébergée |
| Tout autre `Sec-Fetch-Mode`, comme `cors` depuis `fetch` | JSON |
| Pas de `Sec-Fetch-Mode`, et `Accept` inclut `text/html` | Redirection ou page hébergée |
| Tout le reste, comme curl | JSON |

`Sec-Fetch-Mode` est un en-tête que le navigateur définit et que les scripts de la page ne peuvent pas modifier. Un envoi de formulaire qui fait naviguer l'onglet envoie `navigate` ; un appel `fetch` envoie `cors` pour une requête cross-site. Le même formulaire reçoit donc une redirection quand il est envoyé de façon classique et du JSON quand votre script l'envoie, sans aucun réglage de votre côté. Le tableau complet se trouve dans [JSON ou redirection](https://ikiform.com/fr/docs/how-it-works.md#response-modes).

Schéma : Le même endpoint Ikiform qui répond à un envoi de formulaire classique et à un appel fetch

1. Un envoi de formulaire classique fait naviguer l'onglet, donc le navigateur envoie Sec-Fetch-Mode: navigate.
2. Ikiform répond en mode page : une redirection 303 en cas de succès, ou une page d'erreur hébergée avec le vrai statut de l'erreur.
3. Un appel fetch envoie Sec-Fetch-Mode: cors, ou votre code demande du JSON avec l'en-tête Accept.
4. Ikiform répond en mode JSON : 201 avec l'identifiant de soumission, ou l'enveloppe d'erreur avec les erreurs de champ indexées par identifiant de champ.

Vous ne configurez pas le mode. Il découle de la façon dont la requête a été faite.

### Où aboutit un envoi réussi

Un envoi de navigateur réussi reçoit un `303`. Ikiform choisit la cible dans cet ordre :

1. Le paramètre **Redirect after submit** du formulaire dans le tableau de bord. Toute URL `http:` ou `https:` fonctionne, car seul votre espace de travail peut la définir.
2. Un champ `_redirect` dans l'envoi, ou `_next` s'il n'y a pas de `_redirect`, quand c'est une URL `https:` sans nom d'utilisateur, mot de passe ni port personnalisé, sur l'un des **Allowed domains** du formulaire ou l'un de leurs sous-domaines.
3. Sinon, la page de remerciement hébergée à `/f/<formId>/thanks`. Quand l'envoi vient d'une page `https:` sur un domaine autorisé, elle renvoie un lien vers cette page.

Un `_redirect` qui enfreint une règle est ignoré, jamais traité comme une erreur, et un formulaire sans domaines autorisés ignore entièrement `_redirect`. Cela empêche quiconque d'utiliser votre formulaire pour renvoyer les visiteurs vers le site de son choix. [Redirection après envoi](https://ikiform.com/fr/guides/html-form-submission/redirect-after-submit.md) traite en détail des pages de remerciement, et les règles complètes se trouvent dans [Formulaires HTML](https://ikiform.com/fr/docs/html.md#redirect-rules).

### Quand un envoi est refusé

En mode page, un envoi refusé affiche une page d'erreur hébergée avec le vrai statut HTTP de l'erreur. Pour une erreur de validation, elle liste chaque problème sous le libellé de votre champ et renvoie un lien vers la page d'où vient l'envoi. Le bouton **Back** du navigateur ramène généralement au formulaire avec ce que le visiteur a saisi ; le lien recharge la page à neuf. En mode JSON, la même erreur revient sous forme d'une seule enveloppe. Chaque code figure dans [Erreurs](https://ikiform.com/fr/docs/errors.md).

`422 en mode JSON`:

```json
{
  "ok": false,
  "error": {
    "code": "validation_failed",
    "message": "Some fields are invalid.",
    "fields": {
      "page": ["Enter a valid URL"],
      "details": ["Must be at least 20 characters"]
    }
  },
  "request_id": "req_01k7v2c9t3e8m5q4x6z0a1b2c3"
}
```

Le spam est la seule chose qui ne ressemble jamais à une erreur. Un envoi évalué comme spam reçoit le même `303` ou `201` qu'un vrai, donc un bot n'apprend rien, et il n'envoie ni e-mail ni webhook.

## Le formulaire dans votre stack

Voici un vrai formulaire, un signalement de page cassée, dans chaque stack. Choisissez la vôtre ; le choix s'applique à chaque exemple de code de ces guides. La définition de champs ci-dessous est celle qu'Ikiform impose sur le serveur, alors essayez : laissez les détails trop courts ou saisissez `example` comme adresse et vous obtenez le `422` que recevrait votre formulaire.

Formulaire : Report a broken page

- **Votre e-mail** (`email`, email, obligatoire)
- **Adresse de la page** (`page`, url, obligatoire)
- **Gravité** (`severity`, select, obligatoire)
- **Que s'est-il passé ?** (`details`, textarea, obligatoire)

Collez cette définition dans l'onglet **Fields** du formulaire dans le tableau de bord et Ikiform valide chaque envoi par rapport à elle. Voir [Champs et validation](https://ikiform.com/fr/docs/fields.md).

**HTML**

Aucun script. Le navigateur vérifie `required`, `type` et `minlength`, envoie les champs en texte urlencoded, suit le `303` d'Ikiform vers votre page `_redirect`, et affiche la page d'erreur d'Ikiform si le serveur refuse l'envoi.

`report.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="email">Your email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="page">Page address</label>
  <input id="page" name="page" type="url" required placeholder="https://example.com/pricing" />

  <label for="severity">How bad is it</label>
  <select id="severity" name="severity">
    <option value="cosmetic">Looks wrong</option>
    <option value="broken">Something doesn't work</option>
    <option value="blocking">I can't continue</option>
  </select>

  <label for="details">What happened</label>
  <textarea id="details" name="details" rows="5" minlength="20" maxlength="4000" required></textarea>

  <input type="hidden" name="_redirect" value="https://example.com/report-sent" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send report</button>
</form>
```

**Next.js**

Une server action reçoit le `FormData`, le transmet en JSON avec votre clé secrète et renvoie les erreurs de champ d'Ikiform. `useActionState` place chaque message sous son input et conserve ce que le visiteur a saisi.

`app/report/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";

export interface ReportState {
  sent: boolean;
  fields: Record<string, string[]>;
  message: string;
  values: Record<string, string>;
}

const NAMES = ["email", "page", "severity", "details"] as const;

export async function sendReport(_previous: ReportState, formData: FormData): Promise<ReportState> {
  const values = Object.fromEntries(NAMES.map((name) => [name, String(formData.get(name) ?? "").trim()]));
  const origin = (await headers()).get("origin");

  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({ ...values, _gotcha: String(formData.get("_gotcha") ?? "") }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { sent: true, fields: {}, message: "Thanks. We'll look at that page today.", values: {} };
  return {
    sent: false,
    fields: result?.error?.fields ?? {},
    message: result?.error?.message ?? "The report didn't go through. Try again.",
    values,
  };
}
```

`app/report/report-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type ReportState, sendReport } from "./actions";

const initial: ReportState = { sent: false, fields: {}, message: "", values: {} };

function FieldError({ id, messages }: { id: string; messages?: string[] }) {
  return messages?.length ? <p id={`${id}-error`}>{messages[0]}</p> : null;
}

export function ReportForm() {
  const [state, action, pending] = useActionState(sendReport, initial);

  if (state.sent) return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required defaultValue={state.values.email}
        aria-invalid={Boolean(state.fields.email)} aria-describedby="email-error" />
      <FieldError id="email" messages={state.fields.email} />

      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required defaultValue={state.values.page}
        aria-invalid={Boolean(state.fields.page)} aria-describedby="page-error" />
      <FieldError id="page" messages={state.fields.page} />

      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue={state.values.severity ?? "broken"}>
        <option value="cosmetic">Looks wrong</option>
        <option value="broken">Something doesn't work</option>
        <option value="blocking">I can't continue</option>
      </select>

      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} required minLength={20} defaultValue={state.values.details}
        aria-invalid={Boolean(state.fields.details)} aria-describedby="details-error" />
      <FieldError id="details" messages={state.fields.details} />

      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send report"}</button>
    </form>
  );
}
```

**Astro**

Avec `accept: "form"`, Zod rejette un e-mail ou une URL invalide avant que quoi que ce soit ne quitte votre serveur, et `isInputError` expose ces messages par champ. Les erreurs de champ `422` d'Ikiform reviennent comme données de l'action sous la même forme.

`src/actions/index.ts`:

```ts
import { defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";

export const server = {
  reportPage: defineAction({
    accept: "form",
    input: z.object({
      email: z.email(),
      page: z.url(),
      severity: z.enum(["cosmetic", "broken", "blocking"]),
      details: z.string().min(20, "Tell us a little more, at least 20 characters."),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(input),
      });
      const result = await response.json();
      if (result.ok) return { sent: true, fields: {} as Record<string, string[]> };
      return { sent: false, fields: (result.error.fields ?? {}) as Record<string, string[]> };
    },
  }),
};
```

`src/pages/report.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.reportPage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : (result?.data?.fields ?? {});
---

{result?.data?.sent ? (
  <p role="status">Thanks. We'll look at that page today.</p>
) : (
  <form method="POST" action={actions.reportPage}>
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required />
    {fields.email && <p>{fields.email[0]}</p>}

    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required />
    {fields.page && <p>{fields.page[0]}</p>}

    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      <option value="cosmetic">Looks wrong</option>
      <option value="broken" selected>Something doesn't work</option>
      <option value="blocking">I can't continue</option>
    </select>

    <label for="details">What happened</label>
    <textarea id="details" name="details" rows="5" minlength="20" required></textarea>
    {fields.details && <p>{fields.details[0]}</p>}

    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button>Send report</button>
  </form>
)}
```

**TanStack Start**

Une server function POST lit le `FormData` dans `.validator()` et renvoie les erreurs de champ comme données simples, pour que le composant puisse les afficher sans rien intercepter.

`src/routes/report.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";

type Outcome = { sent: boolean; fields: Record<string, string[]>; message: string };

const sendReport = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    const read = (name: string) => String(data.get(name) ?? "").trim();
    return {
      email: read("email"),
      page: read("page"),
      severity: read("severity"),
      details: read("details"),
      _gotcha: read("_gotcha"),
    };
  })
  .handler(async ({ data }): Promise<Outcome> => {
    const origin = getRequestHeader("origin");
    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(data),
    });
    const result = await response.json();
    if (result.ok) return { sent: true, fields: {}, message: "Thanks. We'll look at that page today." };
    return { sent: false, fields: result.error.fields ?? {}, message: String(result.error.message) };
  });

export const Route = createFileRoute("/report")({ component: Report });

function Report() {
  const send = useServerFn(sendReport);
  const [outcome, setOutcome] = useState<Outcome | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      setOutcome(await send({ data: new FormData(event.currentTarget) }));
    } catch {
      setOutcome({ sent: false, fields: {}, message: "The report didn't go through. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (outcome?.sent) return <p role="status">{outcome.message}</p>;
  const fields = outcome?.fields ?? {};

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required aria-invalid={Boolean(fields.email)} />
      {fields.email ? <p>{fields.email[0]}</p> : null}
      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required aria-invalid={Boolean(fields.page)} />
      {fields.page ? <p>{fields.page[0]}</p> : null}
      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue="broken">
        <option value="cosmetic">Looks wrong</option>
        <option value="broken">Something doesn't work</option>
        <option value="blocking">I can't continue</option>
      </select>
      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} minLength={20} required aria-invalid={Boolean(fields.details)} />
      {fields.details ? <p>{fields.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {outcome?.message ? <p role="alert">{outcome.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send report"}</button>
    </form>
  );
}
```

**React Router**

L'`action` de la route est la vraie cible du formulaire, donc `<Form>` fonctionne aussi avant l'hydratation. `data()` transmet le statut `422` d'Ikiform avec les erreurs de champ et les valeurs saisies.

`app/routes/report.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/report";

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const values = {
    email: String(form.get("email") ?? "").trim(),
    page: String(form.get("page") ?? "").trim(),
    severity: String(form.get("severity") ?? ""),
    details: String(form.get("details") ?? "").trim(),
  };
  const origin = request.headers.get("origin");

  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({ ...values, _gotcha: String(form.get("_gotcha") ?? "") }),
  });
  const result = await response.json();
  if (result.ok) return { sent: true as const };
  return data(
    {
      sent: false as const,
      fields: (result.error.fields ?? {}) as Record<string, string[]>,
      message: String(result.error.message),
      values,
    },
    { status: response.status },
  );
}

export default function Report({ actionData }: Route.ComponentProps) {
  const sending = useNavigation().state === "submitting";

  if (actionData?.sent) return <p role="status">Thanks. We'll look at that page today.</p>;
  const fields = actionData?.fields ?? {};
  const values = actionData?.values;

  return (
    <Form method="post">
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required defaultValue={values?.email} />
      {fields.email ? <p>{fields.email[0]}</p> : null}
      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required defaultValue={values?.page} />
      {fields.page ? <p>{fields.page[0]}</p> : null}
      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue={values?.severity ?? "broken"}>
        <option value="cosmetic">Looks wrong</option>
        <option value="broken">Something doesn't work</option>
        <option value="blocking">I can't continue</option>
      </select>
      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} minLength={20} required defaultValue={values?.details} />
      {fields.details ? <p>{fields.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {actionData && !Object.keys(fields).length ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send report"}</button>
    </Form>
  );
}
```

**SvelteKit**

L'action de formulaire par défaut renvoie `fail()` avec le statut et les erreurs de champ d'Ikiform. Sans JavaScript, la page est rendue à nouveau avec eux ; avec `use:enhance`, elle se met à jour sur place. Déclarez `IKIFORM_SECRET_KEY` dans `src/env.ts` avec `defineEnvVars` pour que `$app/env/private` puisse la lire.

`src/routes/report/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, fetch, url }) => {
    const form = await request.formData();
    const values = {
      email: String(form.get("email") ?? "").trim(),
      page: String(form.get("page") ?? "").trim(),
      severity: String(form.get("severity") ?? ""),
      details: String(form.get("details") ?? "").trim(),
    };

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify({ ...values, _gotcha: String(form.get("_gotcha") ?? "") }),
    });
    const result = await response.json();
    if (!result.ok) {
      return fail(response.status, { values, fields: (result.error.fields ?? {}) as Record<string, string[]> });
    }
    return { sent: true };
  },
} satisfies Actions;
```

`src/routes/report/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let fields = $derived(form?.fields ?? {});

  const SEVERITIES = [
    ["cosmetic", "Looks wrong"],
    ["broken", "Something doesn't work"],
    ["blocking", "I can't continue"],
  ];
</script>

{#if form?.sent}
  <p role="status">Thanks. We'll look at that page today.</p>
{:else}
  <form method="POST" use:enhance>
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required value={form?.values?.email ?? ""} />
    {#if fields.email}<p>{fields.email[0]}</p>{/if}

    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required value={form?.values?.page ?? ""} />
    {#if fields.page}<p>{fields.page[0]}</p>{/if}

    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      {#each SEVERITIES as [value, label]}
        <option {value} selected={(form?.values?.severity ?? "broken") === value}>{label}</option>
      {/each}
    </select>

    <label for="details">What happened</label>
    <textarea id="details" name="details" rows="5" minlength="20" required value={form?.values?.details ?? ""}></textarea>
    {#if fields.details}<p>{fields.details[0]}</p>{/if}

    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button>Send report</button>
  </form>
{/if}
```

**Nuxt**

Une route serveur transmet le signalement avec `runtimeConfig.ikiformSecretKey`, défini depuis `NUXT_IKIFORM_SECRET_KEY`. Elle lève `createError` avec les erreurs de champ dans `data`, que `$fetch` restitue côté client.

`server/api/report.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<Record<string, string>>(event);
  const { ikiformSecretKey } = useRuntimeConfig(event);
  const origin = getRequestHeader(event, "origin");

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      email: body.email ?? "",
      page: body.page ?? "",
      severity: body.severity ?? "",
      details: body.details ?? "",
      _gotcha: body._gotcha ?? "",
    }),
  });
  const result = await response.json();
  if (!result.ok) {
    throw createError({
      status: response.status,
      message: String(result.error.message),
      data: { fields: result.error.fields ?? {} },
    });
  }
  return { sent: true };
});
```

`app/components/ReportForm.vue`:

```vue
<script setup lang="ts">
const sent = ref(false);
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});

async function send(event: Event) {
  const form = event.target as HTMLFormElement;
  sending.value = true;
  fields.value = {};
  try {
    await $fetch("/api/report", { method: "POST", body: Object.fromEntries(new FormData(form)) });
    sent.value = true;
  } catch (failure) {
    const reply = failure as { data?: { data?: { fields?: Record<string, string[]> } } };
    fields.value = reply.data?.data?.fields ?? { details: ["The report didn't go through. Try again."] };
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <p v-if="sent" role="status">Thanks. We'll look at that page today.</p>
  <form v-else method="post" action="/api/report" @submit.prevent="send">
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required />
    <p v-if="fields.email">{{ fields.email[0] }}</p>
    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required />
    <p v-if="fields.page">{{ fields.page[0] }}</p>
    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      <option value="cosmetic">Looks wrong</option>
      <option value="broken" selected>Something doesn't work</option>
      <option value="blocking">I can't continue</option>
    </select>
    <label for="details">What happened</label>
    <textarea id="details" name="details" rows="5" minlength="20" required />
    <p v-if="fields.details">{{ fields.details[0] }}</p>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button :disabled="sending">{{ sending ? "Sending" : "Send report" }}</button>
  </form>
</template>
```

**React**

Une application monopage envoie depuis le navigateur, donc il n'y a pas de clé secrète ici ; **Allowed domains** protège le formulaire. `Accept: application/json` obtient une réponse JSON au lieu d'une redirection.

`src/ReportForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const SEVERITIES = [
  ["cosmetic", "Looks wrong"],
  ["broken", "Something doesn't work"],
  ["blocking", "I can't continue"],
];

export function ReportForm() {
  const [sent, setSent] = useState(false);
  const [sending, setSending] = useState(false);
  const [fields, setFields] = useState<Record<string, string[]>>({});
  const [problem, setProblem] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setSending(true);
    setFields({});
    setProblem("");
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const result = await response.json();
      if (result.ok) setSent(true);
      else if (result.error.fields) setFields(result.error.fields);
      else setProblem(result.error.message);
    } catch {
      setProblem("Couldn't reach the server. Check your connection and try again.");
    } finally {
      setSending(false);
    }
  }

  if (sent) return <p role="status">Thanks. We'll look at that page today.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required aria-invalid={Boolean(fields.email)} />
      {fields.email ? <p>{fields.email[0]}</p> : null}
      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required aria-invalid={Boolean(fields.page)} />
      {fields.page ? <p>{fields.page[0]}</p> : null}
      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue="broken">
        {SEVERITIES.map(([value, label]) => (
          <option key={value} value={value}>{label}</option>
        ))}
      </select>
      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} minLength={20} required aria-invalid={Boolean(fields.details)} />
      {fields.details ? <p>{fields.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {problem ? <p role="alert">{problem}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send report"}</button>
    </form>
  );
}
```

**Vue**

Le même envoi depuis le navigateur dans un composant Vue. Le formulaire garde une vraie `action` et une vraie `method`, donc il s'envoie encore de façon classique si le script ne se charge pas.

`src/components/ReportForm.vue`:

```vue
<script setup lang="ts">
import { ref } from "vue";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

const sent = ref(false);
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});
const problem = ref("");

async function send(event: SubmitEvent) {
  sending.value = true;
  fields.value = {};
  problem.value = "";
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(event.target as HTMLFormElement),
    });
    const result = await response.json();
    if (result.ok) sent.value = true;
    else if (result.error.fields) fields.value = result.error.fields;
    else problem.value = result.error.message;
  } catch {
    problem.value = "Couldn't reach the server. Check your connection and try again.";
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <p v-if="sent" role="status">Thanks. We'll look at that page today.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required :aria-invalid="Boolean(fields.email)" />
    <p v-if="fields.email">{{ fields.email[0] }}</p>
    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required :aria-invalid="Boolean(fields.page)" />
    <p v-if="fields.page">{{ fields.page[0] }}</p>
    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      <option value="cosmetic">Looks wrong</option>
      <option value="broken" selected>Something doesn't work</option>
      <option value="blocking">I can't continue</option>
    </select>
    <label for="details">What happened</label>
    <textarea id="details" name="details" rows="5" minlength="20" required :aria-invalid="Boolean(fields.details)" />
    <p v-if="fields.details">{{ fields.details[0] }}</p>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="problem" role="alert">{{ problem }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send report" }}</button>
  </form>
</template>
```

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

Chaque version serveur transmet l'en-tête `Origin` du navigateur quand il existe, car un `fetch` côté serveur n'en envoie pas et Ikiform s'en sert pour reconnaître les tests gratuits depuis `localhost` sur un espace de travail sans offre.

- Chaque version garde une vraie `method` et, quand elle envoie directement à Ikiform, une vraie `action`. Avec les scripts désactivés, le formulaire s'envoie encore et reçoit le `303`.
- Le `name` de chaque input est l'`id` du champ dans la définition. Une différence, comme `url` au lieu de `page`, reçoit `422` avec `Unknown field "url"`.
- L'input masqué `_gotcha` est le honeypot. Les bots qui le remplissent sont stockés comme spam, discrètement.
- Les versions JavaScript affichent `error.fields` sous chaque input, en utilisant le premier message de chaque champ. La version HTML simple laisse cela à la page d'erreur d'Ikiform.

La documentation va plus loin pour chaque framework : [Next.js](https://ikiform.com/fr/docs/guides/nextjs.md), [Astro](https://ikiform.com/fr/docs/guides/astro.md), [SvelteKit](https://ikiform.com/fr/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/fr/docs/guides/nuxt.md), [React](https://ikiform.com/fr/docs/guides/react.md), [Vue](https://ikiform.com/fr/docs/guides/vue.md) et [depuis un serveur](https://ikiform.com/fr/docs/guides/server.md).

## Deux niveaux de validation

La validation du navigateur et celle du serveur n'ont pas le même rôle. La vérification des contraintes du navigateur (`required`, `type="email"`, `minlength`, `pattern`) donne un retour immédiat, mais n'importe qui peut la contourner : un bot envoie directement, et un script peut appeler `form.submit()`, qui n'exécute jamais la vérification. Le serveur est le seul endroit où une règle tient vraiment.

| Règle | Dans le navigateur | Dans la définition de champs d'Ikiform |
| --- | --- | --- |
| Obligatoire | `required` | `"required": true` |
| E-mail | `type="email"` | `"type": "email"`, avec `allowed_domains` en option |
| URL | `type="url"` | `"type": "url"`, avec `https_only` en option |
| Longueur | `minlength`, `maxlength` | `validation.min_length`, `max_length` |
| Seulement les choix listés | Un `<select>`, qu'un bot ignore | `options` ; tout le reste est refusé |
| Aucun champ supplémentaire | Impossible | Tout champ absent de la définition est refusé |

Sans définition, Ikiform accepte n'importe quels champs et les stocke tels qu'ils arrivent. Publiez-en une et il valide, stocke les nombres et les cases à cocher comme de vrais nombres et booléens, et refuse l'envoi avec `422 validation_failed` avant que quoi que ce soit ne soit stocké. [Validation](https://ikiform.com/fr/guides/html-form-submission/validation.md) montre comment faire dire la même chose aux deux niveaux.

## Envoyer avec JavaScript

Pour garder le visiteur sur la page, écoutez `submit`, appelez `preventDefault()` et envoyez `new FormData(form)` avec `fetch`. Quelques détails décident si cela fonctionne du premier coup :

- Ne définissez pas `Content-Type` vous-même quand le corps est un `FormData`. Le navigateur définit `multipart/form-data` avec la délimitation ; un en-tête écrit à la main n'a pas de délimitation et le corps ne peut pas être analysé.
- Envoyez `Accept: application/json`. Ikiform choisirait de toute façon JSON d'après `Sec-Fetch-Mode`, mais l'en-tête le rend explicite, et c'est l'un des en-têtes de requête que les navigateurs peuvent envoyer en cross-site sans preflight.
- Un corps `FormData` ou `URLSearchParams` est une requête cross-site simple, donc il n'y a pas de preflight. Un corps JSON avec `Content-Type: application/json` déclenche d'abord un preflight ; Ikiform y répond, mais cela coûte un aller-retour.
- `form.submit()` ignore la validation et l'événement `submit`. `form.requestSubmit()` exécute les deux, comme le ferait un clic.
- Passez le bouton cliqué en second argument, `new FormData(form, event.submitter)`, si son `name` et sa `value` comptent.

Le pattern complet, avec état de chargement, focus et erreurs réseau, se trouve dans [sans rechargement de page](https://ikiform.com/fr/guides/html-form-submission/without-page-reload.md). La documentation [JavaScript et fetch](https://ikiform.com/fr/docs/frameworks.md) liste chaque en-tête et format de corps que l'endpoint accepte.

## Choisissez votre cas

Chacune de ces pages reprend une partie de l'envoi et va plus loin que la vue d'ensemble ci-dessus, avec du code pour ce cas.

- [Sans rechargement de page](https://ikiform.com/fr/guides/html-form-submission/without-page-reload.md): Envoyez un formulaire avec fetch et FormData pour que le visiteur reste sur la page, avec erreurs de champ, état de chargement, nouvelles tentatives et solution de repli quand les scripts échouent.
- [Redirection après envoi](https://ikiform.com/fr/guides/html-form-submission/redirect-after-submit.md): Envoyez les visiteurs vers votre propre page de remerciement après l'envoi d'un formulaire : la redirection 303, le champ _redirect, les domaines autorisés et l'effet d'un rafraîchissement.
- [L'attribut action](https://ikiform.com/fr/guides/html-form-submission/action-attribute.md): Ce que fait l'attribut action d'un formulaire, comment les valeurs relatives et vides sont résolues, comment formaction envoie un formulaire à deux endroits, et pourquoi les hébergeurs statiques répondent 405.
- [Validation de formulaire](https://ikiform.com/fr/guides/html-form-submission/validation.md): Validez un formulaire avec required, type, pattern et min, stylisez les erreurs avec :user-invalid, et imposez les mêmes règles sur le serveur avec des définitions de champs.

## Vérifiez que tout fonctionne

Faites ces vérifications une fois par formulaire, dans une fenêtre de navigation privée. Ikiform n'a pas d'offre gratuite. Les tests sont gratuits : un espace de travail sans offre accepte 50 soumissions de test par mois depuis `localhost`. Pour passer en production, choisissez Pro à partir de 6 $/mois pour 500 soumissions, ou payez une seule fois : 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais. Voir les [tarifs](https://ikiform.com/fr/pricing).

1. Dans **Allowed domains** du formulaire, ajoutez le domaine de votre site, plus `localhost` pendant le développement. Laissez vide pour accepter les envois de partout.
2. Désactivez JavaScript dans les outils de développement de votre navigateur et envoyez le formulaire. Il doit quand même arriver sur votre page de remerciement avec un `303`.
3. Avec JavaScript activé, ouvrez le panneau Réseau et envoyez à nouveau. La requête vers `/f/<formId>` doit répondre `201` avec du JSON, et la page ne doit pas se recharger.
4. Envoyez avec un message trop court. L'envoi classique affiche la page d'erreur d'Ikiform avec le libellé de votre champ ; l'envoi par script affiche le message sous le champ.
5. Sur la page de remerciement, rafraîchissez. Rien ne doit être renvoyé, et aucune seconde soumission ne doit apparaître.
6. Ouvrez **Submissions** dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
7. Envoyez avec le honeypot rempli, `_gotcha=bot`. L'envoi reçoit quand même une réponse de succès, mais la soumission est stockée comme spam et n'envoie ni e-mail ni webhook.

## Dépannage

| Problème | Que faire |
| --- | --- |
| `405 Method Not Allowed` après l'envoi | Le formulaire a envoyé vers votre propre hébergeur statique, qui n'accepte pas de POST. Vérifiez `action` : ce doit être l'URL complète `https://ikiform.com/f/<formId>`, pas un chemin relatif ni une valeur vide. |
| Ouvrir l'endpoint dans un onglet affiche `404 not_found` | C'est normal. Un onglet envoie un GET, et l'endpoint n'accepte que POST. Vérifiez que la `method` du formulaire est `post` ; un formulaire sans méthode envoie un GET avec les champs dans l'URL. |
| `415 unsupported_media_type` | Le formulaire a `enctype="text/plain"`, ou un script a envoyé un type qu'Ikiform ne lit pas. Retirez l'`enctype`, ou utilisez `multipart/form-data`. |
| `422 validation_failed` | L'envoi ne correspond pas à la définition des champs du formulaire. `error.fields` nomme chaque champ et la raison. Faites correspondre le `name` de l'input à l'id du champ. |
| Le navigateur arrive sur la page de remerciement d'Ikiform au lieu de la vôtre | `_redirect` est ignoré sauf si son domaine figure dans **Allowed domains** et qu'il s'agit d'une URL `https:`. Ajoutez le domaine, ou définissez plutôt **Redirect after submit** dans les réglages du formulaire. |
| Un champ manque dans la soumission | Il n'a pas de `name`, il est `disabled`, ou c'est une case à cocher non cochée. Donnez-lui un `name`, utilisez `readonly` au lieu de `disabled`, ou définissez un champ `checkbox` pour qu'une case non cochée soit stockée comme `false`. |
| Le rafraîchissement demande de renvoyer le formulaire | La page a répondu au POST avec `200`. Les envois directs vers Ikiform reçoivent toujours un `303` ; si votre propre serveur se trouve entre les deux, redirigez après avoir traité l'envoi. |
| `403 origin_not_allowed`, ou tous les envois finissent en spam | Le domaine de la page n'est pas dans les **Allowed domains** du formulaire. Ajoutez-le, plus `localhost` pour les tests en local. Les sous-domaines d'un domaine listé sont autorisés. |

## Questions

### À quoi sert l'attribut action dans un formulaire HTML ?

C'est l'URL qui reçoit les données du formulaire. Une valeur relative est résolue par rapport à l'adresse de la page, et un formulaire sans action envoie à la page elle-même. Un bouton d'envoi peut la remplacer avec formaction.

### Un formulaire doit-il utiliser GET ou POST ?

POST pour tout ce qui envoie des données personnelles ou modifie un état, comme les formulaires de contact, d'inscription ou de commande. GET sert aux formulaires de recherche et de filtre, dont les champs ont leur place dans une URL que l'on peut partager.

### Pourquoi rafraîchir la page renvoie-t-il mon formulaire ?

La page que vous voyez est la réponse directe à un POST, donc le navigateur propose de le répéter. Répondez plutôt à un POST réussi par une redirection 303 vers une page de remerciement. Ikiform le fait pour chaque envoi de navigateur.

### Un formulaire HTML peut-il envoyer du JSON ?

Pas tout seul. Un formulaire envoie des données urlencoded ou multipart. Pour envoyer du JSON, lisez le formulaire avec FormData dans un gestionnaire submit et envoyez JSON.stringify(Object.fromEntries(data)) avec fetch. Ikiform accepte les trois.

### L'attribut required suffit-il à valider un formulaire ?

Non. Il aide les personnes, mais les bots envoient directement et le contournent. Validez aussi sur le serveur. Avec Ikiform, définissez les champs et chaque envoi est vérifié par rapport à eux avant d'être stocké.

### Puis-je envoyer un formulaire sans backend ?

Il vous faut quelque chose pour recevoir le POST, mais ce n'est pas forcément votre serveur. Faites pointer l'action du formulaire vers un backend de formulaires comme Ikiform, et la page peut rester entièrement statique.

### Le formulaire peut-il accepter des envois de fichiers ?

Pas encore. Un envoi contenant un fichier est refusé avec 422 files_not_supported. Demandez plutôt un lien vers le fichier, comme une URL de drive partagé.

## Autres guides

- [Sans rechargement de page](https://ikiform.com/fr/guides/html-form-submission/without-page-reload.md): Envoyez un formulaire avec fetch et FormData pour que le visiteur reste sur la page, avec erreurs de champ, état de chargement, nouvelles tentatives et solution de repli quand les scripts échouent.
- [Redirection après envoi](https://ikiform.com/fr/guides/html-form-submission/redirect-after-submit.md): Envoyez les visiteurs vers votre propre page de remerciement après l'envoi d'un formulaire : la redirection 303, le champ _redirect, les domaines autorisés et l'effet d'un rafraîchissement.
- [L'attribut action](https://ikiform.com/fr/guides/html-form-submission/action-attribute.md): Ce que fait l'attribut action d'un formulaire, comment les valeurs relatives et vides sont résolues, comment formaction envoie un formulaire à deux endroits, et pourquoi les hébergeurs statiques répondent 405.
- [Validation de formulaire](https://ikiform.com/fr/guides/html-form-submission/validation.md): Validez un formulaire avec required, type, pattern et min, stylisez les erreurs avec :user-invalid, et imposez les mêmes règles sur le serveur avec des définitions de champs.
- [Inscription à un événement](https://ikiform.com/fr/guides/event-registration.md): Recevez les RSVP sur votre page d'événement : formulaire, limite d'invités, décompte fiable, clôture quand c'est complet et liste d'accueil, sans billetterie.
- [Vérifier les signatures de webhook](https://ikiform.com/fr/guides/webhook-signature-verification.md): Vérifiez qu'un webhook de formulaire vient bien d'Ikiform : le schéma HMAC-SHA256 de Standard Webhooks, un vecteur de test, les corps bruts, les timestamps et la rotation du secret.
- [Liste d'attente pour un nouveau SaaS](https://ikiform.com/fr/guides/waitlist.md): Créez une liste d'attente avant lancement sur votre landing page : le formulaire, l'anti-spam, les doublons, les e-mails de confirmation et un export propre le jour J.
