Skip to content

File Uploads

This guide covers everything you need to handle file uploads in Remix V3 -- from receiving files in a form submission to storing them on disk or in the cloud and serving them back to users.

How Multipart Form Data Works

When an HTML form includes file inputs, the browser uses a special encoding called multipart/form-data. Instead of sending all values as a single URL-encoded string, the browser packages each field as a separate "part" with its own headers and body.

A multipart request body looks like this:

------boundary123
Content-Disposition: form-data; name="title"

My Photo Album
------boundary123
Content-Disposition: form-data; name="cover"; filename="photo.jpg"
Content-Type: image/jpeg

(binary data)
------boundary123--

Each part is separated by a boundary string. Text fields contain their value as plain text. File fields include the original filename, content type, and the binary file data.

The enctype attribute is required

Without enctype="multipart/form-data" on the form tag, the browser sends the filename as text instead of the file contents. This is the most common mistake when building upload forms:

html
<!-- Correct -->
<form method="post" enctype="multipart/form-data">

<!-- Wrong -- files will not be uploaded -->
<form method="post">

Parsing Form Data

Remix provides the parseFormData function for handling multipart uploads:

ts
import { parseFormData, FileUpload } from 'remix/form-data-parser'

router.map(uploadRoute, async ({ request }) => {
  let formData = await parseFormData(request)

  let title = formData.get('title') as string
  let file = formData.get('cover') as FileUpload

  console.log(file.name)   // 'photo.jpg'
  console.log(file.type)   // 'image/jpeg'
  console.log(file.size)   // 1048576 (bytes)
})

If you are using the formData() middleware, it handles parsing automatically and makes the result available via context.get(FormData):

ts
import { formData } from 'remix/form-data-middleware'

let router = createRouter({
  middleware: [
    formData({
      maxFileSize: 10 * 1024 * 1024,  // 10 MB per file
      maxTotalSize: 50 * 1024 * 1024,  // 50 MB total
      maxFiles: 10,
      uploadHandler(fileUpload) {
        return fileUpload
      },
    }),
  ],
})

router.map(uploadRoute, async ({ context }) => {
  let data = context.get(FormData)
  let file = data.get('avatar') as File
  // ...
})

Upload Handlers

The uploadHandler option controls what happens to each uploaded file as it is received. This is where you decide whether files are kept in memory, streamed to disk, or sent directly to cloud storage.

Default (Memory)

Without an uploadHandler, files are stored entirely in memory as File objects. This is fine for small files but dangerous for large ones:

ts
formData() // Files held in memory

Custom Handler

The handler receives a FileUpload object and can return whatever you want the form field to resolve to:

ts
formData({
  async uploadHandler(fileUpload) {
    // Only handle files under 5 MB
    if (fileUpload.size > 5 * 1024 * 1024) {
      throw new Error('File too large')
    }

    // Store the file and return a key
    let key = `uploads/${crypto.randomUUID()}-${fileUpload.name}`
    await fileStorage.set(key, fileUpload)
    return key
  },
})

When you return a string from the handler, formData.get('fieldName') will return that string instead of a File object. This lets you store the file during parsing and get the storage key in your handler.

File Storage Backends

Filesystem Storage

Store files on the local filesystem. Good for development and single-server deployments:

ts
import { createFsFileStorage } from 'remix/file-storage/fs'

let fileStorage = createFsFileStorage('./uploads')

// Store a file
let key = `avatars/${crypto.randomUUID()}.jpg`
await fileStorage.set(key, file)

// Retrieve a file
let file = await fileStorage.get(key)
// Returns a File object or null

// Check if a file exists
let exists = await fileStorage.has(key)

// Delete a file
await fileStorage.remove(key)

// List files with a prefix
let files = await fileStorage.list('avatars/')
// Returns an array of keys

The key is a relative path within the storage directory. fileStorage.set('avatars/photo.jpg', file) creates the file at ./uploads/avatars/photo.jpg.

S3 Storage

Store files in Amazon S3, Cloudflare R2, MinIO, or any S3-compatible service:

bash
npm install remix/file-storage-s3
ts
import { createS3FileStorage } from 'remix/file-storage-s3'

