Guides17 min de lecture
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.
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.
- 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.
- 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é.
- 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é.
- Il encode ce jeu selon l'enctype du formulaire, en urlencoded sauf indication contraire.
- Il navigue vers l'URL action avec la méthode du formulaire, en envoyant le corps.
- 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.
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 | 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é :
<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. Unidseul n'envoie rien. - Les contrôles
disabledsont ignorés. Utilisezreadonlysi 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 sansvalueenvoieon. - Seul le bouton qui a envoyé le formulaire est inclus, avec son
nameet savalue. <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 attributformqui désigne l'iddu 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 :
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.
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--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 |
| 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.
- Avec une réponse 200, le navigateur envoie le formulaire et affiche le corps de la réponse comme page.
- Cette page est liée au POST, donc un rafraîchissement demande de renvoyer le formulaire et une confirmation renvoie les données.
- Avec une réponse 303, le navigateur envoie le formulaire et reçoit l'instruction de charger une autre URL.
- Il charge cette URL en GET. Un rafraîchissement ne répète que le GET, donc rien n'est envoyé deux fois.
| 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 |
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.comComment 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.
- Un envoi de formulaire classique fait naviguer l'onglet, donc le navigateur envoie Sec-Fetch-Mode: navigate.
- 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.
- Un appel fetch envoie Sec-Fetch-Mode: cors, ou votre code demande du JSON avec l'en-tête Accept.
- 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.
Où aboutit un envoi réussi
Un envoi de navigateur réussi reçoit un 303. Ikiform choisit la cible dans cet ordre :
- Le paramètre Redirect after submit du formulaire dans le tableau de bord. Toute URL
http:ouhttps:fonctionne, car seul votre espace de travail peut la définir. - Un champ
_redirectdans l'envoi, ou_nexts'il n'y a pas de_redirect, quand c'est une URLhttps: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. - Sinon, la page de remerciement hébergée à
/f/<formId>/thanks. Quand l'envoi vient d'une pagehttps: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 traite en détail des pages de remerciement, et les règles complètes se trouvent dans Formulaires HTML.
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.
{
"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.
Aperçu
Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.
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.
<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>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.
"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,
};
}"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>
);
}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.
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[]> };
},
}),
};---
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>
)}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.
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>
);
}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.
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>
);
}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.
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;<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}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.
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 };
});<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>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.
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>
);
}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.
<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>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
methodet, quand elle envoie directement à Ikiform, une vraieaction. Avec les scripts désactivés, le formulaire s'envoie encore et reçoit le303. - Le
namede chaque input est l'iddu champ dans la définition. Une différence, commeurlau lieu depage, reçoit422avecUnknown field "url". - L'input masqué
_gotchaest le honeypot. Les bots qui le remplissent sont stockés comme spam, discrètement. - Les versions JavaScript affichent
error.fieldssous 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, Astro, SvelteKit, Nuxt, React, Vue et depuis un serveur.
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 |
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 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-Typevous-même quand le corps est unFormData. Le navigateur définitmultipart/form-dataavec 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èsSec-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
FormDataouURLSearchParamsest une requête cross-site simple, donc il n'y a pas de preflight. Un corps JSON avecContent-Type: application/jsondéclenche d'abord un preflight ; Ikiform y répond, mais cela coûte un aller-retour. form.submit()ignore la validation et l'événementsubmit.form.requestSubmit()exécute les deux, comme le ferait un clic.- Passez le bouton cliqué en second argument,
new FormData(form, event.submitter), si sonnameet savaluecomptent.
Le pattern complet, avec état de chargement, focus et erreurs réseau, se trouve dans sans rechargement de page. La documentation JavaScript et fetch 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 pageEnvoyez 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 envoiEnvoyez 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 actionCe 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 formulaireValidez 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.
- Dans Allowed domains du formulaire, ajoutez le domaine de votre site, plus
localhostpendant le développement. Laissez vide pour accepter les envois de partout. - 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. - Avec JavaScript activé, ouvrez le panneau Réseau et envoyez à nouveau. La requête vers
/f/<formId>doit répondre201avec du JSON, et la page ne doit pas se recharger. - 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.
- Sur la page de remerciement, rafraîchissez. Rien ne doit être renvoyé, et aucune seconde soumission ne doit apparaître.
- Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
- 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
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.
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.
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.
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.
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é.
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.
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é.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.