Updated Jul 6, 2026

Submitting Data: GET vs. POST, FormData, and Fetch

Submit the signup form as-is and the whole page reloads - the browser navigates to a new URL built from the form's data. That's native form submission, and it still has a place. Most modern apps intercept it instead, sending the data with fetch and updating the page without a reload. Both matter; know when to reach for which.

Native submission: GET vs. POST

Every <form> has a method, and it changes where the data goes:

<form method="get" action="/search">
  <input type="text" name="q">
</form>

A GET form appends its fields to the URL as a query string: submitting q=svelte navigates to /search?q=svelte. That's right for searches and filters - the result is bookmarkable and shareable, and "search again" is just reloading the URL.

<form method="post" action="/signup">
  <input type="text" name="fullName">
</form>

A POST form sends the data in the request body instead, invisible in the URL. That's right for anything that changes state or carries sensitive data - a signup form with a password has no business appearing in a URL, browser history, or a server access log.

Without JavaScript, either version fully navigates: the browser follows action, the server responds with a new page (or a redirect), and the current page is gone. That's a real, working form with zero lines of JavaScript - worth remembering when you don't need an SPA-style flow at all.

Intercepting submission with preventDefault()

To keep the user on the page, listen for the form's submit event and stop the default navigation:

const form = document.querySelector('form');

form.addEventListener('submit', (event) => {
  event.preventDefault();
  // handle the data yourself from here
});

preventDefault() stops the browser from navigating anywhere. If you forget it, everything else in this phase still runs, but the page reloads immediately after, and your JavaScript never gets to react to the result.

Building a FormData object

FormData reads every named field from a form element in one call - no manually grabbing each input by id:

form.addEventListener('submit', (event) => {
  event.preventDefault();

  const data = new FormData(form);
  console.log(data.get('email'));       // one field
  console.log(Object.fromEntries(data)); // the whole form as a plain object
});

FormData picks up name attributes, not id - a field with no name is silently skipped, which is the most common reason "my form data is missing a field" turns out to be a typo'd or missing name. Checkboxes are the other trap: an unchecked checkbox sends nothing at all, not false - data.get('agree') returns null unless the box was checked.

Sending it with fetch

FormData can go straight into a fetch call as the request body - the browser sets the right Content-Type header automatically:

form.addEventListener('submit', async (event) => {
  event.preventDefault();

  const data = new FormData(form);

  const response = await fetch('/api/signup', {
    method: 'POST',
    body: data,
  });

  if (response.ok) {
    const result = await response.json();
    showSuccess(`Welcome, ${result.name}!`);
  } else {
    const error = await response.json();
    showError(error.message);
  }
});

response.ok is true for any 2xx status and false otherwise - it's the quickest way to branch between success and failure without checking exact status codes. A 422 with {"message": "Email already registered"} lands in the else branch, where showError can put that exact message next to the email field instead of a generic "something went wrong."

If you'd rather send JSON instead of multipart/form-data (some APIs expect that), convert first:

const data = new FormData(form);
const json = Object.fromEntries(data);

await fetch('/api/signup', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(json),
});

⚠️ Gotcha. fetch only rejects its promise on a network failure (no connection, DNS failure) - a 404 or 500 response still resolves normally with response.ok set to false. Wrap in try/catch for the network case, and check response.ok separately for the HTTP-status case; missing either one means some class of failure shows the user nothing at all.

try {
  const response = await fetch('/api/signup', { method: 'POST', body: new FormData(form) });
  if (!response.ok) {
    const error = await response.json();
    showError(error.message);
    return;
  }
  showSuccess('Account created.');
} catch (networkError) {
  showError('Could not reach the server. Check your connection.');
}

Putting the whole form together

const form = document.querySelector('#signup-form');

form.addEventListener('submit', async (event) => {
  event.preventDefault();

  if (!form.checkValidity()) {
    form.reportValidity(); // triggers the native error bubbles
    return;
  }

  try {
    const response = await fetch('/api/signup', { method: 'POST', body: new FormData(form) });
    const result = await response.json();

    if (response.ok) {
      window.location.href = '/welcome';
    } else {
      showError(result.message);
    }
  } catch {
    showError('Could not reach the server. Check your connection.');
  }
});

form.checkValidity() runs every constraint from phase 2 (required, pattern, minlength, your custom setCustomValidity calls) in one shot; reportValidity() shows the same native bubbles a real submit would have. Running both before the fetch call means the built-in and server checks stay layered instead of the JavaScript path silently skipping past HTML validation.

Check your understanding:

[
  {
    "q": "Why does a signup form use method=\"post\" instead of \"get\"?",
    "choices": ["POST is faster than GET", "GET puts the data in the URL, exposing it in history and logs", "GET forms cannot include a password field"],
    "answer": 1,
    "explain": "GET appends form data to the URL as a query string - fine for a search, unacceptable for a password."
  },
  {
    "q": "What does FormData.get('agree') return if the checkbox was left unchecked?",
    "choices": ["false", "\"\" (empty string)", "null"],
    "answer": 2,
    "explain": "An unchecked checkbox contributes no field at all to FormData, so get() returns null rather than a false value."
  },
  {
    "q": "When does fetch's returned promise reject?",
    "choices": ["On any 4xx or 5xx response", "Only on a network-level failure, like no connection", "Whenever response.ok is false"],
    "answer": 1,
    "explain": "fetch resolves normally even for error status codes - you check response.ok yourself. The promise only rejects on network failures."
  }
]

Where to go next

The form now renders accessibly, validates on both sides, and submits without a page reload. What happens after the browser gets that response - how it turns HTML, CSS, and the DOM into pixels on screen in the first place - is the subject of How the Browser Renders a Page.


← Phase 2: Validation: Built-in vs. Custom · Guide overview

Check your understanding 3 questions

1. Why does a signup form use method="post" instead of "get"?

2. What does FormData.get('agree') return if the checkbox was left unchecked?

3. When does fetch's returned promise reject?