splitforms.com
← Back to the journal

How to send form data to an API (fetch, JSON and FormData)

Send HTML form data to any REST API: native POST, fetch with FormData, or JSON with auth headers. Copy-paste code plus fixes for CORS and 415 errors.

Start free — 500 submissionsSee pricing →No card, no time limit. Pro is $5/mo.
Short answer
To send form data to an API, listen for the form's submit event, call event.preventDefault(), build a FormData object from the form, and POST it with fetch(). Send the FormData as-is for multipart/form-data, or convert it with Object.fromEntries() and JSON.stringify() when the API expects JSON. Keep API secret keys on a server, never in the browser.
Try it on your own form

Point any HTML form at one endpoint. 500 free submissions — no card, no time limit — and unlimited forms.

Create free account

Last reviewed . Code and headers tested in Chrome 154.0.8037.97 and Safari 26.6.2 on macOS 26.6.2.

Which Content-Type should I use to send form data to an API?

Use the encoding the API documents: FormData for multipart, URLSearchParams for URL-encoded, or a JSON string with Content-Type: application/json. Only the JSON row needs a header you write yourself.

Key facts: form data encodings for fetch
The API expectsBody to buildContent-TypeHow the server parses it
multipart/form-dataThe form has a file input, or the API documents multipart.new FormData(form)MDN: FormDataDon't set one. The browser sends multipart/form-data; boundary=----WebKitFormBoundary….await request.formData() (Next.js route handlers, Workers); multer or busboy (Express); $_POST and $_FILES (PHP)
application/x-www-form-urlencodedOAuth token endpoints and older form handlers; no files.new URLSearchParams(new FormData(form))MDN: URLSearchParamsDon't set one. The browser sends application/x-www-form-urlencoded;charset=UTF-8.await request.formData(); express.urlencoded(); $_POST (PHP)
application/jsonMost REST APIs; no files.JSON.stringify(Object.fromEntries(new FormData(form)))MDN: Object.fromEntries()Set Content-Type: application/json yourself.await request.json(); express.json(); json_decode(file_get_contents('php://input'), true) (PHP)
  • Object.fromEntries() keeps only the last value of a repeated name, so two checked boxes named topic arrive as one. Use formData.getAll(name) for those fields. Files can't travel in JSON at all.
  • Forget the JSON header and fetch labels a string body text/plain;charset=UTF-8, which strict JSON APIs answer with 415 Unsupported Media Type.
  • Setting multipart/form-data by hand drops the boundary, and the server reads an empty body (why).

Chrome 154.0.8037.97 and Safari 26.6.2 sent exactly these Content-Type values in our test on October 2, 2026.

How do I send form data to an API?

Every modern browser supports the same four steps: stop the default submit, read the fields with FormData, encode them the way the API expects, and fetch() them. This example sends JSON to /api/leads, which can be your own endpoint or any REST API that accepts JSON.

<form id="lead-form">
  <label>Name <input name="name" required></label>
  <label>Email <input name="email" type="email" required></label>
  <label>Message <textarea name="message"></textarea></label>
  <button type="submit">Send</button>
  <p id="form-status" role="status"></p>
</form>

<script>
const form = document.getElementById('lead-form');
const statusEl = document.getElementById('form-status');

function showError(message) {
  statusEl.textContent = 'Could not send: ' + message;
}

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const body = JSON.stringify(Object.fromEntries(new FormData(form)));

  try {
    const res = await fetch('/api/leads', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body,
      signal: AbortSignal.timeout(10000),
    });

    if (!res.ok) {
      const err = await res.json().catch(() => ({}));
      showError(err.message || res.status);
      return;
    }

    form.reset();
    statusEl.textContent = 'Thanks, your message was sent.';
  } catch (err) {
    // Timeout: Chrome throws TimeoutError, Safari AbortError.
    // Network or CORS failure: TypeError.
    const timedOut = err.name === 'TimeoutError' || err.name === 'AbortError';
    showError(timedOut
      ? 'the request timed out. Please try again.'
      : 'network error. Please try again.');
  }
});
</script>

