Eazip.js
EazipGitHub
Guides

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.

This guide gives your web app a "download all" button for an S3 bucket or prefix, with the ZIP built in the user's browser. AWS credentials stay on the server, and no archive bytes flow through your backend.

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 an Entire S3 Bucket as a ZIP; this page implements the browser path in full. Prefer to build the ZIP from your backend instead? See Create a ZIP from URLs with one API call.

All the browser code

This is 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 browser already has fetchable URLs — public objects, or signed URLs you obtained some other way — you are done: pass them straight to createZip and skip to bucket CORS. files also accepts plain URL strings and browser File or Blob objects, mixed freely; see Input types for every accepted shape.

For a private bucket — the usual case — the rest of this guide builds the two things the snippet above relies on: the /api/exports/bucket-prefix endpoint, and a bucket CORS rule.

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 Partial results and errors for how the job behaves when some objects fail, and the Core Reference for both call shapes.

Feed it URLs: presign 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.js reports the file as failed even though the signed URL itself is valid.

When browser limits bite

A Local job like the one above runs entirely 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 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.

The signed URL list stays the same; 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 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; the integration decision guide compares all of them.

  • 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, create the job from your server and receive a webhook when the ZIP is ready. See Zip S3 Files into a Download Link via API.

Limits and alternatives

Every approach here still pays S3's standard data transfer pricing: objects served to a browser fetch, or to managed Eazip, 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). Moving the job to managed Eazip changes who fetches the bytes, not that underlying S3 egress cost — someone still reads every object from S3 once.

Eazip.js 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 one-off pull of a bucket you own is faster with aws s3 sync and a local zip — no code to deploy. The comparison post covers when each approach wins.

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. Every "download all as ZIP" feature is built on top of S3 — this guide builds it in the browser.

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 a browser ZIP handle?

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.