Download Cloudflare R2 Objects as a ZIP
Presign R2 objects on the server, then zip them in the browser with Eazip — no storage credentials client-side and no R2 egress fees.
To let a user download an R2 folder as one ZIP, have your backend list the objects and return short-lived signed URLs, then hand that list to Eazip in the browser. Eazip fetches each URL and builds the archive on the visitor's device, so R2's zero-egress pricing applies and your server never streams the file bytes.
Loading live demo…
Prepare signed URLs on the server
Your backend should authenticate the request, authorize the requested folder, then list and presign each object with the S3-compatible API:
import {
S3Client,
ListObjectsV2Command,
GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
export async function getFolderZipSources(prefix: string) {
// Authenticate the caller and authorize `prefix` before this point.
const listed = await s3.send(
new ListObjectsV2Command({ Bucket: 'my-bucket', Prefix: prefix }),
);
return Promise.all(
(listed.Contents ?? [])
.filter((object) => object.Key && !object.Key.endsWith('/'))
.map(async (object) => ({
url: await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: 'my-bucket', Key: object.Key! }),
{ expiresIn: 900 },
),
filename: object.Key!.slice(prefix.length),
})),
);
}ListObjectsV2Command returns at most 1,000 keys per call; page through
ContinuationToken for larger folders. Set expiresIn long enough to cover
the whole ZIP build, not just the first request.
If your backend already runs as a Cloudflare Worker with an R2 bucket
binding, you can list objects with env.BUCKET.list({ prefix }) directly and
skip the S3 SDK and API token. The binding itself cannot mint presigned URLs,
though, so either sign with the S3-compatible API as above, or have the
Worker serve each object from a route the frontend can call and return that
route's URL as the filename's source instead of an R2 host.
Wrap this in an endpoint that returns only { url, filename }[]. Storage
credentials must never reach the browser.
Configure R2 CORS
The browser fetches each signed URL directly from R2, so the bucket needs a CORS policy that allows your app's origin:
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]Apply it from the dashboard (bucket Settings → CORS Policy → Add CORS policy) or with the S3-compatible API:
import { S3Client, PutBucketCorsCommand } from '@aws-sdk/client-s3';
await s3.send(
new PutBucketCorsCommand({
Bucket: 'my-bucket',
CORSConfiguration: {
CORSRules: [
{
AllowedOrigins: ['https://app.example.com'],
AllowedMethods: ['GET'],
AllowedHeaders: ['*'],
MaxAgeSeconds: 3600,
},
],
},
}),
);wrangler r2 bucket cors set my-bucket --file cors.json applies the same file
from the CLI. Policy changes can take up to 30 seconds to propagate.
Create the ZIP in the browser
Fetch the signed source list from your endpoint and pass it straight to
createZip:
import { createZip } from '@eazip/core';
const response = await fetch('/api/exports/reports-2026-q2/files', {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Could not prepare export files');
}
const files = await response.json(); // { url, filename }[]
const result = await createZip({
files,
zipName: 'reports-2026-q2.zip',
});
result.download();This runs entirely in the visitor's browser: no account or API key is required, and no bytes pass through your application server. Since R2 charges no egress fee, downloading a large R2 folder this way costs the same as downloading a single object — you only pay for the R2 request and any compute used to list and sign the URLs.
When to use Eazip Cloud
Local (browser) execution covers most folder downloads. Move the same job to Eazip Cloud when:
- the folder is multi-gigabyte or holds thousands of objects;
- the download must survive a page reload or a closed tab;
- some signed URLs won't clear CORS from the visitor's browser; or
- you don't want archive bytes occupying tab memory.
const result = await createZip({
strategy: 'cloud',
publicKey: 'pk_ez_...',
files,
zipName: 'reports-2026-q2.zip',
});
result.downloadAll();Cloud fetches the same { url, filename } list from Eazip's servers instead
of the browser, so it needs the R2 hostname allowed as a source host rather
than browser CORS. See When to use Eazip Cloud for the full
comparison.
Limits and alternatives
- A Local job still runs in one browser tab, so it inherits normal tab memory and lifetime limits for very large archives — that's what Cloud is for.
- Signed URLs expire. If listing and signing thousands of objects takes a
while, either raise
expiresInor sign lazily in batches. - If you only need to serve a single object, skip Eazip and return R2's own presigned GET URL — zipping is only useful once there's more than one file.
- If you need a full bucket mirror on disk rather than a browser download,
rcloneor the R2 S3 API are a better fit than a browser-built ZIP.
FAQ
How do I download an entire R2 folder as a ZIP?
List the objects under that prefix on your server, presign each with
getSignedUrl, and pass the resulting { url, filename } list to
createZip in the browser. See Prepare signed URLs on the
server above.
Can I zip R2 objects without sending them through my server?
Yes. Once the browser has signed URLs, Eazip fetches R2 directly and builds the ZIP client-side; your server only lists and signs, it never streams file bytes.
Does zipping R2 files with Eazip cost extra egress?
No. R2 has no egress fee, and Local Eazip jobs fetch from the browser, so
there's no bandwidth cost on your infrastructure either. You still pay
R2's normal per-request pricing for the GET and LIST calls.
How many R2 objects can I zip in the browser?
There's no hard limit from Eazip, but very large or numerous files will use tab memory and time. For thousands of objects or multi-gigabyte folders, use Eazip Cloud instead of Local.
Do I need R2 API tokens in the browser?
No, and you should not put them there. Keep the access key and secret on the
server that presigns URLs; the browser only ever receives short-lived signed
URLs. See Input types for what createZip accepts,
and Create a ZIP from remote URLs
for the general URL-list workflow.
Download an Entire S3 Bucket or Prefix as a ZIP
List a bucket prefix, presign each object, and package the results into one ZIP with @eazip/core in the browser.
Download All Firebase Storage Files as a ZIP
List a Cloud Storage for Firebase folder, turn the files into a ZIP in the browser, and scale past tab limits with Eazip Cloud.