let fileStorage = createS3FileStorage({
  bucket: 'my-app-uploads',
  region: 'us-east-1',
  accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
})

The API is identical to filesystem storage -- set, get, has, remove, list. Your application code does not change when switching between backends.

Using with Cloudflare R2

ts
let fileStorage = createS3FileStorage({
  bucket: 'my-bucket',
  region: 'auto',
  endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  accessKeyId: process.env.R2_ACCESS_KEY_ID!,
  secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
})

Using with MinIO (Self-Hosted)

ts
let fileStorage = createS3FileStorage({
  bucket: 'uploads',
  region: 'us-east-1',
  endpoint: 'http://localhost:9000',
  accessKeyId: 'minioadmin',
  secretAccessKey: 'minioadmin',
  forcePathStyle: true,
})

Switch backends with environment variables

Use a factory function to choose the storage backend based on the environment:

ts
function createFileStorage() {
  if (process.env.S3_BUCKET) {
    return createS3FileStorage({
      bucket: process.env.S3_BUCKET,
      region: process.env.S3_REGION!,
      accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    })
  }
  return createFsFileStorage('./uploads')
}

let fileStorage = createFileStorage()

Serving Uploaded Files

Files stored through fileStorage are not directly accessible by browsers. You need a route to serve them:

ts
import { get } from 'remix/fetch-router/routes'

let fileRoute = get('/files/:key+')

router.map(fileRoute, async ({ params }) => {
  let file = await fileStorage.get(params.key)

  if (!file) {
    return new Response('Not found', { status: 404 })
  }

  return new Response(file.stream(), {
    headers: {
      'Content-Type': file.type,
      'Content-Length': String(file.size),
      'Cache-Control': 'public, max-age=31536000, immutable',
    },
  })
})

The :key+ catch-all parameter matches everything after /files/, including slashes, so avatars/abc-123.jpg is captured as a single string.

LazyFile for Efficient Serving

When serving files from the filesystem, LazyFile avoids loading the entire file into memory. It reads the file on demand when the response body is consumed:

ts
import { LazyFile } from 'remix/form-data-parser'

router.map(fileRoute, async ({ params }) => {
  let filePath = `./uploads/${params.key}`

  try {
    let file = new LazyFile(filePath)
    return new Response(file.stream(), {
      headers: {
        'Content-Type': file.type,
        'Content-Length': String(file.size),
      },
    })
  } catch {
    return new Response('Not found', { status: 404 })
  }
})

createFileResponse Helper

For common file serving patterns, the createFileResponse helper handles content type detection, range requests, and caching:

ts
import { createFileResponse } from 'remix/file-storage/fs'

router.map(fileRoute, async ({ request, params }) => {
  return createFileResponse(fileStorage, params.key, request)
})

This automatically handles:

  • Content type based on the file extension
  • Range requests for partial content (video seeking, download resumption)
  • ETag and If-None-Match for conditional requests
  • Cache-Control headers

Image Processing

Remix does not include image processing built-in, but it works well with the sharp library:

bash
npm install sharp
npm install -D @types/sharp

Resizing on Upload

ts
import sharp from 'sharp'

formData({
  async uploadHandler(fileUpload) {
    if (!fileUpload.type.startsWith('image/')) {
      return fileUpload
    }

    // Read the file into a buffer
    let buffer = Buffer.from(await fileUpload.arrayBuffer())

    // Create thumbnail
    let thumbnail = await sharp(buffer)
      .resize(200, 200, { fit: 'cover' })
      .jpeg({ quality: 80 })
      .toBuffer()

    // Create medium size
    let medium = await sharp(buffer)
      .resize(800, 600, { fit: 'inside', withoutEnlargement: true })
      .jpeg({ quality: 85 })
      .toBuffer()

    let id = crypto.randomUUID()
    let thumbFile = new File([thumbnail], `${id}-thumb.jpg`, { type: 'image/jpeg' })
    let mediumFile = new File([medium], `${id}-medium.jpg`, { type: 'image/jpeg' })
    let originalFile = new File([buffer], `${id}-original.jpg`, { type: fileUpload.type })

    await fileStorage.set(`images/${id}/thumb.jpg`, thumbFile)
    await fileStorage.set(`images/${id}/medium.jpg`, mediumFile)
    await fileStorage.set(`images/${id}/original.jpg`, originalFile)

    return id  // Return the base ID for all sizes
  },
})

