Skip to content

7. File Uploads

Our bookstore needs cover images. In this chapter, you will learn how browsers send files to a server, how to receive and store them with Remix, and how to serve them back to visitors.

How Browsers Send Files

When an HTML form includes a file input, the browser cannot use the normal form encoding (which only handles text). Instead, it uses a format called multipart form data -- a way to package multiple pieces of data (text fields and binary files) into a single request body.

To tell the browser to use this format, you add enctype="multipart/form-data" to your form:

html
<form method="post" enctype="multipart/form-data">
  <input type="file" name="cover" />
  <button type="submit">Upload</button>
</form>

What is "multipart"?

The word "multipart" means the request body is divided into multiple parts, separated by a boundary string. Each part has its own headers (like Content-Type) and body. Text fields are sent as plain text parts, and files are sent as binary parts with their original filename and MIME type.

Without enctype="multipart/form-data", the browser will send the filename as a text string instead of the actual file contents. This is one of the most common mistakes when building file upload forms.

Configuring the Upload Handler

The formData() middleware you added in the previous chapter already knows how to parse multipart form data. By default, it stores uploaded files in memory. For real applications, you want to save files to disk or a cloud storage service.

You configure this by passing an uploadHandler option to the middleware. Update app/server.ts:

ts
import { formData } from 'remix/form-data-middleware'
import { createFsFileStorage } from 'remix/file-storage/fs'

// Create a file storage backed by the local filesystem
let fileStorage = createFsFileStorage('./uploads')

let router = createRouter({
  middleware: [
    logger(),
    formData({
      uploadHandler(fileUpload) {
        // Return the file upload object as-is; we'll store it in the handler
        return fileUpload
      },
    }),
    methodOverride(),
    session(sessionCookie, sessionStorage),
    authMiddleware,
  ],
})

The uploadHandler function is called for each file in the upload. It receives a FileUpload object -- a standard web File with the data the user uploaded. Here we return it directly so we can decide in each route handler where and how to store it.

Memory usage

If you do not provide an uploadHandler, uploaded files are stored entirely in memory. For small files (like profile pictures) this is fine, but large files (like videos) can crash your server by using too much memory. Always configure an upload handler for production applications.

Building the Book Cover Upload Form

Let's update the "add book" form to include a file input for the cover image. Update app/routes/admin/books.ts:

ts
export function showNewBookForm(
  errors?: Record<string, string>,
  values?: Record<string, string>,
) {
  return html`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>Add a Book - The Book Nook</title>
      </head>
      <body>
        <h1>Add a New Book</h1>
        <form method="post" enctype="multipart/form-data">
          <div>
            <label for="title">Title</label>
            <input
              type="text"
              id="title"
              name="title"
              value="${values?.title ?? ''}"
            />
            ${errors?.title ? `<p style="color: red">${errors.title}</p>` : ''}
          </div>
          <div>
            <label for="author">Author</label>
            <input
              type="text"
              id="author"
              name="author"
              value="${values?.author ?? ''}"
            />
            ${errors?.author ? `<p style="color: red">${errors.author}</p>` : ''}
          </div>
          <div>
            <label for="year">Year Published</label>
            <input
              type="number"
              id="year"
              name="year"
              value="${values?.year ?? ''}"
            />
            ${errors?.year ? `<p style="color: red">${errors.year}</p>` : ''}
          </div>
          <div>
            <label for="description">Description</label>
            <textarea id="description" name="description" rows="5">${values?.description ?? ''}</textarea>
            ${errors?.description ? `<p style="color: red">${errors.description}</p>` : ''}
          </div>
          <div>
            <label for="cover">Cover Image</label>
            <input type="file" id="cover" name="cover" accept="image/*" />
            ${errors?.cover ? `<p style="color: red">${errors.cover}</p>` : ''}
          </div>
          <button type="submit">Add Book</button>
        </form>
      </body>
    </html>
  `
}

Key changes:

  • Added enctype="multipart/form-data" to the <form> tag.
  • Added an <input type="file" name="cover" accept="image/*" />. The accept attribute tells the browser's file picker to only show image files, though users can override this.

Storing Uploaded Files

Now update the POST handler to save the uploaded file. When a form includes file inputs and uses multipart encoding, context.get(FormData) returns a FormData object where file fields are File objects instead of strings.

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

let fileStorage = createFsFileStorage('./uploads')

router.map(newBookRoutes.action, { middleware: [requireAdmin] }, async ({ context }) => {
  let data = context.get(FormData)

  // Get text fields
  let values = {
    title: data.get('title') as string,
    author: data.get('author') as string,
    year: data.get('year') as string,
    description: data.get('description') as string,
  }

  // Validate text fields (same as before)
  let result = parseSafe(NewBookSchema, values)

  if (!result.success) {
    let errors: Record<string, string> = {}
    for (let issue of result.issues) {
      let key = issue.path?.[0]?.key
      if (typeof key === 'string') {
        errors[key] = issue.message
      }
    }
    return showNewBookForm(errors, values)
  }

  // Handle the file upload
  let coverFile = data.get('cover')
  let coverKey: string | null = null

  if (coverFile instanceof File && coverFile.size > 0) {
    // Validate the file
    if (coverFile.size > 5 * 1024 * 1024) {
      return showNewBookForm({ cover: 'Cover image must be under 5 MB.' }, values)
    }

    if (!coverFile.type.startsWith('image/')) {
      return showNewBookForm({ cover: 'Cover must be an image file.' }, values)
    }

    // Generate a unique key and store the file
    coverKey = `covers/${crypto.randomUUID()}-${coverFile.name}`
    await fileStorage.set(coverKey, coverFile)
  }

  // Insert the book with its cover reference
  await insertInto(db, BooksTable, {
    title: result.value.title,
    author: result.value.author,
    year: result.value.year,
    description: result.value.description,
    coverKey,
  })

  let session = context.get(Session)
  session.flash('message', 'Book added successfully!')

  return new Response(null, {
    status: 302,
    headers: { Location: '/books' },
  })
})

