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:
<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:
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:
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/*" />. Theacceptattribute 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.
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:
data.get('cover')returns aFileobject when the form uses multipart encoding.- We check
coverFile instanceof File && coverFile.size > 0to 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). - We validate the file size and type.
- We generate a unique storage key using
crypto.randomUUID()to prevent name collisions. fileStorage.set(key, file)saves the file to disk.- 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:
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:
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:
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:
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:
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:
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-dataencoding, enabled by theenctypeattribute - The
formData()middleware parses multipart uploads; configure anuploadHandlerfor file access createFsFileStoragecreates a filesystem-backed storage for filesfileStorage.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
createS3FileStoragefor scalable, durable cloud storage
In the final chapter, you will learn how to deploy your bookstore to production.