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 situation | Path |
|---|---|
| The browser already has the file URLs | Skip the server step and pass them to createZip directly |
| The URLs live on your server | Presigned URL endpoint plus a browser ZIP — this guide's main path, below |
| Large or private exports on Cloud | A backend-created session, see Other setups |
Prepare signed URLs 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 reports the file as failed even though the signed URL itself is valid.
Create the ZIP in the browser
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();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
GetObjectCommandURL 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
/tmpstorage 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.