File Storage S3 Tutorial
This tutorial shows you how to set up S3-compatible file storage, upload and download files, and integrate S3 as the destination for form uploads.
Prerequisites
- A Remix V3 project
- An S3-compatible bucket (AWS S3, Cloudflare R2, or MinIO)
- Access credentials for the bucket
Step 1: Configure S3 Storage
Create a storage instance with your bucket credentials. Store secrets in environment variables, not in code.
ts
// app/storage.ts
import { createS3FileStorage } from 'remix/file-storage-s3'
export let storage = createS3FileStorage({
bucket: process.env.S3_BUCKET!,
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
})Cloudflare R2
For Cloudflare R2, set the endpoint and use auto as the region:
ts
export let storage = createS3FileStorage({
bucket: process.env.R2_BUCKET!,
region: 'auto',
endpoint: process.env.R2_ENDPOINT!,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
})MinIO (Local Development)
For local development with MinIO, enable path-style URLs:
ts
export let storage = createS3FileStorage({
bucket: 'dev-uploads',
region: 'us-east-1',
endpoint: 'http://localhost:9000',
forcePathStyle: true,
credentials: {
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin',
},
})Step 2: Upload and Download Files
The S3 backend implements the same FileStorage interface as every other backend.
ts
import { storage } from './storage'
// Upload a file
let report = new File(['Q1 results...'], 'report.txt', {
type: 'text/plain',
})
await storage.set('reports/2025/q1.txt', report)
// Download a file
let file = await storage.get('reports/2025/q1.txt')
if (file) {
console.log(file.name) // 'q1.txt'
console.log(await file.text()) // 'Q1 results...'
}
// Check existence
await storage.has('reports/2025/q1.txt') // true
// Delete
await storage.remove('reports/2025/q1.txt')Step 3: Use S3 as a Form Upload Handler
Combine S3 storage with parseFormData to stream uploaded files directly to your bucket.
ts
import { parseFormData } from 'remix/form-data-parser'
import { storage } from './storage'
export async function action({ request }: { request: Request }) {
let formData = await parseFormData(request, {}, async (fileUpload) => {
let key = `uploads/${Date.now()}-${fileUpload.name}`
let file = new File([await fileUpload.arrayBuffer()], fileUpload.name, {
type: fileUpload.type,
})
await storage.set(key, file)
return key
})
let fileKey = formData.get('document') as string
return Response.json({ fileKey })
}Step 4: Serve Files from S3
Retrieve a file from S3 and serve it as an HTTP response.
ts
import { createFileResponse } from 'remix/response/file'
import { storage } from './storage'
export async function loader({ params }: { params: { '*': string } }) {
let file = await storage.get(params['*'])
if (!file) {
return new Response('Not Found', { status: 404 })
}
return createFileResponse(file)
}Step 5: Organize with Prefixes
Use the prefix option to scope all keys under a common path, keeping the bucket organized.
ts
let userUploads = createS3FileStorage({
bucket: process.env.S3_BUCKET!,
region: process.env.AWS_REGION!,
prefix: 'user-uploads/',
})
// Stored as "user-uploads/avatar.jpg" in the bucket
await userUploads.set('avatar.jpg', file)Summary
| Concept | What You Learned |
|---|---|
| Configuration | Set bucket, region, credentials, and optionally endpoint |
| R2 / MinIO | Use endpoint and forcePathStyle for non-AWS services |
| CRUD operations | Same set, get, has, remove interface as other backends |
| Upload handler | Combine with parseFormData to stream uploads to S3 |
| Serving files | Retrieve with get and return with createFileResponse |
| Prefixes | Scope keys with the prefix option |
Next Steps
- See file-storage for the base interface and other backends
- Serve files with range requests using response
- See the API Reference for all options