EdgeStore

S3

Upload to your own AWS S3 bucket or an S3-compatible service such as Cloudflare R2, Backblaze B2, DigitalOcean Spaces, Google Cloud Storage, Tigris, Supabase Storage, or MinIO.

The S3 provider supports direct browser uploads, automatic multipart uploads, backend uploads, signed downloads, object lookup, and deletion. Your application owns the bucket's IAM, CORS, read permissions, and lifecycle configuration.

Setup

Install the optional AWS dependencies:

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Configure the provider on your router:

src/server/edgestore.ts
import { initEdgeStore } from '@edgestore/server';
import { s3 } from '@edgestore/server/providers/s3';

const es = initEdgeStore.create();
export const router = es.router({
  documents: es.fileBucket().accessControl('private'),
}).provider(s3({
  region: 'us-east-1',
  bucketName: 'my-storage-bucket',
}));

export type EdgeStoreRouter = typeof router;

Pass router to your framework adapter as its router option. Set EDGESTORE_JWT_SECRET to a stable random secret, for example one generated with openssl rand -base64 32. All application instances must share it.

Pass credentials with an AWS credentials object or provider function. If it is unset, ES_AWS_ACCESS_KEY_ID and ES_AWS_SECRET_ACCESS_KEY are used when both are set; otherwise the AWS SDK's default provider chain applies, including environment variables, shared config, and instance/task roles.

OptionDefault / environment variable
bucketNameES_AWS_BUCKET_NAME
regionES_AWS_REGION, then AWS SDK region resolution
endpointES_AWS_ENDPOINT
forcePathStyleES_AWS_FORCE_PATH_STYLE === 'true'
baseUrlEDGESTORE_BASE_URL, otherwise the storage URL
uploadUrlExpiresIn3600 seconds
signedUrlExpiresIn3600 seconds
multipart.thresholdBytes100 MiB
multipart.partSizeBytes16 MiB; minimum 5 MiB
multipart.sessionExpiresIn86400 seconds
jwtSecretMultipart signing override; otherwise EDGESTORE_JWT_SECRET or EDGESTORE_SECRET_KEY

Setting jwtSecret does not replace the required environment secret. Signed URL lifetimes must be between 1 and 604800 seconds; temporary AWS credentials can expire sooner.

Paths and filenames

The default key is the logical router bucket, an optional _public segment, router path values, and a generated UUID plus extension:

documents/acme/generated-id.pdf
avatars/_public/user-123/generated-id.webp

Use the router's .path(...) for context/input-derived folders. Use options: { manualFileName: file.name } on a browser upload to preserve the original filename. A repeated key overwrites the existing object (or creates a new version if bucket versioning is enabled). Generated names avoid accidental collisions; use manual names only when overwrite behavior is intentional.

For custom layouts, the async path callback can rewrite everything beneath the logical bucket prefix:

s3({
  path: ({ defaultPath }) => defaultPath.replace(/^_public\//, ''),
});

The callback receives edgestoreBucketName, fileInfo (including router path values and metadata), and defaultPath. It cannot remove the logical bucket prefix or use . / .. path segments. For example, it can produce documents/acme/invoices/report.pdf, but cannot place that file outside documents/.

S3 uploads return a key. Save it in your database for backend lookup, deletion, and signed URL creation, which accept { key } or { url }. Key references continue to work when you change CDN domains. URL references must match the currently configured baseUrl.

Large files and cancellation

Keep using the normal React upload({ file, onProgressChange, signal }) call. Files above the threshold automatically use multipart uploads with retries. Use AbortController to cancel an upload.

s3({
  multipart: {
    thresholdBytes: 100 * 1024 * 1024,
    partSizeBytes: 16 * 1024 * 1024,
  },
});

The browser requests part URLs as it reaches each part and refreshes a rejected URL once, so uploadUrlExpiresIn only needs to cover one part transfer. A multipart session can request URLs, complete, or abort until multipart.sessionExpiresIn passes. Uploads do not resume across page reloads.

Backend uploads send up to four parts at a time through your S3 client, so the client's retry and transport settings apply.

Configure an S3 lifecycle rule to clean up incomplete uploads left by browser shutdowns, network loss, or failed cancellation:

{
  "Rules": [{
    "ID": "abort-incomplete-uploads",
    "Status": "Enabled",
    "Filter": { "Prefix": "" },
    "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
  }]
}

Merge this rule into your existing lifecycle configuration.

Private downloads

Keep the physical bucket private and use .accessControl('private'). Your backend must authorize the caller before issuing a signed URL:

// After checking that the current user can read this database file record:
const download = await router.client.documents.createSignedUrl({
  url: { key: storedFileKey },
  expiresIn: 300,
});
// Send download.signedUrl to the browser.

The backend client is privileged; it does not authenticate application users for you. Cookie-based access-control schemas are unsupported and rejected at adapter initialization. A logical bucket's public/private setting does not configure S3 permissions. _public is a naming convention that you can use in infrastructure rules. For example, an S3 policy can grant public reads to avatars/_public/*, or a CloudFront behavior can allow public viewing of that path while other behaviors require signed access. The prefix alone grants no access. With CloudFront origin access control, the S3 bucket can remain private. If you customize the path, update your access rules to match.

Add .autoSignedUrls({ expiresIn: 300 }) to a private router bucket to receive a signed read URL with upload results. Save the key or canonical URL, not the expiring signed URL. Request a fresh signed URL when needed; the client does not refresh private download URLs automatically. Browser upload read URLs begin expiring when the upload is requested, so allow enough time for the transfer.

For public files, configure public reads or a CDN separately. baseUrl changes canonical file URLs, but signed downloads still use the S3 endpoint; it does not create CloudFront signed URLs.

Backend uploads and object settings

Use the same configured router for generated files and background jobs:

const uploaded = await router.client.documents.upload({
  content: {
    blob: new Blob([pdfBytes], { type: 'application/pdf' }),
    extension: 'pdf',
  },
  options: { manualFileName: 'report.pdf' },
});

Backend uploads use the same path, object settings, and multipart configuration. They accept Blob, text, or URL content. Streaming sources are not supported.

Use objectOptions for cache policy, download filenames, metadata, tags, storage class, and S3/KMS encryption settings. It accepts a static object or an async callback with the same arguments as path:

s3({
  objectOptions: ({ fileInfo }) => ({
    CacheControl: 'private, max-age=60',
    ContentDisposition: 'attachment',
    Metadata: { category: fileInfo.metadata.category ?? 'general' },
  }),
});

ContentType comes from the file's MIME type. File contents are not inspected.

For custom AWS retry/transport behavior, pass client: new S3Client(...). For browser uploads, configure that client with requestChecksumCalculation: 'WHEN_REQUIRED'. When injecting a client with a custom endpoint, also set baseUrl explicitly.

IAM and CORS

A baseline application role policy is below. Replace the bucket name and narrow object prefixes to your logical buckets where appropriate. KMS encryption needs additional key permissions.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:AbortMultipartUpload"],
    "Resource": "arn:aws:s3:::my-storage-bucket/*"
  }]
}

Configure bucket CORS for your actual application origins. Exposing ETag is required for browser multipart completion:

[{
  "AllowedOrigins": ["http://localhost:3000", "https://app.example.com"],
  "AllowedMethods": ["PUT", "GET", "HEAD"],
  "AllowedHeaders": ["*"],
  "ExposeHeaders": ["ETag"],
  "MaxAgeSeconds": 3600
}]

S3-compatible services

s3() works with services that implement the S3 API, including Cloudflare R2, Backblaze B2, DigitalOcean Spaces, Google Cloud Storage, Tigris, Supabase Storage, and MinIO. Point endpoint at the service, pass its access keys as credentials (or the ES_AWS_ACCESS_KEY_ID / ES_AWS_SECRET_ACCESS_KEY environment variables), and configure bucket CORS as described above, including exposing ETag.

