Skip to main content
1

Set up file storage

Choose a storage backend for uploaded files. For local development, use filesystem storage:
app/storage.ts
import { createFsFileStorage } from 'remix/file-storage/fs'
import { resolve } from 'node:path'

// Store files in ./uploads directory
export let fileStorage = createFsFileStorage(resolve('./uploads'))
For production, use S3 or S3-compatible storage:
app/storage.ts
import { createS3FileStorage } from 'remix/file-storage-s3'
import { S3Client } from '@aws-sdk/client-s3'

let s3Client = new S3Client({
  region: 'us-east-1',
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
})

export let fileStorage = createS3FileStorage({
  client: s3Client,
  bucket: 'my-app-uploads',
})
2

Create an upload handler

Build a function that processes uploaded files and returns a public URL:
app/utils/uploads.ts
import type { FileUpload } from 'remix/form-data-parser'
import { fileStorage } from '../storage.ts'

export async function uploadHandler(file: FileUpload): Promise<string> {
  // Generate unique key for this file
  let ext = file.name.split('.').pop() || 'jpg'
  let timestamp = Date.now()
  let random = Math.random().toString(36).substring(7)
  let key = `${file.fieldName}/${timestamp}-${random}.${ext}`

  // Store the file
  await fileStorage.set(key, file)

  // Return public URL path
  return `/uploads/${key}`
}
The upload handler receives a FileUpload object with the file data and metadata.
3

Configure form data middleware

Add the form data middleware with your upload handler:
app/router.ts
import { createRouter } from 'remix/fetch-router'
import { formData } from 'remix/form-data-middleware'
import { uploadHandler } from './utils/uploads.ts'

export let router = createRouter({
  middleware: [
    formData({ uploadHandler }),
  ],
})
The middleware automatically parses multipart/form-data requests and processes file uploads.
4

Create an upload form

Build a form with file input fields:
app/pages/upload.tsx
import { css } from 'remix/component'
import { Document } from '../layout.tsx'

interface UploadFormProps {
  uploadedUrl?: string
  error?: string
}

export function UploadForm({ uploadedUrl, error }: UploadFormProps) {
  return (
    <Document title="Upload File">
      <h1>Upload File</h1>

      {error && (
        <div
          mix={[
            css({
              padding: '1rem',
              background: '#fee',
              border: '1px solid #fcc',
              borderRadius: '4px',
              marginBottom: '1rem',
            }),
          ]}
        >
          {error}
        </div>
      )}

      {uploadedUrl && (
        <div
          mix={[
            css({
              padding: '1rem',
              background: '#efe',
              border: '1px solid #cfc',
              borderRadius: '4px',
              marginBottom: '1rem',
            }),
          ]}
        >
          <p>File uploaded successfully!</p>
          <img
            src={uploadedUrl}
            alt="Uploaded file"
            mix={[
              css({
                maxWidth: '400px',
                marginTop: '1rem',
                borderRadius: '4px',
              }),
            ]}
          />
        </div>
      )}

      <form
        method="POST"
        action="/upload"
        enctype="multipart/form-data"
      >
        <div mix={[css({ marginBottom: '1rem' })]}>
          <label
            for="file"
            mix={[css({ display: 'block', marginBottom: '0.5rem' })]}
          >
            Choose File
          </label>
          <input
            type="file"
            id="file"
            name="file"
            accept="image/*"
            required
            mix={[
              css({
                padding: '0.5rem',
                border: '1px solid #ddd',
                borderRadius: '4px',
              }),
            ]}
          />
        </div>

        <div mix={[css({ marginBottom: '1rem' })]}>
          <label
            for="description"
            mix={[css({ display: 'block', marginBottom: '0.5rem' })]}
          >
            Description (optional)
          </label>
          <input
            type="text"
            id="description"
            name="description"
            mix={[
              css({
                width: '100%',
                padding: '0.5rem',
                border: '1px solid #ddd',
                borderRadius: '4px',
              }),
            ]}
          />
        </div>

        <button
          type="submit"
          mix={[
            css({
              padding: '0.75rem 1.5rem',
              background: '#0070f3',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }),
          ]}
        >
          Upload
        </button>
      </form>
    </Document>
  )
}
Note the enctype="multipart/form-data" attribute - this is required for file uploads.
5

Handle file uploads

Process uploaded files in your route handler:
app/router.ts
import { routes } from 'remix/fetch-router/routes'
import { render } from './utils/render.ts'
import { UploadForm } from './pages/upload.tsx'

export let appRoutes = routes({
  upload: {
    index: 'GET /upload',
    submit: 'POST /upload',
  },
})

// Show upload form
router.get(appRoutes.upload.index, () => {
  return render(<UploadForm />)
})

// Process upload
router.post(appRoutes.upload.submit, async ({ get }) => {
  let form = get(FormData)
  
  // The file has been uploaded by the middleware
  // The form now contains the URL returned by uploadHandler
  let fileUrl = form.get('file')?.toString()
  let description = form.get('description')?.toString() ?? ''

  if (!fileUrl) {
    return render(
      <UploadForm error="Please select a file to upload" />,
      { status: 400 }
    )
  }

  // Save metadata to database
  console.log('File uploaded:', { fileUrl, description })

  return render(<UploadForm uploadedUrl={fileUrl} />)
})
After the middleware processes the upload, the file field contains the URL string returned by your upload handler.
6

Serve uploaded files

Create a route to serve files from storage:
app/router.ts
import { createFileResponse } from 'remix/response/file'
import { fileStorage } from './storage.ts'

