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 { useEazip } from '@eazip/react';
export function ExportButton() {
const zip = useEazip();
async function handleExport() {
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();
zip.download({ files, zipName: 'bucket-export.zip' });
}
return <button onClick={handleExport}>Download all files</button>;
}<EazipTray />, rendered once near your app root, shows progress and hands
over the finished download.
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:
- authenticate the application user;
- list the objects under the requested bucket and prefix;
- authorize the request against those specific objects; and
- 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.
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.
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:
[
{
"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.jsonWithout 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
GetObjectCommandURL and let the browser download it directly. - A one-off pull of a bucket you own is faster with
aws s3 syncand a localzip— 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.