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.