Learn · JavaScript
The FormData API explained
FormData is the browser's own model of what a form would submit. Built from a form element, it applies the same rules as a native submission, which makes it the right starting point for any JavaScript submit. Most bugs come from converting it into something else.
Building one from a form
new FormData(form) reads the form's current values with the submission rules: controls without a name are skipped, as are disabled controls, unchecked checkboxes and radios, and buttons. Pass the submit button as the second argument and its name=value pair is included, exactly as a native submit would include it. The constructor throws if that element is not a submit button of the form.
js
form.addEventListener("submit", (event) => {
event.preventDefault();
const fd = new FormData(form, event.submitter);
for (const [name, value] of fd) console.log(name, value);
});Multiple values per name
Checkbox groups and <select multiple> produce several entries with the same name. get() returns the first one, getAll() returns all of them. append() adds an entry, set() replaces every entry with that name.
The trap is Object.fromEntries(fd): later entries overwrite earlier ones, so a checkbox group comes out as its last ticked value with no error.
js
// <input type="checkbox" name="topic" value="sales" checked>
// <input type="checkbox" name="topic" value="support" checked>
fd.get("topic"); // "sales"
fd.getAll("topic"); // ["sales", "support"]
Object.fromEntries(fd).topic; // "support" (sales is gone)
// Keep arrays for the names you know are multi-valued:
const MULTI = new Set(["topic"]);
const obj = {};
for (const key of new Set(fd.keys())) {
obj[key] = MULTI.has(key) ? fd.getAll(key) : fd.get(key);
}Decide per field rather than "array if more than one". The generic version turns a group with one box ticked into a string and the same group with two ticked into an array, and the server code has to handle both.
Files
A file input contributes File objects. An empty file input still contributes an entry: the spec adds a File with an empty name, type application/octet-stream and no bytes. Servers that treat every file part as an upload reject or store that phantom file, so check file.name or file.size before counting it.
To attach a file you built in JavaScript, pass a filename: fd.append("avatar", blob, "avatar.png"). Without one, a Blob is sent with the name blob.
Let fetch set the Content-Type
When the body is a FormData, fetch generates a boundary and sets Content-Type: multipart/form-data; boundary=.... When the body is URLSearchParams, it sets application/x-www-form-urlencoded;charset=UTF-8. Setting the header yourself for FormData drops the boundary and breaks parsing on the server.
js
// multipart/form-data; boundary=... (set by fetch)
await fetch(url, { method: "POST", body: new FormData(form) });
// application/x-www-form-urlencoded;charset=UTF-8 (set by fetch)
await fetch(url, { method: "POST", body: new URLSearchParams(new FormData(form)) });Converting to URLSearchParams or JSON
new URLSearchParams(fd) keeps repeated names and gives you the smaller URL-encoded body, but only for text fields: a File is serialized as the literal string [object File]. Use it for forms without file inputs.
JSON.stringify(Object.fromEntries(fd)) loses data twice: repeated names collapse to the last value, and a File serializes as {}. If an API needs JSON, build the object deliberately as in the multi-value example above and upload files separately.
js
const fd = new FormData();
fd.append("t", "a");
fd.append("t", "b");
fd.append("f", new File(["x"], "x.txt"));
new URLSearchParams(fd).toString(); // "t=a&t=b&f=%5Bobject+File%5D"
JSON.stringify(Object.fromEntries(fd)); // '{"t":"b","f":{}}'Adding computed values: the formdata event
The form fires a formdata event whenever its entry list is built: on a native submission and on new FormData(form). Appending to event.formData there adds a value to both paths without hidden inputs, which is handy for a timezone or a client-side build id.
js
form.addEventListener("formdata", (event) => {
event.formData.set("tz", Intl.DateTimeFormat().resolvedOptions().timeZone);
});How this works with Formward
Formward accepts URL-encoded, multipart and JSON bodies on the same endpoint. For URL-encoded and multipart bodies, a repeated field name keeps its last value, which is the Object.fromEntries behaviour described above. To keep every ticked checkbox, give each a distinct name, join the values into one field before sending, or post JSON with an array value.
Empty file inputs are skipped rather than rejected, so an optional upload field does not break submissions. Files need the form's uploads switch and a plan that includes uploads; see file uploads.
Sources
Point a form at an EU endpoint
Formward receives the POST, filters spam and stores submissions in Sweden. No server to run.