Multipart Parser Tutorial
This tutorial walks through parsing multipart file uploads from scratch using the low-level multipart-parser package. By the end you will know how to validate incoming requests, stream file parts, enforce upload limits, and handle errors gracefully.
Prerequisites
- A Remix V3 project with a route that accepts
POSTrequests - Basic understanding of HTML forms and the Fetch API
Step 1: Check if the Request is Multipart
Before parsing, confirm that the incoming request actually contains multipart data. The isMultipartRequest helper inspects the Content-Type header for you.
import {
isMultipartRequest,
getMultipartBoundary,
parseMultipartStream,
} from 'remix/multipart-parser'
export async function action({ request }: { request: Request }) {
if (!isMultipartRequest(request)) {
return new Response('Expected a multipart form submission', {
status: 415, // Unsupported Media Type
})
}
let boundary = getMultipartBoundary(request)
if (!boundary) {
return new Response('Missing multipart boundary', { status: 400 })
}
// We now have a valid boundary and can parse the body
}What is a boundary? When the browser encodes a multipart body, it picks a random string (like ------WebKitFormBoundary7MA4YWxk) and places it between each part. The same string is included in the Content-Type header so the parser knows how to split the body.
Step 2: Parse Parts with the Streaming API
Use parseMultipartStream to iterate over parts without buffering the entire body in memory. Each part has a headers property (a standard Headers object) and a body property (Uint8Array).
let boundary = getMultipartBoundary(request)!
let files: Array<{ name: string; size: number }> = []
for await (let part of parseMultipartStream(request.body!, boundary)) {
let disposition = part.headers.get('Content-Disposition') ?? ''
// Extract the field name and filename from Content-Disposition
let nameMatch = disposition.match(/name="([^"]+)"/)
let filenameMatch = disposition.match(/filename="([^"]+)"/)
if (!nameMatch) continue
let fieldName = nameMatch[1]
if (filenameMatch) {
// This is a file part
let filename = filenameMatch[1]
files.push({ name: filename, size: part.body.byteLength })
console.log(`Received file "${filename}" (${part.body.byteLength} bytes)`)
} else {
// This is a text field
let value = new TextDecoder().decode(part.body)
console.log(`Field "${fieldName}" = "${value}"`)
}
}Step 3: Enforce Upload Limits
Production applications must enforce limits to prevent abuse. Pass an options object to control maximum sizes and counts.
import {
parseMultipart,
getMultipartBoundary,
MaxFileSizeExceededError,
MaxPartsExceededError,
MaxTotalSizeExceededError,
} from 'remix/multipart-parser'
export async function action({ request }: { request: Request }) {
let boundary = getMultipartBoundary(request)!
try {
let parts = await parseMultipart(request.body!, boundary, {
maxFileSize: 5 * 1024 * 1024, // 5 MB per file
maxParts: 10, // No more than 10 fields
maxTotalSize: 20 * 1024 * 1024, // 20 MB total
maxHeaderSize: 8192, // 8 KB per part header
})
// Process parts...
return new Response('OK')
} catch (error) {
if (error instanceof MaxFileSizeExceededError) {
return new Response('File too large. Maximum size is 5 MB.', {
status: 413,
})
}
if (error instanceof MaxPartsExceededError) {
return new Response('Too many form fields.', { status: 400 })
}
if (error instanceof MaxTotalSizeExceededError) {
return new Response('Total upload size exceeds 20 MB.', {
status: 413,
})
}
throw error
}
}Step 4: Stream Files Directly to Storage
For large files, you may want to pipe part data directly to a destination rather than holding it in memory. The low-level MultipartParser class gives you this control.
import { MultipartParser, getMultipartBoundary } from 'remix/multipart-parser'
import { writeFile } from 'remix/fs'
export async function action({ request }: { request: Request }) {
let boundary = getMultipartBoundary(request)!
let parser = new MultipartParser(boundary, {
maxFileSize: 50 * 1024 * 1024,
})
let savedFiles: string[] = []
parser.onPart((part) => {
let disposition = part.headers.get('Content-Disposition') ?? ''
let filenameMatch = disposition.match(/filename="([^"]+)"/)
if (filenameMatch) {
let filename = filenameMatch[1]
let path = `./uploads/${Date.now()}-${filename}`
let file = new File([part.body], filename)
writeFile(path, file)
savedFiles.push(path)
}
})
for await (let chunk of request.body!) {
parser.write(chunk)
}
parser.end()
return Response.json({ files: savedFiles })
}Step 5: Build a Complete Upload Route
Here is a full example that ties everything together -- validation, limits, file saving, and a corresponding HTML form.
// app/routes/upload.ts
import {
isMultipartRequest,
getMultipartBoundary,
parseMultipartStream,
MaxFileSizeExceededError,
} from 'remix/multipart-parser'
import { writeFile } from 'remix/fs'
export async function loader() {
return new Response(
`<form method="post" enctype="multipart/form-data">
<label>Title: <input name="title" required /></label>
<label>Photo: <input name="photo" type="file" accept="image/*" /></label>
<button type="submit">Upload</button>
</form>`,
{ headers: { 'Content-Type': 'text/html' } },
)
}
export async function action({ request }: { request: Request }) {
if (!isMultipartRequest(request)) {
return new Response('Invalid content type', { status: 415 })
}
let boundary = getMultipartBoundary(request)!
let title = ''
let photoPath = ''
try {
for await (let part of parseMultipartStream(request.body!, boundary)) {
let disposition = part.headers.get('Content-Disposition') ?? ''
let nameMatch = disposition.match(/name="([^"]+)"/)
if (!nameMatch) continue
let fieldName = nameMatch[1]
let filenameMatch = disposition.match(/filename="([^"]+)"/)
if (fieldName === 'title') {
title = new TextDecoder().decode(part.body)
} else if (fieldName === 'photo' && filenameMatch) {
let filename = `${Date.now()}-${filenameMatch[1]}`
photoPath = `./uploads/${filename}`
await writeFile(photoPath, new File([part.body], filename))
}
}
} catch (error) {
if (error instanceof MaxFileSizeExceededError) {
return new Response('File too large', { status: 413 })
}
throw error
}
return Response.json({ title, photoPath })
}Summary
| Concept | What You Learned |
|---|---|
| Validation | Use isMultipartRequest and getMultipartBoundary before parsing |
| Streaming | parseMultipartStream yields parts without buffering the whole body |
| Limits | Pass maxFileSize, maxParts, maxTotalSize to prevent abuse |
| Error handling | Catch typed errors like MaxFileSizeExceededError |
| Low-level control | MultipartParser class for custom buffering and piping |
Next Steps
- Use form-data-parser for a higher-level API that returns standard
FormData - Store files with file-storage for a backend-agnostic solution
- See the API Reference for the complete list of exports