Let's walk through the file handling:

  1. data.get('cover') returns a File object when the form uses multipart encoding.
  2. We check coverFile instanceof File && coverFile.size > 0 to make sure a file was actually uploaded (the field exists even if the user did not pick a file, but its size will be 0).
  3. We validate the file size and type.
  4. We generate a unique storage key using crypto.randomUUID() to prevent name collisions.
  5. fileStorage.set(key, file) saves the file to disk.
  6. We store the storage key in the database so we can retrieve the file later.

Add a coverKey column to BooksTable

You will need to add a coverKey column to your books table:

ts
export let BooksTable = defineTable('books', {
  id: column.integer({ primaryKey: true }),
  title: column.text(),
  author: column.text(),
  year: column.integer(),
  description: column.text(),
  coverKey: column.text({ nullable: true }),
})

Serving Uploaded Files

Uploaded files are stored on disk, but browsers cannot access them directly -- they need to be served through a route. Create a route that reads files from storage and returns them as responses.

Add to app/server.ts:

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

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

router.map(fileRoute, async ({ params }) => {
  let key = params.key

  let file = await fileStorage.get(key)

  if (!file) {
    return new Response('File 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+ syntax is a catch-all parameter -- it matches everything after /uploads/, including slashes. So /uploads/covers/abc-123-photo.jpg gives you params.key === "covers/abc-123-photo.jpg".

The response includes:

  • Content-Type -- Tells the browser what kind of file it is (e.g. image/jpeg).
  • Content-Length -- The file size in bytes.
  • Cache-Control -- Tells the browser (and any proxies) to cache this file for a year. Because each file has a unique key (thanks to the UUID), the URL changes whenever the file changes, so aggressive caching is safe.

Displaying Cover Images

Now update the book detail page to show the cover image:

ts
router.map(bookRoutes.detail, async ({ params }) => {
  let book = await selectFrom(db, BooksTable, {
    where: { id: Number(params.id) },
  })

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

  let coverHtml = book.coverKey
    ? `<img src="/uploads/${book.coverKey}" alt="Cover of ${book.title}" style="max-width: 300px" />`
    : '<p>No cover image</p>'

  return html`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>${book.title} - The Book Nook</title>
      </head>
      <body>
        <h1>${book.title}</h1>
        ${coverHtml}
        <p>By ${book.author} (${book.year})</p>
        <p>${book.description}</p>
        <a href="/books">Back to all books</a>
      </body>
    </html>
  `
})

File Size Limits and Security

File uploads introduce security risks that you should be aware of:

Size Limits

Large uploads can exhaust your server's memory or disk space. Always enforce limits:

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

If any limit is exceeded, the middleware throws an error that results in a 400 (Bad Request) response.

File Type Validation

Always validate file types on the server. The accept attribute on <input type="file"> is just a hint to the browser -- anyone can send any file type with a crafted request:

ts
let allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']

if (!allowedTypes.includes(coverFile.type)) {
  return showNewBookForm({ cover: 'Only JPEG, PNG, WebP, and GIF images are allowed.' }, values)
}

Filename Sanitization

Never use the original filename directly in file paths. Our approach of generating a UUID-based key (covers/${crypto.randomUUID()}-${coverFile.name}) is safe because fileStorage hashes the key internally. However, if you ever write files directly to disk, sanitize the filename to prevent path traversal attacks (where a filename like ../../etc/passwd could write to sensitive locations).

Do not serve the uploads directory as static files

It might seem simpler to use a static file middleware to serve uploads, but this can be dangerous. A malicious user could upload an HTML file that runs JavaScript in your domain's context. Always serve uploads through a route handler where you can set proper Content-Type headers and other security controls.

S3 Storage for Production

Filesystem storage works for development and single-server deployments, but for production applications you typically want to store files in a cloud storage service like Amazon S3, Google Cloud Storage, or Cloudflare R2. Remix provides an S3-compatible storage backend:

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

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

This implements the same FileStorage interface (get, set, has, remove, list), so your route handlers work without any changes. The only difference is where the files end up.

Benefits of cloud storage:

  • Scales horizontally -- Multiple servers can access the same files.
  • Durability -- Cloud providers replicate your data across multiple data centers.
  • CDN integration -- Serve files from edge locations close to your users.
  • No disk management -- You do not need to worry about running out of disk space.

Summary

In this chapter you learned:

  • Browsers send files using multipart/form-data encoding, enabled by the enctype attribute
  • The formData() middleware parses multipart uploads; configure an uploadHandler for file access
  • createFsFileStorage creates a filesystem-backed storage for files
  • fileStorage.set(key, file) stores a file; fileStorage.get(key) retrieves it
  • Always validate file size, type, and count on the server
  • Serve uploaded files through a route handler, not as static files
  • Use UUID-based keys to prevent filename collisions
  • For production, use createS3FileStorage for scalable, durable cloud storage

In the final chapter, you will learn how to deploy your bookstore to production.

Released under the MIT License.