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-presignerConfigure the provider on your router:
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.
| Option | Default / environment variable |
|---|---|
bucketName | ES_AWS_BUCKET_NAME |
region | ES_AWS_REGION, then AWS SDK region resolution |
endpoint | ES_AWS_ENDPOINT |
forcePathStyle | ES_AWS_FORCE_PATH_STYLE === 'true' |
baseUrl | EDGESTORE_BASE_URL, otherwise the storage URL |
uploadUrlExpiresIn | 3600 seconds |
signedUrlExpiresIn | 3600 seconds |
multipart.thresholdBytes | 100 MiB |
multipart.partSizeBytes | 16 MiB; minimum 5 MiB |
multipart.sessionExpiresIn | 86400 seconds |
jwtSecret | Multipart 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.webpUse 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
temporaryandreplaceTargetUrlare 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 S3Metadata. - 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.