Skip to content

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 POST requests
  • 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.

ts
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).

ts
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.

ts
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.

ts
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.

ts
// 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

ConceptWhat You Learned
ValidationUse isMultipartRequest and getMultipartBoundary before parsing
StreamingparseMultipartStream yields parts without buffering the whole body
LimitsPass maxFileSize, maxParts, maxTotalSize to prevent abuse
Error handlingCatch typed errors like MaxFileSizeExceededError
Low-level controlMultipartParser class for custom buffering and piping

Next Steps

Released under the MIT License.