Eazip.js
EazipGitHub

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…

Pick your path

Where the file URLs already live decides how much of this guide you need. The main path below assumes they exist only on your server. If that isn't your situation, the full decision guide covers every option.

Your situationPath
The browser already has the file URLsSkip the server step and pass them to createZip directly
The URLs live on your serverPresigned URL endpoint plus a browser ZIP — this guide's main path, below
Large or private exports on CloudA backend-created session, see Other setups

Prepare signed URLs on the server

Your backend should authenticate the request, authorize the requested folder, then list the objects and presign a GET URL for each one.

Either host works. Pick the Worker if your app already runs on Cloudflare and can list through an R2 bucket binding; pick Node if you have an app server doing the authorization anyway.

A bucket binding lists objects without an API token, but it cannot mint presigned URLs. Sign them with aws4fetch and R2's S3-compatible credentials, which is the approach Cloudflare documents for Workers:

// src/index.ts
import { AwsClient } from 'aws4fetch';

const BUCKET = 'my-bucket';
const EXPIRES_IN = 900; // seconds

export default {
  async fetch(request: Request, env: Env) {
    const prefix = new URL(request.url).searchParams.get('prefix') ?? '';

    // Authenticate the caller and authorize `prefix` before this point.

    const signer = new AwsClient({
      service: 's3', // required by the signer, ignored by R2
      region: 'auto',
      accessKeyId: env.R2_ACCESS_KEY_ID,
      secretAccessKey: env.R2_SECRET_ACCESS_KEY,
    });

    const endpoint = `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
    const files: { url: string; filename: string }[] = [];
    let cursor: string | undefined;

    do {
      const listed = await env.BUCKET.list({ prefix, cursor });

      for (const object of listed.objects) {
        if (object.key.endsWith('/')) continue;

        // Encode per path segment so key slashes survive as slashes.
        const key = object.key.split('/').map(encodeURIComponent).join('/');
        const signed = await signer.sign(
          new Request(
            `${endpoint}/${BUCKET}/${key}?X-Amz-Expires=${EXPIRES_IN}`,
          ),
          { aws: { signQuery: true } },
        );

        files.push({
          url: signed.url,
          filename: object.key.slice(prefix.length),
        });
      }

      cursor = listed.truncated ? listed.cursor : undefined;
    } while (cursor);

    return Response.json(files);
  },
};

signQuery: true puts the signature in the query string, so the browser can fetch the URL with no headers of its own. Keep the access key and secret in Worker secrets, not in wrangler.jsonc.

If you would rather not hold S3 credentials at all, serve each object from a Worker route with env.BUCKET.get(key) and return those route URLs instead — same-origin, no CORS policy needed, but every byte then flows through your Worker.

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.

Either way, the endpoint returns only { url, filename }[]. Storage credentials must never reach the browser. Set the expiry long enough to cover the whole ZIP build, not just the first request.

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.

Other setups

Two variations replace the presigned-URL endpoint entirely.

  • Backend-created session — for exports that are large, or whose source list shouldn't travel to the browser at all: a 1,000-object prefix otherwise means signing and shipping 1,000 URLs in one response. With createSession, your endpoint returns only { sessionId, clientSecret } and the URL list never crosses the network. See Backend-created sessions.
  • No browser involved — scheduled or automated exports, such as a nightly archive of a bucket, call the HTTP API directly with a secret key and receive a webhook when the ZIP is ready. No frontend SDK takes part.

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.