Create a ZIP From Presigned URLs
Turn any storage provider's expiring signed URLs into one browser ZIP download with @eazip/core, without exposing credentials.
Any storage that issues expiring, signed URLs works the same way: your server
authenticates the request and signs each object, and the browser fetches the
resulting URLs and zips them with @eazip/core. This pattern applies whether
the URLs come from Amazon S3, an S3-compatible provider, or a completely
different object store — the only thing that changes is how you generate the
URL.
Loading live demo…
Pick your path
Where the signed URLs already live decides how much of this guide you need. The main path below assumes your server has to mint them. If that isn't your situation, the full decision guide covers every option.
| Your situation | Path |
|---|---|
| The browser already holds expiring URLs | Skip the server step and pass them to createZip directly |
| Your server must mint the signed URLs | Signing endpoint plus a browser ZIP — this guide's main path, below |
| Large or private exports on Cloud | A backend-created session, see Other setups |
The pattern
Every provider that supports presigned or signed URLs needs the same four server-side responsibilities:
- authenticate the application user;
- authorize the specific objects being requested;
- create short-lived signed URLs; and
- return only
{ url, filename }entries — never storage credentials.
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({
region: 'auto',
endpoint: process.env.STORAGE_ENDPOINT,
});
type SignedSource = {
url: string;
filename: string;
};
export async function signSources(keys: string[]): Promise<SignedSource[]> {
return Promise.all(
keys.map(async (key) => ({
url: await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: process.env.BUCKET, Key: key }),
{ expiresIn: 900 },
),
filename: key,
})),
);
}Only the endpoint (and sometimes region) changes between providers,
because @aws-sdk/s3-request-presigner works against any S3-compatible API.
That covers Cloudflare R2, Backblaze B2, DigitalOcean Spaces, Wasabi, MinIO,
Hetzner Object Storage, Scaleway Object Storage, OVHcloud Object Storage, and
Tigris. Providers with their own SDK — Supabase Storage, Firebase Storage,
Azure Blob Storage — use a different signing call, but return the same
{ url, filename } shape.
Create the ZIP in the browser
Once your endpoint returns signed sources, fetching and zipping them is provider-agnostic:
import { useEazip } from '@eazip/react';
export function ExportButton() {
const zip = useEazip();
const downloadExport = async () => {
const response = await fetch('/api/exports/123/files', {
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: 'export.zip',
});
};
return <button onClick={downloadExport}>Download export</button>;
}Render <EazipTray /> once near your app root — it
shows progress and the finished download.
import { createZip } from '@eazip/core';
const response = await fetch('/api/exports/123/files', {
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: 'export.zip',
});
result.download();createZip() resolves once every reachable URL has been fetched and packaged;
result.download() then starts the browser download. files also accepts
plain URL strings or a mix of URLs and browser File/Blob objects — see
Input types for every accepted shape. For a Local
job, the storage provider must allow browser CORS on GET requests from your
application's origin, or Eazip reports each blocked file as failed.
Pick the URL lifetime
The signed URL's expiry has to outlive the ZIP job, not just the moment the link is generated:
- Small jobs (a handful of files) can use a short expiry, such as the 15-minute default shown above — the whole fetch usually finishes in seconds.
- Large jobs (many files, or large files on a slow connection) need a longer expiry, because the last file is signed once but fetched only after every earlier file has downloaded.
- A URL that expires mid-job fails only that one file. With
failOnUrlErrorleft at its default,createZip()still returns a partial ZIP with the files that succeeded; setfailOnUrlError: trueif every file is required and a failure should reject the whole job instead.
When in doubt, sign for longer than you think you need. A signed URL that sits unused for a few extra minutes costs nothing; one that expires before the fetch runs turns into a support ticket.
When to use Eazip Cloud
Local jobs run entirely in the visitor's browser tab and cover most everyday
downloads. Switch to strategy: 'cloud' when:
- the archive is multi-gigabyte or contains thousands of signed URLs;
- the storage provider can't or won't allow browser CORS;
- the job needs to survive a page reload; or
- you'd rather not hold archive bytes in tab memory at all.
const result = await createZip({
strategy: 'cloud',
publicKey: 'pk_ez_...',
files,
zipName: 'export.zip',
});
result.downloadAll();Cloud fetches the same signed URLs outside the browser, so add the storage provider's hostname to the Public App's allowed source hosts. Stream mode is the default and adds no separate outbound-bandwidth fee on top of Eazip's own metered usage, though the storage provider may still charge for the source fetch; stored mode instead keeps a reusable archive artifact. See When to use Eazip Cloud for the full comparison with Local.
Other setups
Two variations replace the signing 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-file export 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.
FAQ
How long should presigned URLs live for a ZIP job?
Long enough to outlast the whole job, not just the request that creates them. Size the expiry to your slowest expected download, and remember the last file in the list is fetched only after every earlier one — so a large job needs a longer expiry than a single-file download link.
Do presigned URLs need CORS for browser downloads?
Yes, for Local jobs. The browser fetches each signed URL directly from the
storage provider, so the provider must return Access-Control-Allow-Origin
for your application's origin on GET requests. Eazip Cloud fetches outside
the browser and isn't subject to browser CORS.
Can I zip presigned URLs from any S3-compatible provider the same way?
Yes. @aws-sdk/s3-request-presigner and the AWS SDK's S3Client work against
any S3-compatible endpoint, including Hetzner Object Storage, Scaleway Object
Storage, OVHcloud Object Storage, and Tigris — point endpoint at the
provider and the rest of the signing and zipping code stays the same.
What happens if one signed URL expires before Eazip fetches it?
By default, only that file is skipped; createZip() still resolves with a
partial ZIP containing the files that succeeded, and result.status is
'partial'. Set failOnUrlError: true if the job should fail outright
instead.
Do I need Eazip Cloud just to use presigned URLs?
No. Presigned URLs work with Local by default — Cloud is only needed once the job outgrows the browser tab, as described above.
Next steps
- Create a ZIP from remote URLs for the general URL-to-ZIP workflow, partial results, and retries.
- Download an entire S3 bucket or prefix as a ZIP for AWS-specific listing and CORS setup.
- Zip Cloudflare R2 objects for R2's presigning specifics.
- Input types for every shape
filesaccepts. - When to use Eazip Cloud for the full Local vs. Cloud comparison.