What each part does:

  • e.preventDefault() stops the browser from navigating away, so your script owns the request.
  • new FormData(form) collects every field that has a name attribute. The FormData in JavaScript guide covers the rest of its API.
  • Object.fromEntries() and JSON.stringify() turn it into a JSON string. If the API accepts multipart, skip both and pass the FormData straight to body.
  • AbortSignal.timeout(10000) cancels the request after 10 seconds instead of leaving the visitor waiting. The error name differs by browser: Chrome 154 throws TimeoutError: signal timed out and Safari 26.6.2 throws AbortError: Fetch is aborted, so the catch checks both names.
  • fetch() only rejects on network failures. A 400 or 500 still resolves, so always check res.ok.

Which method should I use?

Use fetch + JSON for REST APIs that document JSON, fetch + FormData when the form has file inputs, and a native form action when the form has to work without JavaScript.

MethodPage reloadsJavaScript neededFilesCustom headers/authCORS preflight
Native form actionYesNoYes (multipart)NoNo
fetch + FormDataNoYesYesYesOnly with custom headers
fetch + JSONNoYesNoYesYes
AxiosNoYesYesYesSame as fetch

How do I send form data to an API without JavaScript?

Point the form's action at the API and set method="post". The browser does the rest.

<form action="https://api.example.com/leads" method="post">
  <label>Name <input name="name" required></label>
  <label>Email <input name="email" type="email" required></label>
  <label>Message <textarea name="message"></textarea></label>
  <button type="submit">Send</button>
</form>

The browser encodes the fields as application/x-www-form-urlencoded (or multipart/form-data when you add enctype="multipart/form-data" for file inputs), sends the POST and then shows whatever the API returns. A good endpoint answers with a 303 redirect to a thank-you page; otherwise visitors land on raw JSON.

Because this is a page navigation, not a script reading a response, CORS doesn't apply. The trade-offs: no JSON body, no custom headers or auth, and a full page load. The HTML form action attribute guide covers the attribute in depth.

How do I POST FormData with fetch?

Pass the FormData object as the body. This sends multipart/form-data, the only encoding that carries files, so use it whenever the form has a file input.

const form = document.getElementById('upload-form');

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

  const res = await fetch('https://api.example.com/leads', {
    method: 'POST',
    body: new FormData(form), // no Content-Type header: the browser sets it
    signal: AbortSignal.timeout(30000), // uploads need a longer timeout
  });

  if (!res.ok) {
    showError('Upload failed (' + res.status + ')');
  }
});

Server body limits matter here: many hosts cap request bodies (Vercel functions at 4.5 MB, per Vercel's limits page), so check file sizes in the browser before sending. The file uploads for contact forms guide walks through size checks and accepted types.

Why shouldn't I set Content-Type for FormData?

A multipart body is split into parts by a random boundary string, and that boundary has to appear in the header. Only the browser knows it, so let the browser write the header.

// Wrong: no boundary, so the server reads an empty body
fetch(url, {
  method: 'POST',
  headers: { 'Content-Type': 'multipart/form-data' },
  body: new FormData(form),
});

// Right: the browser sends
// Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...
fetch(url, { method: 'POST', body: new FormData(form) });

If your server logs an empty body or "missing boundary", a hand-written Content-Type header is the first thing to check. The same goes for Axios: pass the FormData and leave the header alone.

How do I send form data as JSON?

Object.fromEntries(new FormData(form)) works for simple forms, but it keeps only the last value when several fields share a name, such as checkbox groups and multi-selects. This helper keeps all of them:

function formToJson(form) {
  const data = new FormData(form);
  const out = {};
  for (const key of new Set(data.keys())) {
    const values = data.getAll(key);
    out[key] = values.length > 1 ? values : values[0];
  }
  return JSON.stringify(out);
}

await fetch('/api/leads', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: formToJson(form),
});

Files can't travel in JSON: a File object serializes to {}. For uploads, send multipart instead.

How do I send URL-encoded form data with fetch?

Some APIs, including OAuth token endpoints and older form handlers, want application/x-www-form-urlencoded, the same format a native form post uses. Wrap the form data in URLSearchParams:

const res = await fetch('https://api.example.com/leads', {
  method: 'POST',
  body: new URLSearchParams(new FormData(form)),
});
// fetch sets Content-Type: application/x-www-form-urlencoded;charset=UTF-8

Like FormData, URLSearchParams sets its own Content-Type and is a CORS "simple" body, so it doesn't trigger a preflight. It can't carry files.

How do I send form data with Axios?

