Eazip
Guides

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 SettingsCORS PolicyAdd 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 expiresIn or 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, rclone or 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.