Skip to content

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 ​

ConceptWhat You Learned
ConfigurationSet bucket, region, credentials, and optionally endpoint
R2 / MinIOUse endpoint and forcePathStyle for non-AWS services
CRUD operationsSame set, get, has, remove interface as other backends
Upload handlerCombine with parseFormData to stream uploads to S3
Serving filesRetrieve with get and return with createFileResponse
PrefixesScope keys with the prefix option

Next Steps ​

Released under the MIT License.