JSON to FormData: Convert and Send JSON as Multipart Form Data in JavaScript

FormData and JSON are the two primary ways to send data in HTTP POST requests, but they serve different purposes. JSON (Content-Type: application/json) is ideal for structured data without files. FormData (Content-Type: multipart/form-data) is required when uploading files alongside data. Converting a JSON object to FormData requires iterating every key and calling formData.append(key, value). Nested objects and arrays need flattening because FormData keys are flat strings. This guide covers a reusable objectToFormData() function that handles nested objects, arrays, and File/Blob values, plus when to use each format. Need to validate your JSON before sending it? Jsonic's formatter catches syntax errors instantly.

Need to inspect or validate your JSON payload before converting? Jsonic's formatter checks structure instantly.

Open JSON Formatter

Why convert JSON to FormData?

The short answer: you only need to convert JSON to FormData when your endpoint requires multipart/form-data encoding — most commonly because it needs to receive a file alongside text fields. When you call fetch() with a JSON body, the browser sends Content-Type: application/json and the server parses it as a UTF-8 string. File upload endpoints cannot accept this format because JSON.stringify() silently drops File and Blob objects (they serialize to ).

Use FormData when: the server expects multipart/form-data, you need to upload a File or Blob alongside JSON data, or you are working with an HTML form that already uses FormData. When you are sending pure structured data with no files, JSON.stringify() plus Content-Type: application/json is simpler, more compact, and always preferred. Only convert to FormData when the endpoint specifically requires multipart form encoding — check the API documentation before assuming it is necessary. For background on fetch() for JSON APIs, see the dedicated guide.

Basic JSON to FormData conversion

For a flat JSON object — one with no nested objects, arrays, or binary values — the conversion is a single loop. Iterate the object's entries with Object.entries() and append each key-value pair to a new FormData instance. The browser coerces numbers and booleans to strings automatically when appending, so no manual conversion is needed for primitive values.

const data = { name: 'Alice', age: 30, email: 'alice@example.com' }

const formData = new FormData()
for (const [key, value] of Object.entries(data)) {
  formData.append(key, value)
}

// Send with fetch — no Content-Type header needed (browser sets it with boundary)
await fetch('/api/users', {
  method: 'POST',
  body: formData,
})

The critical rule: do not manually set Content-Type: multipart/form-data in the fetch options. The browser must set this header itself because it needs to include the boundary parameter — a unique string that separates each field in the multipart body. If you set Content-Type manually without the boundary, the server receives a malformed request it cannot parse. Simply omit the Content-Type header entirely and the browser handles it correctly. To learn more about converting objects to JSON for the cases where FormData is not needed, see the companion guide.

Handle nested objects and arrays

FormData is fundamentally flat — every entry is a string key mapped to a string or binary value. When your JSON contains nested objects or arrays, you must serialize the nesting into flat string keys. Two conventions exist for this: bracket notation (address[city], tags[0]) used by PHP, Laravel, and Rails; and dot notation (address.city, tags.0) used by some REST APIs. Bracket notation is more widely supported and is what express.urlencoded() parses automatically, so it is the safer default.

The reusable objectToFormData() function below handles nested objects, arrays, File/Blob values, and skips null and undefined fields:

function objectToFormData(obj, formData = new FormData(), prefix = '') {
  for (const [key, value] of Object.entries(obj)) {
    const fieldName = prefix ? `${prefix}[${key}]` : key

    if (value === null || value === undefined) continue

    if (value instanceof File || value instanceof Blob) {
      formData.append(fieldName, value)
    } else if (Array.isArray(value)) {
      value.forEach((item, i) => {
        const arrayKey = `${fieldName}[${i}]`
        if (typeof item === 'object' && item !== null) {
          objectToFormData(item, formData, arrayKey)
        } else {
          formData.append(arrayKey, item)
        }
      })
    } else if (typeof value === 'object') {
      objectToFormData(value, formData, fieldName)
    } else {
      formData.append(fieldName, String(value))
    }
  }
  return formData
}

// Usage
const fd = objectToFormData({
  name: 'Alice',
  address: { city: 'Austin' },
  tags: ['a', 'b'],
})
// Appends: name=Alice, address[city]=Austin, tags[0]=a, tags[1]=b

The recursive call handles arbitrarily deep nesting. File and Blob instances are appended directly without stringification —FormData.append() accepts them natively and the browser encodes them as binary MIME parts. Primitive values are converted to strings with String(value) to avoid browser inconsistencies with non-string types.

Upload a file with JSON data

The most common reason to convert JSON to FormData is combining a file upload with structured text fields in a single request. Pass the File object from an <input type="file"> element directly as a value in your data object — objectToFormData() detects it and appends it as binary.

// HTML: <input type="file" id="avatar">
const file = document.getElementById('avatar').files[0]

