Documentation Index
Fetch the complete documentation index at: https://mintlify.com/remix-run/remix/llms.txt
Use this file to discover all available pages before exploring further.
Set up file storage
Choose a storage backend for uploaded files. For local development, use filesystem storage: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: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',
})
Create an upload handler
Build a function that processes uploaded files and returns a public URL: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. Configure form data middleware
Add the form data middleware with your upload handler: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. Create an upload form
Build a form with file input fields: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. Handle file uploads
Process uploaded files in your route handler: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. Serve uploaded files
Create a route to serve files from storage: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. Validate file uploads
Add validation to check file size, type, and other constraints: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 }
)
}
})
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')
})
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()