The endpoint must be reachable from both your server and the browser. baseUrl defaults to <endpoint>/<bucket>; set it to the service's public or CDN URL when public files are served from a different host. Services differ in support for tags, storage classes, and KMS encryption, so only set objectOptions your service supports. EdgeStore's CI runs against MinIO; the setups below follow each service's S3 compatibility documentation.

Cloudflare R2

s3({
  bucketName: 'edgestore',
  region: 'auto',
  endpoint: 'https://<ACCOUNT_ID>.r2.cloudflarestorage.com',
  credentials: { accessKeyId: '...', secretAccessKey: '...' },
  // Public files: an r2.dev subdomain or a custom domain on the bucket.
  baseUrl: 'https://files.example.com',
});

Create the keys as an R2 API token. R2 does not support object tagging or KMS encryption, and supports the STANDARD and STANDARD_IA storage classes.

Backblaze B2

s3({
  bucketName: 'edgestore',
  region: 'us-west-004',
  endpoint: 'https://s3.us-west-004.backblazeb2.com',
  credentials: { accessKeyId: '<keyID>', secretAccessKey: '<applicationKey>' },
});

Use the region from your bucket's S3 endpoint and an application key. B2 does not support object tagging or KMS encryption.

DigitalOcean Spaces

s3({
  bucketName: 'edgestore',
  region: 'nyc3',
  endpoint: 'https://nyc3.digitaloceanspaces.com',
  credentials: { accessKeyId: '...', secretAccessKey: '...' },
  // Optional: serve public files through the Spaces CDN.
  baseUrl: 'https://edgestore.nyc3.cdn.digitaloceanspaces.com',
});

Spaces supports customer-provided encryption keys but not KMS encryption.

Google Cloud Storage

s3({
  bucketName: 'edgestore',
  region: 'auto',
  endpoint: 'https://storage.googleapis.com',
  credentials: { accessKeyId: '<HMAC access ID>', secretAccessKey: '<HMAC secret>' },
});

Cloud Storage's XML API is S3-compatible when you use HMAC keys, including signed URLs, multipart uploads, and multi-object delete. Create an HMAC key for a service account with access to the bucket. Tagging and KMS headers are not supported.

Tigris

s3({
  bucketName: 'edgestore',
  region: 'auto',
  endpoint: 'https://t3.storage.dev',
  credentials: { accessKeyId: '...', secretAccessKey: '...' },
});

Supabase Storage

s3({
  bucketName: 'edgestore',
  region: '<project region>',
  endpoint: 'https://<project_ref>.storage.supabase.co/storage/v1/s3',
  forcePathStyle: true,
  credentials: { accessKeyId: '...', secretAccessKey: '...' },
  // Public buckets are served from the object endpoint.
  baseUrl:
    'https://<project_ref>.supabase.co/storage/v1/object/public/edgestore',
});

Create S3 access keys in your project's storage settings. Supabase Storage does not support object tagging, storage classes, or server-side encryption options.

MinIO

s3({
  bucketName: 'edgestore',
  region: 'us-east-1',
  endpoint: 'http://localhost:9000',
  forcePathStyle: true,
});

Limitations

  • temporary and replaceTargetUrl are rejected. Track application-owned files in your database and delete old files after successful replacement.
  • Confirmation, listing/filtering, restore, and provider-generated thumbnails are unsupported. Thumbnail options do not generate thumbnails.
  • Router path/metadata values are returned at upload time but are not persisted as an EdgeStore index. get() returns key, URL, size, and S3 timestamps. Persist application metadata in your database or explicitly set S3 Metadata.
  • No collision-prevention mode, automatic CDN invalidation, or version-specific object references. Standard S3 overwrite/delete and versioning rules apply.
  • Browser multipart cleanup is best effort, not a substitute for lifecycle rules.

On this page