JSON with Multipart: File Uploads, Mixed Content, and FormData
Last updated:
Combining JSON metadata with file uploads requires multipart/form-data — a single request can carry a JSON part with structured metadata and a binary part with the file, avoiding two-request patterns that risk partial failure and race conditions. The multipart boundary separates parts: Content-Disposition: form-data; name="metadata" carries the JSON string, and Content-Disposition: form-data; name="file"; filename="photo.jpg" carries the binary. Fetching multipart in the browser uses FormData.append('metadata', JSON.stringify(obj)) — do NOT set Content-Type manually; let the browser add the boundary string automatically. This guide covers the browser FormData + JSON pattern, Express multer + JSON body, Next.js App Router file upload, S3 presigned POST with JSON fields, OpenAPI 3.0 schema design, and when to choose base64-in-JSON over multipart.
Why Multipart + JSON Instead of Two Requests
The naive approach to uploading a file with metadata is two sequential requests: POST the JSON first, get back an ID, then POST the file referencing that ID. This pattern introduces real problems. If the second request fails, the server holds orphaned metadata with no file. If the client disconnects between requests, cleanup logic is needed. Latency doubles because both round trips must complete before the server can process the complete record.
Multipart solves this by packing both the JSON metadata and the binary file into a single HTTP request body. The server receives everything atomically — either both arrive or neither does (assuming the connection stays open). For profile photo uploads, document management systems, and media APIs, this is the correct pattern. Common use cases: uploading an image alongside alt text and title, submitting a CV PDF with applicant JSON data, attaching a recording with transcription metadata, or creating a product with a photo in a single operation.
The latency benefit is roughly one RTT (round-trip time) per upload. On a 50ms connection, two requests cost at least 100ms of network overhead before any processing. A single multipart request cuts that to 50ms. At scale — thousands of uploads per minute — the cumulative saving is significant and the operational complexity of handling partial-upload cleanup disappears entirely.
Browser FormData + JSON Pattern
The browser FormData API is the standard way to assemble a multipart request. Append the JSON string as a named part, then append the file. Pass the FormData object directly to fetch() — never set Content-Type yourself.
// ─── Browser: send JSON metadata + file in one multipart request ─────────────
const fileInput = document.querySelector('#fileInput')
const file = fileInput.files[0]
const metadata = {
title: 'Profile photo',
altText: 'User avatar',
userId: 42,
}
const formData = new FormData()
// Append JSON as a string part — NOT as a Blob with application/json type
formData.append('metadata', JSON.stringify(metadata))
formData.append('file', file, file.name)
// CRITICAL: do NOT set Content-Type header
// fetch() will set: Content-Type: multipart/form-data; boundary=----FormBoundaryXYZ
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
// headers: { 'Content-Type': '...' } ← NEVER do this
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message)
}
const result = await response.json()
console.log(result.fileUrl)If you need to attach the JSON part with an explicit application/json content type (some server parsers require it), wrap it in a Blob:
// Alternative: JSON part with explicit content type
const metadataBlob = new Blob([JSON.stringify(metadata)], {
type: 'application/json',
})
formData.append('metadata', metadataBlob)
// The part will have: Content-Type: application/jsonExpress + Multer JSON Metadata Parsing
On the Express side, multer handles multipart parsing. Use upload.fields() to accept both named parts. After the middleware, req.files holds file buffers and req.body holds string fields — including your JSON metadata string.
import express from 'express'
import multer from 'multer'
import { z } from 'zod'
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp']
cb(null, allowed.includes(file.mimetype))
},
})
const MetadataSchema = z.object({
title: z.string().min(1).max(200),
altText: z.string().max(500).optional(),
userId: z.number().int().positive(),
})
const app = express()
app.post(
'/api/upload',
upload.fields([{ name: 'file', maxCount: 1 }, { name: 'metadata' }]),
(req, res) => {
// req.files['file'][0].buffer — file bytes in memory
// req.body.metadata — JSON string from form part
const files = req.files as Record<string, Express.Multer.File[]>
if (!files?.file?.[0]) {
return res.status(400).json({ message: 'Missing file part' })
}
// Parse and validate the JSON metadata part
let metadata: z.infer<typeof MetadataSchema>
try {
const raw = JSON.parse(req.body.metadata ?? '{}')
metadata = MetadataSchema.parse(raw)
} catch (err) {
return res.status(400).json({ message: 'Invalid metadata JSON', detail: String(err) })
}
const fileBuffer = files.file[0].buffer
const mimeType = files.file[0].mimetype
// Process upload — e.g., save to S3 or disk
console.log('metadata:', metadata)
console.log('file size:', fileBuffer.byteLength, 'mime:', mimeType)
return res.json({ success: true, title: metadata.title })
}
)
app.listen(3000)Next.js App Router File Upload with JSON
Next.js App Router route handlers expose request.formData() — a native Web API method that returns a FormData object. No additional middleware is needed. Call .get() to extract each named part.
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { writeFile } from 'fs/promises'
import path from 'path'
import { z } from 'zod'
const MetadataSchema = z.object({
title: z.string().min(1).max(200),
userId: z.number().int().positive(),
})
export async function POST(request: NextRequest) {
let formData: FormData
try {
formData = await request.formData()
} catch {
return NextResponse.json({ message: 'Invalid multipart body' }, { status: 400 })
}
// Extract the file part
const file = formData.get('file') as File | null
if (!file || file.size === 0) {
return NextResponse.json({ message: 'Missing file part' }, { status: 400 })
}
// Extract and parse the JSON metadata part
const metadataRaw = formData.get('metadata')
if (typeof metadataRaw !== 'string') {
return NextResponse.json({ message: 'Missing metadata part' }, { status: 400 })
}
let metadata: z.infer<typeof MetadataSchema>
try {
metadata = MetadataSchema.parse(JSON.parse(metadataRaw))
} catch (err) {
return NextResponse.json({ message: 'Invalid metadata', detail: String(err) }, { status: 400 })
}
// Write file to disk (or stream to S3)
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filePath = path.join('/tmp', file.name)
await writeFile(filePath, buffer)
return NextResponse.json({
success: true,
title: metadata.title,
fileName: file.name,
size: file.size,
})
}
// Optional: disable Next.js body size limit for large file uploads
// export const config = { api: { bodyParser: false } } // Pages Router only
// App Router handles this automaticallyFor large files, avoid loading the entire buffer into memory. Instead, stream the file.stream() directly to your storage backend using the AWS SDK or a writable stream. The File object returned by formData.get() implements the Web Streams API.
S3 Presigned POST with JSON Policy
S3 presigned POST lets clients upload directly to S3 without routing file bytes through your server. The server generates a presigned policy — a JSON object encoding the conditions — signs it, and returns the URL and fields to the client. The client then POSTs the file directly to S3 in a multipart request.
// ─── Server: generate presigned POST policy ──────────────────────────────────
import { S3Client, createPresignedPost } from '@aws-sdk/s3-presigned-post'
const s3 = new S3Client({ region: 'us-east-1' })
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const fileName = searchParams.get('fileName') ?? 'upload'
const contentType = searchParams.get('contentType') ?? 'application/octet-stream'
const key = `uploads/${Date.now()}-${fileName}`
const { url, fields } = await createPresignedPost(s3, {
Bucket: process.env.S3_BUCKET!,
Key: key,
Conditions: [
// JSON conditions array — limits what the client can send
['content-length-range', 1, 10 * 1024 * 1024], // 1 byte to 10 MB
['starts-with', '$Content-Type', 'image/'], // only images
{ 'x-amz-meta-user-id': '42' }, // custom metadata field
],
Fields: {
'Content-Type': contentType,
'x-amz-meta-title': 'Profile photo',
'x-amz-meta-user-id': '42',
},
Expires: 300, // 5 minutes
})
return NextResponse.json({ url, fields, key })
}
// ─── Client: upload directly to S3 ──────────────────────────────────────────
async function uploadToS3(file: File) {
// 1. Get presigned policy from your API
const res = await fetch(
`/api/presign?fileName=${encodeURIComponent(file.name)}&contentType=${encodeURIComponent(file.type)}`
)
const { url, fields } = await res.json()
// 2. Build multipart form — fields from policy MUST come before the file
const formData = new FormData()
for (const [key, value] of Object.entries(fields)) {
formData.append(key, value as string)
}
formData.append('file', file) // 'file' must be LAST
// 3. POST directly to S3 — no Authorization header needed
const uploadRes = await fetch(url, {
method: 'POST',
body: formData,
// Do NOT set Content-Type — browser must add boundary
})
if (!uploadRes.ok) {
const xml = await uploadRes.text()
throw new Error(`S3 upload failed: ${xml}`)
}
return `${url}${fields.key}`
}OpenAPI 3.0 Multipart JSON Schema
Documenting multipart endpoints in OpenAPI 3.0 requires declaring each part as a property of the multipart/form-data schema. For JSON parts, use contentMediaType: application/json (or inline the schema). For file parts, use format: binary.
# OpenAPI 3.0 — multipart/form-data with JSON metadata and file upload
paths:
/api/upload:
post:
summary: Upload file with JSON metadata
operationId: uploadFile
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- file
- metadata
properties:
file:
type: string
format: binary
description: Binary file to upload (max 10 MB)
metadata:
type: string
contentMediaType: application/json
description: JSON string with file metadata
# Inline the JSON schema for documentation
example: '{"title":"Profile photo","userId":42}'
encoding:
file:
contentType: image/jpeg, image/png, image/webp
metadata:
contentType: application/json
responses:
'200':
description: Upload successful
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
fileUrl:
type: string
format: uri
'400':
description: Invalid request (bad JSON, missing parts, file too large)Some API tooling (Swagger UI, Redoc) renders the contentMediaType annotation as a JSON editor for that part. Alternatively, reference a $ref schema component inside the properties.metadata block to reuse the same schema across multiple endpoints. For AWS API Gateway, use the multipart/form-data binary media type setting and handle parsing in your Lambda function.
Alternatives: Base64 JSON vs Multipart
Base64 encoding embeds binary file data as a text string inside a JSON payload. This avoids multipart entirely and keeps a strictly application/json content type throughout your API. The tradeoff: base64 inflates file size by approximately 33%, requires the entire file to be buffered in memory on both client and server, and prevents streaming.
// ─── Base64 approach (only practical for small files) ────────────────────────
async function uploadAsBase64(file: File) {
const arrayBuffer = await file.arrayBuffer()
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)))
const response = await fetch('/api/upload-json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Profile photo',
userId: 42,
file: {
name: file.name,
type: file.type,
data: base64, // 33% larger than the original binary
},
}),
})
return response.json()
}
// ─── Server: decode base64 back to binary ────────────────────────────────────
// const fileBuffer = Buffer.from(body.file.data, 'base64')
// // fileBuffer.length is ~33% larger than the original before base64 reductionWhen to use base64 in JSON: the file is under 10 KB (icons, thumbnails, small signatures), your API must remain strictly application/json (e.g., a JSON-RPC or GraphQL API), or you are targeting a client environment with poor FormData support. When to use multipart: any file over 10 KB, when you need streaming, when you want to enforce file size limits at the HTTP layer rather than in application code, or when you are uploading to S3 presigned POST.
A practical size limit rule: if your base64-encoded string would exceed 50 KB inside the JSON body, switch to multipart. Base64 in JSON also breaks HTTP-level compression efficiency and makes payload logging awkward — huge binary strings in your structured logs.
Definitions
- Multipart boundary
- A unique string generated by the browser or HTTP client that appears between each part in a
multipart/form-databody, delimited with--boundaryand terminated with--boundary--. The boundary is included in theContent-Typeheader so the server knows where each part begins and ends. - Form data part
- A single section within a multipart body, consisting of part headers (including
Content-Dispositionand optionallyContent-Type) followed by the part body. Each call toFormData.append()in the browser creates one part. - Content-Disposition
- An HTTP header used within multipart parts to name the field and, for file parts, provide the original filename. Example:
Content-Disposition: form-data; name="metadata"for a JSON part, orContent-Disposition: form-data; name="file"; filename="photo.jpg"for a file part. - Presigned POST
- An AWS S3 mechanism where the server generates a signed policy document (JSON) and URL, allowing a client to upload directly to S3 without AWS credentials. The policy encodes conditions — allowed bucket, key prefix, file size range, content type — and is attached as form fields alongside the file in a multipart POST.
- Base64 encoding
- A binary-to-text encoding scheme that represents arbitrary binary data using 64 printable ASCII characters (A-Z, a-z, 0-9, +, /). Commonly used to embed binary files in JSON strings. Increases size by approximately 33% and requires full buffering — not suitable for large file uploads.
- MIME type
- A two-part identifier for file and data formats, such as
image/jpeg,application/json, ormultipart/form-data. In multipart requests, each part may declare its own MIME type via aContent-Typeheader. Thefile.typeproperty in the browser reports the MIME type of a selected file. - Binary part
- A multipart section that carries raw binary data (file bytes) rather than text. Binary parts are transmitted as-is in the HTTP body, delimited by the multipart boundary. Unlike base64, binary parts do not inflate file size and can be streamed directly to storage.
FAQ
How do I send JSON and a file in the same request?
Use multipart/form-data. In the browser, create a FormData object, call formData.append('metadata', JSON.stringify(obj)) to add the JSON string part, and formData.append('file', fileInput.files[0]) to add the file. Pass the FormData to fetch() without setting Content-Type. On the server, parse both parts separately and call JSON.parse() on the metadata string.
Why should I not set Content-Type manually for multipart?
The Content-Type header for multipart must include a boundary parameter that matches the delimiter used in the request body, e.g., Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXYZ. The browser generates this boundary string. If you set the header manually without the exact boundary, the server cannot locate part boundaries and the entire request body will fail to parse.
How do I parse JSON from a multipart request in Express?
Use multer with upload.fields([{name:'file'}, {name:'metadata'}]). After the middleware runs, req.body.metadata holds the JSON string from the form part. Call JSON.parse(req.body.metadata) to get the object. Always validate the result with Zod or Joi before using it — never assume the client sent valid JSON.
How do I handle file uploads in Next.js App Router?
In a route handler (app/api/upload/route.ts), call await request.formData() to get the FormData object. Use formData.get('file') to retrieve the file as a File object and formData.get('metadata') to retrieve the JSON string. Call JSON.parse() on the metadata string. To write to disk, use Buffer.from(await file.arrayBuffer()) and fs.writeFile().
How do I upload a file to S3 with JSON metadata?
Generate a presigned POST policy server-side with createPresignedPost() from @aws-sdk/s3-presigned-post. The Conditions array is a JSON structure encoding file size limits, allowed content types, and custom metadata fields. Return the url and fields to the client. Client-side, build a FormData with all policy fields appended first, then the file last, and POST directly to the S3 URL without an Authorization header.
What is the OpenAPI schema for multipart JSON requests?
In OpenAPI 3.0, define a requestBody with content: multipart/form-data. Under schema.properties, declare each part: file parts use type: string, format: binary; JSON parts use type: string, contentMediaType: application/json. Use the encoding map to specify the contentType for each part. This allows Swagger UI to render the correct input fields.
Should I use base64 or multipart for file uploads with JSON?
Use multipart for any file over 10 KB. Base64 inflates file size by ~33%, cannot be streamed, and wastes memory during encoding and decoding on both sides. Use base64 in JSON only when you need a strictly application/json API contract and the files are tiny (icons, small thumbnails). For production file upload systems, multipart is always the correct choice.
How do I validate JSON in a multipart request?
After calling JSON.parse() on the metadata string, validate the object with a runtime schema validator. With Zod: MetadataSchema.parse(obj) — throws a ZodError with field-level details if validation fails. With Joi: schema.validate(obj). Return a 400 Bad Request with the validation error details if the JSON is malformed or fails schema rules. Never trust multipart JSON metadata without validation.
Further reading and primary sources
- MDN FormData API — Browser FormData API reference on MDN
- multer – Node.js multipart middleware — Express multipart/form-data handling middleware
- AWS S3 Presigned POST — AWS S3 POST policy and presigned POST documentation
- OpenAPI 3.0 – File Upload — OpenAPI 3.0 file upload schema documentation
Related guides: JSON encode decode · JSON data validation · JSON security · REST API JSON response
Validate your JSON metadata before uploading
Use the Jsonic formatter to check your JSON metadata is valid before embedding it in a multipart request.
Open JSON Formatter