Eazip.js
EazipGitHub

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.

S3 has no built-in "download folder as ZIP" action, so you list the objects under a prefix, presign each one, and hand the resulting { url, filename } list to @eazip/core. This keeps storage credentials on the server and lets the browser do the archiving.

Loading live demo…

Pick your path

Where the object URLs already live decides how much of this guide you need. The main path below assumes they exist only on your server, which is the usual case for a private bucket. 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 endpoint should:

  1. authenticate the application user;
  2. list the objects under the requested bucket and prefix;
  3. authorize the request against those specific objects; and
  4. return short-lived signed URLs, never AWS credentials.

Either host works, and the browser code is identical. Pick the Node route handler if you already run an app server that authenticates the user; pick Lambda if the export should live in AWS next to the bucket.

server/export.ts
import {
  S3Client,
  ListObjectsV2Command,
  GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({ region: 'us-east-1' });

type SignedSource = {
  url: string;
  filename: string;
};

export async function listPrefixAsSignedUrls(
  bucket: string,
  prefix: string,
): Promise<SignedSource[]> {
  const sources: SignedSource[] = [];
  let continuationToken: string | undefined;

  do {
    const page = await s3.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(
        s3,
        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;
}

The same listing and presigning logic runs as a Lambda function behind a function URL or an API Gateway HTTP API. Both deliver payload format version 2.0, so one handler serves either front door: read the prefix from event.queryStringParameters, and return a { statusCode, headers, body } object whose body is a JSON string.

index.mjs
import {
  S3Client,
  ListObjectsV2Command,
  GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

// Created outside the handler so it is reused across warm invocations.
const s3 = new S3Client({});
const BUCKET = process.env.EXPORT_BUCKET;

export const handler = async (event) => {
  const prefix = event.queryStringParameters?.prefix ?? '';

  // TODO: authenticate the caller and verify they may read `prefix`.

  const sources = [];
  let continuationToken;

  do {
    const page = await s3.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(
        s3,
        new GetObjectCommand({ Bucket: BUCKET, Key: object.Key }),
        { expiresIn: 900 },
      );

      sources.push({ url, filename: object.Key.slice(prefix.length) });
    }

    continuationToken = page.NextContinuationToken;
  } while (continuationToken);

  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(sources),
  };
};

Deploy this as index.handler on a current Node.js runtime. The runtimes ship the AWS SDK for JavaScript v3, but AWS recommends bundling the clients you use — @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner — with your deployment package so a runtime update can't change their behavior.

The function's execution role needs s3:ListBucket on the bucket itself and s3:GetObject on its objects. Presigning is a local signing operation, so the URLs the function hands out carry exactly these permissions and expire with expiresIn.

Because the browser calls the function from your app's origin, configure CORS on the function URL rather than emitting the headers from your code — Lambda answers the preflight itself, and hand-written headers on a GET response are appended to the configured ones, producing duplicates the browser rejects:

aws lambda create-function-url-config \
  --function-name export-bucket-prefix \
  --auth-type AWS_IAM \
  --cors '{
    "AllowOrigins": ["https://app.example.com"],
    "AllowMethods": ["GET"],
    "AllowHeaders": ["authorization", "content-type"],
    "MaxAge": 300
  }'

AWS_IAM requires each request to be SigV4-signed, which a browser does not do on its own. Use NONE only if the handler performs its own authentication — for example verifying a session cookie or bearer token before it lists anything. Behind API Gateway, configure CORS on the HTTP API and put a Lambda authorizer or JWT authorizer in front instead.

ListObjectsV2Command returns at most 1,000 keys per page, so a prefix with more objects needs the ContinuationToken loop above. Keep expiresIn short, but long enough for every file to finish downloading; a link that expires mid-fetch fails only that file, and the archive is still usable if you leave failOnUrlError at its default.

Configure bucket CORS

The browser fetches each object directly from S3, so the bucket must allow your application's origin:

s3-cors.json
[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["GET"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3000
  }
]

Apply it with the AWS CLI or the S3 console's CORS editor:

aws s3api put-bucket-cors \
  --bucket your-bucket \
  --cors-configuration file://s3-cors.json

Without this, the browser blocks the fetch and Eazip reports the file as failed even though the signed URL itself is valid.

Create the ZIP in the browser

download-export.ts
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();

files also accepts plain URL strings, or a mix of URLs and browser File or Blob objects if part of the export comes from a local picker; see Input types for every accepted shape.

createZip() resolves once every reachable object has been fetched and packaged, then result.download() starts the browser download. Use startZip() instead when you need live progress or a cancel button; see Create a ZIP from remote URLs for that shape and for how partial results behave when some objects fail.

When to use Eazip Cloud

Local jobs like the one above run entirely in the visitor's tab. Switch to Eazip Cloud when:

  • the prefix totals multiple gigabytes or thousands of objects;
  • the bucket cannot allow browser CORS, for example a strict compliance bucket;
  • the export should survive a page reload; or
  • you would rather not hold archive bytes in tab memory at all.

Cloud accepts the same signed URL list; only the strategy changes:

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, and make the presigned URL lifetime long enough for Eazip Cloud to start and finish reading each object. See When to use Eazip Cloud for the full comparison with Local.

Other setups

Two variations replace the presigned-URL endpoint entirely.

  • Backend-created session — for exports that are large, or whose object list shouldn't travel to the browser at all: a 1,000-key prefix otherwise means presigning 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 prefix, 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

Every approach here still pays S3's standard data transfer pricing: objects served to a fetch in the browser, or to Eazip Cloud, count as data transfer out of S3, billed at roughly $0.09/GB after the first 100 GB/month across your AWS account (US regions; other regions and higher tiers price differently). Cloud's stream mode removes the archive from your visitor's browser, but it does not remove that underlying S3 egress cost — someone still fetches every byte from S3 once.

Eazip is not the right tool for every S3 download:

  • A single object doesn't need a ZIP at all; return one presigned GetObjectCommand URL and let the browser download it directly.
  • A server-side archive, built with a Lambda function that streams objects into a ZIP and uploads the result, avoids sending bytes through the visitor's browser, but you take on Lambda's /tmp storage limit (up to 10 GB, configurable) and 15-minute maximum execution time yourself. Eazip Cloud exists largely so you don't have to build and operate that path.

FAQ

Can I download an S3 folder as a ZIP without a server?

Not safely for private data. A "folder" in S3 is just a shared key prefix, and listing or reading it requires AWS credentials or a signed URL, so some backend step has to authenticate the request and presign the objects. If the prefix is fully public, you can generate the URL list once and cache it, but the listing step still needs to run somewhere with S3 access.

Does S3 support ZIP downloads natively?

No. S3 stores and serves individual objects; it has no operation that returns a prefix as a single archive. Some tools work around this with S3 Batch Operations or a Lambda function, but both require you to write and run that archiving logic yourself. Eazip does the archiving in the browser or in Eazip Cloud instead.

Why does my S3 ZIP download fail with a CORS error?

The browser is fetching each signed URL directly from S3, and the bucket has no CORS rule allowing your origin. Add the CORS configuration shown above, and confirm AllowedMethods includes GET for the app's exact origin.

How large a bucket can Eazip zip?

A Local job is limited by the browser tab's memory and the tab staying open. For anything multi-gigabyte, thousands of objects, or that needs to survive a reload, use strategy: 'cloud' as shown above.

Do signed URLs expose my AWS credentials?

No. A presigned URL grants time-limited access to one object using your server's credentials to sign it; the credentials themselves never reach the browser. Keep expiresIn as short as your download flow allows.