Axios turns a plain object into JSON and sets the header for you. Pass a FormData instead and it sends multipart.

import axios from 'axios';

async function submitForm(form) {
  try {
    const { data } = await axios.post(
      'https://api.example.com/leads',
      Object.fromEntries(new FormData(form)),
      { timeout: 10000 }
    );
    return { success: true, data };
  } catch (error) {
    if (error.code === 'ECONNABORTED') {
      return { success: false, error: 'Request timed out' };
    }
    // Axios rejects on 4xx/5xx; the API's body is on error.response
    return {
      success: false,
      error: error.response?.data?.message ?? error.message,
    };
  }
}

Unlike fetch, Axios rejects on non-2xx statuses. It's worth it if your app already uses it; for a single form, fetch needs no dependency.

How do I read the error an API sends back?

Most APIs explain a failure in the response body. Read it once, based on the response's own Content-Type, and fall back to the status code:

async function readError(res) {
  const type = res.headers.get('content-type') || '';
  if (type.includes('application/json')) {
    const body = await res.json().catch(() => ({}));
    return body.message || body.error || 'Request failed (' + res.status + ')';
  }
  const text = await res.text().catch(() => '');
  return text.slice(0, 200) || 'Request failed (' + res.status + ')';
}

const res = await fetch('/api/leads', { method: 'POST', body: new FormData(form) });
if (!res.ok) showError(await readError(res));
  • 400 / 422: validation failed. Show the API's message next to the form.
  • 401 / 403: missing or wrong credentials, a server-side problem, not the visitor's.
  • 413: the body is too large, usually an attached file.
  • 415: wrong body format for this API (see the Content-Type table).
  • 429: rate limited. Ask the visitor to wait a minute.
  • 5xx: the API failed. Keep the input and let the visitor try again.

What does each fetch error mean?

Each common error has one cause worth checking first. The one-line answers below link to the post that covers each error in full, with every browser's exact wording.

  • Access to fetch at '…' has been blocked by CORS policy (console): the API didn't allow your origin, or didn't answer the OPTIONS preflight a JSON POST needs. Fix the CORS error.
  • Failed to fetch / Load failed / NetworkError when attempting to fetch resource. (Chrome / Safari / Firefox): no readable response came back. The network failed, the URL is wrong, or CORS blocked it; the console line says which. Tell them apart.
  • 415 Unsupported Media Type: the body's Content-Type isn't one the API accepts. Match a row of the table above, or see wrong Content-Type fixes.
  • Unexpected token '<', "<!DOCTYPE "... is not valid JSON (Chrome) or The string did not match the expected pattern. (Safari): you called res.json() on an HTML page, usually the host's 404 or 405 error page. Why the API returned HTML.
  • 405 Method Not Allowed: the URL only answers GET, which is every page on a static host. Fix the 405.
  • 401 Unauthorized: the key or token is missing or wrong, a server-side problem. Fix the 401.

Tested in Chrome 154.0.8037.97 and Safari 26.6.2 on macOS 26.6.2, on . Firefox wasn't part of that run, so Firefox wording is quoted from Firefox's own source or MDN, linked in each row.

Calling an API that needs a secret key

Anything in browser JavaScript is public, including an Authorization header. When an API needs a secret key, post the form to your own server route and let the server add the key. In Next.js that's a route handler:

// app/api/leads/route.ts
export async function POST(request: Request) {
  const data = await request.json().catch(() => null);

  // Validate here: never forward whatever the browser sent unchecked
  if (!data || typeof data.email !== 'string' || !data.email.includes('@')) {
    return Response.json({ message: 'A valid email is required.' }, { status: 400 });
  }

  const upstream = await fetch('https://api.example.com/leads', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.LEADS_API_KEY}`,
    },
    body: JSON.stringify({
      name: data.name,
      email: data.email,
      message: data.message,
    }),
    signal: AbortSignal.timeout(10000),
  });

  if (!upstream.ok) {
    return Response.json(
      { message: 'Could not save your message. Please try again.' },
      { status: 502 }
    );
  }

  return Response.json({ ok: true });
}

The browser code from the first example posts to /api/leads unchanged. The key lives only in process.env on the server, and because the browser talks to its own origin, CORS never comes up. A serverless function or a Server Action works the same way. The exception is a public, submit-only key, like a form backend's access_key: it can only create submissions, so it is designed to sit in your HTML.

How do I fix CORS errors when posting a form to an API?

CORS is enforced by the browser, which is why the same request works in curl or Postman and fails on your page. The browser treats form posts two ways:

  • Simple requests: a POST whose body is FormData, URLSearchParams or plain text, with no custom headers. The browser sends it straight away, but your script can only read the response if the API returns Access-Control-Allow-Origin.
  • Preflighted requests: anything with Content-Type: application/json or an Authorization header. The browser first sends an OPTIONS request, and the API must answer it with the allowed origin, method and headers before the POST goes out.
# What the API has to return for a JSON POST from https://yoursite.com
Access-Control-Allow-Origin: https://yoursite.com
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization

If you own the API, add those headers, including on the OPTIONS response. If you don't, proxy through your own route like the /api/leads handler above: server-to-server requests aren't subject to CORS. mode: 'no-cors' is not a fix. It hides the response from your script and won't send a JSON Content-Type. The CORS error fix guide quotes each browser's exact message and walks through every fix.

How do I send form data to an API in React or Next.js?

The same pattern works in a component. Capture the form element before the first await, because React clears e.currentTarget once the handler yields.

'use client';

import { useState } from 'react';

export function ContactForm() {
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = e.currentTarget;
    setStatus('loading');

    try {
      const res = await fetch('/api/leads', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(Object.fromEntries(new FormData(form))),
        signal: AbortSignal.timeout(10000),
      });
      if (!res.ok) throw new Error(String(res.status));
      form.reset();
      setStatus('success');
    } catch {
      setStatus('error');
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? 'Sending...' : 'Send'}
      </button>
      {status === 'success' && <p>Thanks! We'll be in touch.</p>}
      {status === 'error' && <p>Something went wrong. Please try again.</p>}
    </form>
  );
}

Should I validate form data before sending it?

Yes, in the browser for fast feedback, and again on the server, because anyone can post to an endpoint without your form:

// Client-side: HTML5 validation (built in)
<input name="email" type="email" required minlength="5" />

// Client-side: custom validation
form.addEventListener('submit', (e) => {
  const email = form.email.value;
  if (!email.includes('@')) {
    e.preventDefault();
    showError('Please enter a valid email address');
  }
});

// Server-side: check required fields, formats and lengths again
// in your route handler before forwarding or storing anything.

Never trust client-side validation alone. The API must always re-validate.

What should every form submission handle?

  1. Loading state: disable the submit button during the request to prevent double submits.
  2. Timeout: pass signal: AbortSignal.timeout(10000) to fetch, as in the first example.
  3. Network errors: catch the fetch rejection and show a plain "Please try again" message.
  4. API errors: check res.ok and show the message from the response body.
  5. Keep the input: only reset the form after a success, so a visitor can press Send again after a failure.

What if you don't want to build the API?

If the endpoint only needs to receive a form, store it and email you, a form backend replaces the route handler. splitforms is ours: a contact form API at https://splitforms.com/api/submit that works with every method above.

<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
  <input type="hidden" name="subject" value="New submission" />
  <input type="hidden" name="redirect" value="https://yoursite.com/thanks" />

  <label>
    Name
    <input name="name" required />
  </label>
  <label>
    Email
    <input name="email" type="email" required />
  </label>
  <label>
    Message
    <textarea name="message" required></textarea>
  </label>

  <input name="botcheck" type="checkbox" style="display:none" tabindex="-1" autocomplete="off" />
  <button type="submit">Send</button>
</form>
  • It accepts JSON, multipart/form-data and URL-encoded bodies. Anything else gets a 415 that names the accepted formats.
  • The access_key is a public, submit-only key. It can't read submissions, so it's safe in HTML.
  • CORS is open to fetch with JSON from any origin, and you can lock a form to your own domains in its settings.
  • A native post with the hidden redirect field gets a 303 to your thank-you page. A fetch call gets JSON back instead, with the validated redirect URL included so your script can navigate.
  • Errors come back as JSON with a message, so the readError helper above works unchanged.
  • The botcheck honeypot plus server-side spam scoring filter bots without a CAPTCHA.

The Free plan covers 500 submissions total; file uploads need a paid plan, from $5/month. Every field and response code is in the API reference.

How do I keep form submissions secure?

  • HTTPS only. Never send form data over plain HTTP.
  • Keep secrets on the server. Secret API keys belong in server environment variables, behind a route like /api/leads. Public submit-only keys, such as a form backend's access_key, only allow submissions, not data retrieval.
  • Rate limiting. Limit your own route per IP address. splitforms rate-limits by IP and access key.
  • Spam protection. Add a hidden honeypot field such as botcheck and reject submissions that fill it. splitforms adds server-side spam scoring on top.
  • CORS. On your own API, allow only the origins that need it rather than * for authenticated endpoints.

Frequently asked questions

How do I convert FormData to JSON?

Use JSON.stringify(Object.fromEntries(new FormData(form))) and send it with a Content-Type: application/json header. Fields with several values, like checkbox groups, need formData.getAll(name), and files can't go in JSON, so use multipart for uploads.

Why shouldn't I set Content-Type when posting FormData with fetch?

The browser has to add multipart/form-data together with a generated boundary. If you set the header yourself, the boundary is missing and the server can't parse the body, which is the most common cause of empty request bodies.

How do I send form data to an API that needs an API key?

Never put a secret key in browser JavaScript. Post the form to your own server route (a Next.js route handler, serverless function or Server Action), which adds the Authorization header and forwards the request. Public, submit-only keys, like a form backend's access_key, are designed to be visible.

What does a 415 Unsupported Media Type error mean?

The API expected a different body format than you sent, usually JSON when you sent form-encoded data, or the reverse. A common cause is a JSON string sent without a Content-Type header: fetch then labels it text/plain;charset=UTF-8. Match the Content-Type header and body to what the API documents.

How do I upload files to an API from an HTML form?

Put an input type="file" in the form, build new FormData(form) and POST it with fetch without setting Content-Type; for a native post, add enctype="multipart/form-data". Watch your host's request body limit; Vercel documents a 4.5 MB cap for its functions at vercel.com/docs/functions/limitations.

Can I send form data to an API without JavaScript?

Yes, with the form's action and method="post". The limits: the body is url-encoded or multipart only (no JSON), you can't add custom headers or auth, and the browser navigates to the response.

How do I add a timeout to a fetch form submission?

Pass signal: AbortSignal.timeout(10000) to fetch and catch both error names: in our October 2, 2026 test, Chrome 154 rejected with TimeoutError and Safari 26.6.2 with AbortError. Then show a 'Please try again' message.

Should I use FormData or JSON to send form data?

Send FormData (multipart/form-data) when the form has file inputs or the API documents multipart. Send JSON when the API expects application/json, which most REST APIs do. Some endpoints, including form backends like splitforms, accept both, plus URL-encoded bodies.

Ship your form API today

A form API that just works.
splitforms gives you a production-ready API endpoint, spam filtering, email delivery, webhooks, and a dashboard — all free for 500 submissions total. No credit card.
Get your API key free →

Keep reading

Related articles

More practical guidance from tutorials.

Browse the journal →
Tutorials

FormSubmit File Uploads: How Attachments Work (and Their Limits)

Yes, FormSubmit.co supports file attachments: set enctype="multipart/form-data" and add a fi…

8 min readRead →
Tutorials

React Hook Form + Zod: Schema Validation Step by Step (2026)

Wire Zod into React Hook Form with zodResolver: install, define a schema, infer types, show…

10 min readRead →
Tutorials

React Hook Form reset(): Clear, Reset and Repopulate a Form

How reset() works in React Hook Form: clearing after submit, resetting to new default values…

8 min readRead →

Explore this topic

Start with the overview, then move into focused guides.

OverviewWhat Is a Form Backend? (Complete Guide)Start here →GuideSelf-Hosted vs SaaS Form BackendRead →GuideForm Backend vs Form BuilderRead →GuideWhere Do Form Submissions Go?Read →ReferenceForm backend guideOpen →

Building forms with ChatGPT, Claude, Cursor, or v0? Connect the native MCP server and give your agent a production form backend.

Explore the MCP server →

Give your form a production backend.

One endpoint adds delivery, spam filtering, storage, and integrations. Start with 500 free submissions — no card, no time limit.

Create free accountRead the docs →
Secure checkoutSSL encryptionPrivacyProtected
VISAAMERICANEXPRESSstripe