Download Cloudflare R2 Files as a ZIP in the Browser
Pass presigned R2 object URLs to @eazip/core and the user's browser fetches and packages them into one ZIP. R2 charges no egress, so the fetches cost nothing in bandwidth.
This guide gives your web app a "download all" button for a Cloudflare R2 bucket or prefix, with the ZIP built in the user's browser. R2 credentials stay on the server, no archive bytes flow through your backend — and because R2 charges no egress, the browser's direct fetches from the bucket cost you nothing in bandwidth, however often users click.
Here is the result — try it:
Loading live demo…
Still choosing between browser, server-side, and managed approaches? The neutral comparison is 4 Ways to Download Cloudflare R2 Files as a ZIP; prefer to build the ZIP from your backend instead? See Zip R2 Files into a Download Link via API.
All the browser code
The complete browser side — it fetches a { url, filename } list from
your server and hands it to Eazip.js:
import { createZip } from '@eazip/core';
const response = await fetch('/api/exports/bucket-prefix', {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Could not prepare export files');
}
const files: { url: string; filename: string }[] = await response.json();
const result = await createZip({
files,
zipName: 'bucket-export.zip',
});
result.download();If the bucket is public — an r2.dev subdomain or a custom domain — you
may already be done: pass those URLs straight to createZip and skip to
bucket CORS. For a private bucket, the rest of
this guide builds the endpoint and the CORS rule the snippet relies on.
Feed it URLs: presign on the server
R2 speaks the S3 API, so the AWS SDK presigns against your account endpoint with an R2 API token (Cloudflare dashboard → R2 → Manage API Tokens):
import {
S3Client,
ListObjectsV2Command,
GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const r2 = 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 listPrefixAsSignedUrls(bucket: string, prefix: string) {
const sources: { url: string; filename: string }[] = [];
let continuationToken: string | undefined;
do {
const page = await r2.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix,
ContinuationToken: continuationToken,
}),
);
for (const object of page.Contents ?? []) {
if (!object.Key || object.Key.endsWith('/')) continue;
const url = await getSignedUrl(
r2,
new GetObjectCommand({ Bucket: bucket, Key: object.Key }),
{ expiresIn: 900 },
);
sources.push({ url, filename: object.Key.slice(prefix.length) });
}
continuationToken = page.NextContinuationToken;
} while (continuationToken);
return sources;
}Authenticate the caller and authorize the prefix before signing anything —
the endpoint is the security boundary. ListObjectsV2 returns at most
1,000 keys per page, hence the ContinuationToken loop. No Worker is
required anywhere in this path; any backend with an R2 API token can sign.
Configure bucket CORS
The browser fetches each object directly from R2, so the bucket must allow your application's origin. Set it in the Cloudflare dashboard (R2 → your bucket → Settings → CORS policy) or through the S3 API:
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3000
}
]Without this, the browser blocks the fetches and Eazip.js reports the files as failed even though the signed URLs are valid.
When browser limits bite
A Local job runs in the visitor's tab: memory holds the archive, and the job ends if the tab closes. Switch the same call to managed Eazip when the prefix reaches multiple gigabytes or thousands of objects, when the export should survive a reload, or when the bucket can't allow browser CORS:
const result = await createZip({
strategy: 'cloud',
publicKey: 'pk_ez_...',
files,
zipName: 'bucket-export.zip',
});
result.downloadAll();Add the bucket's hostname to the Public App's allowed source hosts. The zero-egress pairing is notable here: R2 charges nothing for Eazip's fetches and Eazip adds no bandwidth fee, so even the managed path has no per-GB bandwidth cost — see Zip R2 Files into a Download Link via API for the full cost math.
FAQ
Do the browser's downloads from R2 cost me bandwidth?
No. R2 charges zero egress; you pay read operations instead, at $0.36 per million after the free allowance — effectively nothing at download-button scale.
Can I skip presigning?
Yes, for public buckets: URLs on r2.dev or a custom domain are directly
fetchable (the public host still needs a CORS rule for your origin).
Private buckets need the signing endpoint above.
Do I need a Cloudflare Worker for this?
No. Presigning uses R2's S3-compatible endpoint from any backend. A Worker is just one possible host for the signing endpoint, not a requirement.
Why do my fetches fail with CORS errors even though the URLs work in a new tab?
Opening a URL directly isn't subject to CORS; fetch from your app's
origin is. Add the bucket CORS rule above with your exact origin,
including the port for local development.
How large an export can the browser handle?
Tab memory and tab lifetime are the limits — multi-gigabyte archives belong on the managed path (one option change, shown above), which on R2 keeps the entire pipeline free of bandwidth charges.
Download an S3 Bucket as a ZIP in the Browser
Pass a list of S3 object URLs to @eazip/core and the user's browser fetches and packages them into one ZIP. Private buckets add a small presigning endpoint and a CORS rule.
Download Supabase Storage Files as a ZIP in the Browser
Sign a whole folder with one createSignedUrls call and let @eazip/core fetch and package the files into a ZIP in the user's browser — no bucket CORS setup, no server proxying.