const formData = objectToFormData({
  name: 'Alice',
  role: 'admin',
  avatar: file,   // File object — appended as binary
})

const response = await fetch('/api/profile', {
  method: 'POST',
  body: formData,
  // Do NOT set Content-Type — browser adds boundary automatically
})

On the server side, Express requires the multer middleware to parse multipart form data. express.json() and express.urlencoded() do not handle multipart/form-data bodies — they will leave req.body empty:

import multer from 'multer'
const upload = multer({ dest: 'uploads/' })

app.post('/api/profile', upload.single('avatar'), (req, res) => {
  console.log(req.body)  // { name: 'Alice', role: 'admin' }
  console.log(req.file)  // { fieldname: 'avatar', path: '...', ... }
})

The upload.single('avatar') call tells multer which field name contains the file. Text fields land in req.body as usual. For multiple files, use upload.array('photos', 10) or upload.any().

Read FormData back to a JSON object

After building a FormData instance, you can reconstruct a plain object from it using Object.fromEntries(). This works perfectly for flat FormData but only returns the last value for duplicate keys and leaves nested keys as raw strings (e.g., "address[city]"):

// Client-side: reconstruct object from FormData
const obj = Object.fromEntries(formData.entries())
// Note: only works for flat FormData — nested keys remain as strings like "address[city]"

// For nested: use a library or manual parsing
// With URLSearchParams pattern:
const params = new URLSearchParams(formData)
// Then parse bracket notation manually

Server-side, the framework handles this automatically. In Express with express.urlencoded({ extended: true }), bracket notation keys like address[city] are parsed into nested objects on req.body — you access them as req.body.address.city without any extra work. With multer, text fields in req.body are parsed the same way. In Next.js App Router, read the fields directly from the request: const data = await request.formData(); const name = data.get('name'). See the JSON.parse() guide for handling JSON strings you receive back from a server response.

JSON vs FormData: when to use each

The decision between JSON and FormData comes down to one question: does the request include a file or binary blob? If yes, use FormData. If no, use JSON. Everything else — nested objects, arrays, developer ergonomics, server-side parsing — is better with JSON. The table below covers every material difference:

JSON (application/json)FormData (multipart/form-data)
Nested objectsNative supportRequires bracket notation
File uploadNot possibleRequired
Array handlingNativeManual index keys
Server parsingexpress.json()multer / busboy
Browser Content-TypeYou set itBrowser sets with boundary
Size efficiencyCompact textLarger (MIME boundaries)
DebuggingEasy (JSON.stringify)Hard (binary boundary)

Rule: use JSON unless you need file upload. Use FormData for file upload plus mixed data. For a deeper look at JSON.stringify() and its options, see the dedicated guide.

TypeScript: typed objectToFormData()

Adding TypeScript types to objectToFormData() catches mistakes at compile time — for example, passing a non-serializable value or a deeply nested object that doesn't match the expected shape. The recursive type FormDataObject allows any depth of nesting while restricting leaf values to the types that FormData can handle:

type FormDataValue = string | number | boolean | File | Blob | null | undefined
type FormDataObject = { [key: string]: FormDataValue | FormDataValue[] | FormDataObject }

function objectToFormData(
  obj: FormDataObject,
  formData: FormData = new FormData(),
  prefix = ''
): FormData {
  for (const [key, value] of Object.entries(obj)) {
    const fieldName = prefix ? `${prefix}[${key}]` : key
    if (value === null || value === undefined) continue
    if (value instanceof File || value instanceof Blob) {
      formData.append(fieldName, value)
    } else if (Array.isArray(value)) {
      (value as FormDataValue[]).forEach((item, i) => {
        formData.append(`${fieldName}[${i}]`, item instanceof File ? item : String(item))
      })
    } else if (typeof value === 'object') {
      objectToFormData(value as FormDataObject, formData, fieldName)
    } else {
      formData.append(fieldName, String(value))
    }
  }
  return formData
}

The FormDataObject type is intentionally permissive about nesting depth but strict about leaf types. If your API contract is known, replace FormDataObject with a specific interface for better IDE autocomplete and stricter compile-time checks. Note that TypeScript does not check the runtime shape of data received from a server — if you need runtime validation, pair this with a schema library like Zod after parsing the response.

React file upload example

Here is a complete React component that collects a name and a file from the user, builds FormData using objectToFormData(), and posts it to an API endpoint. The form prevents submission if no file is selected, and the handler does not set Content-Type so the browser adds the boundary automatically:

function ProfileForm() {
  const [name, setName] = useState('')
  const [file, setFile] = useState<File | null>(null)

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    if (!file) return

    const formData = objectToFormData({ name, avatar: file })

    const res = await fetch('/api/profile', {
      method: 'POST',
      body: formData,
    })
    const data = await res.json()
    console.log(data)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} placeholder="Name" />
      <input type="file" onChange={e => setFile(e.target.files?.[0] ?? null)} />
      <button type="submit">Upload</button>
    </form>
  )
}