Serving Resized Images

ts
let imageRoute = get('/images/:id/:size')

router.map(imageRoute, async ({ params }) => {
  let key = `images/${params.id}/${params.size}`
  let file = await fileStorage.get(key)

  if (!file) {
    return new Response('Not found', { status: 404 })
  }

  return new Response(file.stream(), {
    headers: {
      'Content-Type': 'image/jpeg',
      'Cache-Control': 'public, max-age=31536000, immutable',
    },
  })
})

Size Limits and Validation

Enforcing Limits in Middleware

ts
formData({
  maxFileSize: 5 * 1024 * 1024,    // 5 MB per file
  maxTotalSize: 20 * 1024 * 1024,   // 20 MB across all files
  maxFiles: 5,                       // Maximum 5 files per request
  uploadHandler(fileUpload) {
    return fileUpload
  },
})

If any limit is exceeded, the middleware returns a 400 response. Set these limits to the smallest values that make sense for your application.

Validating in the Handler

For more control, validate individual files in your route handler:

ts
router.map(uploadRoute, async ({ context }) => {
  let data = context.get(FormData)
  let file = data.get('document') as File

  // Check if a file was uploaded
  if (!(file instanceof File) || file.size === 0) {
    return showForm({ document: 'Please select a file to upload.' })
  }

  // Check file size
  if (file.size > 10 * 1024 * 1024) {
    return showForm({ document: 'File must be under 10 MB.' })
  }

  // Check file type
  let allowedTypes = ['application/pdf', 'image/jpeg', 'image/png']
  if (!allowedTypes.includes(file.type)) {
    return showForm({ document: 'Only PDF, JPEG, and PNG files are allowed.' })
  }

  // Proceed with storing the file
  let key = `documents/${crypto.randomUUID()}-${sanitizeFilename(file.name)}`
  await fileStorage.set(key, file)
})

Security

File uploads are one of the most dangerous features in a web application. Follow these practices to stay safe.

Content Type Validation

Never trust the Content-Type header or file extension from the client. They can be spoofed:

ts
let allowedTypes = new Set([
  'image/jpeg',
  'image/png',
  'image/webp',
  'image/gif',
])

if (!allowedTypes.has(file.type)) {
  return showForm({ file: 'Only image files are allowed.' })
}

For sensitive applications, inspect the file's magic bytes (the first few bytes that identify the file format):

ts
async function isJpeg(file: File): Promise<boolean> {
  let buffer = new Uint8Array(await file.slice(0, 3).arrayBuffer())
  return buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF
}

Filename Sanitization

Never use the original filename in file paths without sanitization:

ts
function sanitizeFilename(name: string): string {
  return name
    .replace(/[^a-zA-Z0-9._-]/g, '_')  // Replace special characters
    .replace(/\.{2,}/g, '.')            // Remove double dots (path traversal)
    .slice(0, 100)                       // Limit length
}

Path traversal attacks

A malicious filename like ../../etc/passwd could write to arbitrary locations on your filesystem. Always sanitize filenames, and better yet, generate your own keys using crypto.randomUUID().

Do Not Serve as Static Files

Never serve an uploads directory directly with a static file middleware. A malicious user could upload an HTML file containing JavaScript that executes in your domain's context. Always serve uploads through a route handler where you control the Content-Type header:

ts
// Serve with explicit Content-Type
return new Response(file.stream(), {
  headers: {
    'Content-Type': file.type,
    'Content-Disposition': 'inline', // or 'attachment' to force download
    'X-Content-Type-Options': 'nosniff',
  },
})

The X-Content-Type-Options: nosniff header prevents browsers from guessing the content type, which could override your explicit Content-Type header.

Virus Scanning

For applications that accept uploads from untrusted users, consider scanning files for malware before storing them. ClamAV is a popular open-source antivirus engine that can be run as a daemon.