router.get('/uploads/:key+', async ({ params, request }) => {
  let file = await fileStorage.get(params.key)

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

  return createFileResponse(file, request, {
    cacheControl: 'public, max-age=31536000, immutable',
  })
})
The createFileResponse function handles content negotiation, range requests, and caching headers automatically.
7

Validate file uploads

Add validation to check file size, type, and other constraints:
app/utils/uploads.ts
import type { FileUpload } from 'remix/form-data-parser'

const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']

export async function uploadHandler(file: FileUpload): Promise<string> {
  // Validate file size
  if (file.size > MAX_FILE_SIZE) {
    throw new Error('File too large. Maximum size is 5MB.')
  }

  // Validate file type
  if (!ALLOWED_TYPES.includes(file.type)) {
    throw new Error('Invalid file type. Only images are allowed.')
  }

  // Generate unique key
  let ext = file.name.split('.').pop() || 'jpg'
  let timestamp = Date.now()
  let random = Math.random().toString(36).substring(7)
  let key = `${file.fieldName}/${timestamp}-${random}.${ext}`

  // Store the file
  await fileStorage.set(key, file)

  return `/uploads/${key}`
}
Handle validation errors in your route:
router.post(appRoutes.upload.submit, async ({ get }) => {
  try {
    let form = get(FormData)
    let fileUrl = form.get('file')?.toString()

    if (!fileUrl) {
      return render(
        <UploadForm error="Please select a file to upload" />,
        { status: 400 }
      )
    }

    return render(<UploadForm uploadedUrl={fileUrl} />)
  } catch (error) {
    let message = error instanceof Error ? error.message : 'Upload failed'
    return render(
      <UploadForm error={message} />,
      { status: 400 }
    )
  }
})
8

Handle multiple file uploads

Support uploading multiple files at once:
app/pages/gallery-upload.tsx
<form method="POST" action="/gallery/upload" enctype="multipart/form-data">
  <div>
    <label for="photos">Choose Photos</label>
    <input
      type="file"
      id="photos"
      name="photos"
      accept="image/*"
      multiple
      required
    />
  </div>

  <button type="submit">Upload Gallery</button>
</form>
Process multiple files:
router.post('/gallery/upload', async ({ get }) => {
  let form = get(FormData)
  
  // getAll returns an array of values
  let photoUrls = form.getAll('photos').map(v => v.toString())

  if (photoUrls.length === 0) {
    return render(
      <GalleryUploadForm error="Please select at least one photo" />,
      { status: 400 }
    )
  }

  // Save to database
  console.log('Gallery uploaded:', photoUrls)

  return redirect('/gallery')
})
9

Add upload progress (optional)

For better UX with large files, track upload progress using client-side JavaScript:
app/assets/upload-form.tsx
export function UploadFormWithProgress() {
  return (
    <Document>
      <h1>Upload File</h1>

      <form id="upload-form" method="POST" action="/upload" enctype="multipart/form-data">
        <input type="file" name="file" required />
        <button type="submit">Upload</button>
      </form>

      <div id="progress" style="display: none;">
        <progress id="progress-bar" max="100" value="0">0%</progress>
        <span id="progress-text">0%</span>
      </div>

      <script
        dangerouslySetInnerHTML={{
          __html: `
            document.getElementById('upload-form').addEventListener('submit', function(e) {
              e.preventDefault();
              
              let formData = new FormData(this);
              let progressBar = document.getElementById('progress-bar');
              let progressText = document.getElementById('progress-text');
              let progressDiv = document.getElementById('progress');
              
              progressDiv.style.display = 'block';
              
              let xhr = new XMLHttpRequest();
              
              xhr.upload.addEventListener('progress', function(e) {
                if (e.lengthComputable) {
                  let percent = (e.loaded / e.total) * 100;
                  progressBar.value = percent;
                  progressText.textContent = Math.round(percent) + '%';
                }
              });
              
              xhr.addEventListener('load', function() {
                if (xhr.status === 200) {
                  window.location.reload();
                }
              });
              
              xhr.open('POST', '/upload');
              xhr.send(formData);
            });
          `,
        }}
      />
    </Document>
  )
}

File Upload Best Practices

Always validate on the server

Never trust client-side validation alone. Always validate file size, type, and content on the server.

Use unique filenames

Generate unique keys/filenames to avoid collisions and allow the same file to be uploaded multiple times.

Set appropriate limits

Limit file sizes and number of files to prevent abuse:
formData({
  uploadHandler,
  maxFileSize: 10 * 1024 * 1024, // 10MB
  maxFiles: 5,
})

Store files outside web root

Don’t store uploaded files in your public directory. Serve them through a route handler that can enforce access control.

Scan for malware

For production applications, integrate virus scanning:
import { scanFile } from 'your-antivirus-package'

export async function uploadHandler(file: FileUpload): Promise<string> {
  // Scan file first
  let isSafe = await scanFile(file)
  if (!isSafe) {
    throw new Error('File failed security scan')
  }

  // Process upload...
}

Storage Options

Filesystem Storage

Good for development and small deployments:
import { createFsFileStorage } from 'remix/file-storage/fs'

let storage = createFsFileStorage('./uploads')

S3 Storage

Recommended for production:
import { createS3FileStorage } from 'remix/file-storage-s3'
import { S3Client } from '@aws-sdk/client-s3'

let storage = createS3FileStorage({
  client: new S3Client({ region: 'us-east-1' }),
  bucket: 'my-bucket',
})

Memory Storage

Useful for testing:
import { createMemoryFileStorage } from 'remix/file-storage/memory'

let storage = createMemoryFileStorage()

Build docs developers (and LLMs) love