A few things to note: useState<File | null>(null) types the file state correctly for TypeScript. The e.target.files?.[0] ?? null expression safely handles the case where the user clears the file input. On the server side (Next.js App Router route handler), read the uploaded file with const data = await request.formData(); const avatar = data.get('avatar') as File. For a working fetch pattern without file upload, see the fetch() for JSON APIs guide.

Frequently asked questions

How do I convert JSON to FormData in JavaScript?

Iterate the JSON object's key-value pairs and call formData.append(key, value) for each one. For a flat object: const formData = new FormData(); for (const [k, v] of Object.entries(obj)) formData.append(k, v). For nested objects and arrays, you need a recursive function that serializes nested keys with bracket notation (address[city]). The objectToFormData() function in this guide handles nested objects, arrays, Files, Blobs, and skips null/undefined values. After building the FormData object, pass it as the body to fetch() without setting Content-Type — the browser automatically sets multipart/form-data with the correct boundary parameter. Setting Content-Type manually breaks multipart parsing on the server because the boundary string is missing. Use Jsonic's formatter to validate your JSON before converting.

Why can't I send a file with JSON?

JSON is a text format. JSON.stringify() converts a JavaScript object to a UTF-8 string. File and Blob objects are binary data — they cannot be represented as JSON strings without encoding, and JSON.stringify() drops them silently (they serialize to {}). The HTTP Content-Type: application/json header tells the server to parse the body as UTF-8 text. To send binary file data, you must use Content-Type: multipart/form-data, which encodes each field (text or binary) as a separate MIME part separated by a boundary string. FormData handles this encoding automatically in the browser. Alternatively, you can Base64-encode the file and include it as a JSON string ("fileData": "SGVsbG8..."), but this increases payload size by ~33% and requires decoding on the server — FormData is the correct solution for file uploads.

What is bracket notation in FormData?

FormData keys are plain strings with no concept of nesting. When sending a nested object like { address: { city: 'Austin' } } as FormData, the nested structure must be encoded into flat string keys. The bracket notation convention encodes nesting as address[city]. Arrays are encoded as tags[0], tags[1], etc. This convention is followed by PHP, Laravel, Rails, and most server-side frameworks — they automatically parse req.body.address.city when the key is address[city]. In Express.js, use express.urlencoded({ extended: true }) to enable bracket notation parsing. In Django/Python, the QueryDict parser handles it similarly. Dot notation (address.city) is an alternative convention used by some APIs, but bracket notation is more widely supported. You can learn about JSON.parse() for the reverse operation on the server side.

Should I use JSON or FormData for API requests?

Use JSON (application/json) for the vast majority of API requests: it supports nested objects natively, is easy to debug with JSON.stringify, has universal server-side parsing, and produces compact payloads. Use FormData (multipart/form-data) only when: (1) you need to upload a File or Blob alongside other data; (2) the server endpoint explicitly requires multipart form encoding (check the API documentation); or (3) you are submitting an HTML form element with enctype="multipart/form-data". For APIs that need both a file and structured data, the most common pattern is to send FormData with flat text fields plus the file — complex nesting in FormData is cumbersome. If the structured data is complex, consider two separate requests: one for the file upload and one for the JSON data. See JSON.stringify() for the JSON-first path.

How do I handle arrays when converting JSON to FormData?

Arrays must be serialized to flat keys. The bracket notation convention encodes tags: ['a', 'b'] as two entries: tags[0]=a and tags[1]=b. In objectToFormData(), iterate the array with index and call formData.append(`$${fieldName}[$${i}]`, item). Some servers (like PHP) accept tags[]=a&tags[]=b (without index) — use formData.append(`$${fieldName}[]`, item) for this style. You can also serialize the entire array as a JSON string: formData.append('tags', JSON.stringify(['a', 'b'])) — the server reads it as the string '["a","b"]' and must call JSON.parse() on it. Choose the convention that matches what your server expects; check the backend framework's documentation.

How do I read FormData sent to an Express server?

In Express.js, the middleware depends on the content type. For multipart/form-data (sent by FormData + fetch): use multer (npm install multer) — it parses text fields into req.body and files into req.file or req.files. Basic setup: app.post('/upload', multer().any(), (req, res) => { console.log(req.body, req.files) }). For application/x-www-form-urlencoded: use express.urlencoded({ extended: true }) — this parses bracket notation into nested objects. Do not use express.json() for FormData — it only handles application/json bodies and ignores multipart. In Next.js App Router, use the request's formData method: const data = await request.formData(); const name = data.get('name').

Ready to work with FormData?

Use Jsonic to validate your JSON before converting it to FormData. You can also explore fetch() for JSON APIs for the cases where FormData is not needed.

Open JSON Formatter