Large File Uploads and Streaming

For large files (videos, datasets), streaming prevents the entire file from being loaded into memory:

ts
formData({
  async uploadHandler(fileUpload) {
    // Stream the file directly to S3 without buffering
    let key = `large-files/${crypto.randomUUID()}`
    await fileStorage.set(key, fileUpload)
    return key
  },
})

The FileUpload object is a standard web File that supports streaming via .stream(). When you pass it to fileStorage.set(), the storage backend reads from the stream incrementally rather than loading everything into memory.

Setting Server-Level Limits

For very large uploads, you may need to increase the HTTP server's body size limit:

ts
import * as http from 'node:http'

let server = http.createServer({
  maxHeaderSize: 16384,
}, requestListener)

For reverse proxies like Nginx, configure the client_max_body_size directive:

nginx
server {
    client_max_body_size 100M;
}

Multiple File Uploads

HTML supports multiple file selection with the multiple attribute:

html
<input type="file" name="photos" multiple accept="image/*" />

In the handler, use getAll to get all uploaded files:

ts
router.map(uploadRoute, async ({ context }) => {
  let data = context.get(FormData)
  let files = data.getAll('photos') as File[]

  let keys: string[] = []
  for (let file of files) {
    if (file instanceof File && file.size > 0) {
      let key = `photos/${crypto.randomUUID()}-${sanitizeFilename(file.name)}`
      await fileStorage.set(key, file)
      keys.push(key)
    }
  }

  // Store the array of keys in the database
  await db.create(albums, {
    title: data.get('title') as string,
    photo_keys: JSON.stringify(keys),
  })
})

Complete Example

Here is a full file upload implementation with validation, multiple backends, and secure serving:

ts
// app/storage.ts
import { createFsFileStorage } from 'remix/file-storage/fs'
import { createS3FileStorage } from 'remix/file-storage-s3'

export let fileStorage = process.env.S3_BUCKET
  ? createS3FileStorage({
      bucket: process.env.S3_BUCKET,
      region: process.env.S3_REGION!,
      accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    })
  : createFsFileStorage('./uploads')
ts
// app/server.ts
import { formData } from 'remix/form-data-middleware'
import { get } from 'remix/fetch-router/routes'
import { form } from 'remix/fetch-router/routes'
import { fileStorage } from './storage.ts'

let router = createRouter({
  middleware: [
    formData({
      maxFileSize: 10 * 1024 * 1024,
      maxTotalSize: 50 * 1024 * 1024,
      maxFiles: 10,
      uploadHandler(fileUpload) {
        return fileUpload
      },
    }),
  ],
})

// Upload form
let uploadRoutes = form('/upload')

router.map(uploadRoutes.index, () => {
  return html`
    <form method="post" enctype="multipart/form-data">
      <input type="text" name="title" placeholder="Title" required />
      <input type="file" name="files" multiple accept="image/*" />
      <button type="submit">Upload</button>
    </form>
  `
})

router.map(uploadRoutes.action, async ({ context }) => {
  let data = context.get(FormData)
  let title = data.get('title') as string
  let files = data.getAll('files') as File[]

  let keys: string[] = []
  for (let file of files) {
    if (!(file instanceof File) || file.size === 0) continue
    if (!file.type.startsWith('image/')) continue
    if (file.size > 10 * 1024 * 1024) continue

    let key = `gallery/${crypto.randomUUID()}.${file.type.split('/')[1]}`
    await fileStorage.set(key, file)
    keys.push(key)
  }

  // Store in database...
  return new Response(null, { status: 302, headers: { Location: '/gallery' } })
})

// Serve files
let fileRoute = get('/files/:key+')

router.map(fileRoute, async ({ params }) => {
  let file = await fileStorage.get(params.key)
  if (!file) return new Response('Not found', { status: 404 })

  return new Response(file.stream(), {
    headers: {
      'Content-Type': file.type,
      'Content-Length': String(file.size),
      'Cache-Control': 'public, max-age=31536000, immutable',
      'X-Content-Type-Options': 'nosniff',
    },
  })
})

Released under the MIT License.