Download Supabase Storage Files as a ZIP in the Browser
Sign a whole folder with one createSignedUrls call and let @eazip/core fetch and package the files into a ZIP in the user's browser — no bucket CORS setup, no server proxying.
This guide gives your Supabase app a "download all" button, with the ZIP
built in the user's browser. Supabase makes this the easiest storage
platform to wire up: createSignedUrls signs a whole array of paths in
one call, and its signed URLs respond with permissive CORS — so there is
usually no storage configuration step at all.
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 Supabase Storage Files as a ZIP; prefer to build the ZIP from your backend instead? See Zip Supabase Storage Files into a Download Link via API.
All the browser code
import { createZip } from '@eazip/core';
const response = await fetch('/api/exports/my-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: 'my-files.zip',
});
result.download();The rest of the guide builds the endpoint that returns that list — or, if you keep authorization in Row Level Security, you can skip the endpoint entirely and sign in the browser (below).
Sign the files
Option 1 — in the browser, under RLS. A client authenticated as the user can sign the user's own files directly, governed by your storage policies. No endpoint, no service key in play:
const { data: objects } = await supabase.storage
.from('uploads')
.list(`user-${userId}`, { limit: 1000 });
const paths = objects
.filter((object) => object.id !== null) // folder rows come back with a null id
.map((object) => `user-${userId}/${object.name}`);
const { data: signed } = await supabase.storage
.from('uploads')
.createSignedUrls(paths, 3600);
const files = signed
.filter((entry) => entry.signedUrl)
.map((entry) => ({ url: entry.signedUrl, filename: entry.path ?? '' }));Option 2 — on your server, with the service role key. When authorization lives in your application code rather than RLS, sign in an endpoint. The service role key bypasses RLS, so the endpoint must verify ownership itself before signing — and the key never reaches the browser. The server-side version of this listing-and-signing helper is in the API guide.
Either way: list() paginates (100 rows by default — raise limit or
loop the offset for big folders), and filter out the folder placeholder
rows (id: null) so the job doesn't try to fetch them.
When browser limits bite
A Local job runs in the visitor's tab: memory holds the archive, the job ends with the tab, and each download re-fetches every file — which also draws your Supabase egress quota every time. Switch the same call to managed Eazip when exports get heavy or are downloaded repeatedly:
const result = await createZip({
strategy: 'cloud',
publicKey: 'pk_ez_...',
files,
zipName: 'my-files.zip',
});
result.downloadAll();Add your project's storage hostname to the Public App's allowed source hosts. On the managed path the sources are fetched once, while the archive is prepared; repeat downloads never touch Supabase again — usually the biggest quota saving available. The numbers are in the API guide.
FAQ
Do I need to configure CORS on my bucket?
Usually not — Supabase's signed URL responses allow cross-origin GET,
which is why this recipe has no CORS section. If you serve files through a
custom proxy or transformations, test one fetch from your origin first.
Can I do this without any backend code at all?
Yes — Option 1 signs in the browser under the user's own RLS policies. You need a backend only when authorization logic lives outside RLS, or when you move to backend-created Cloud sessions later.
Do browser downloads count against my Supabase egress quota?
Yes — every fetch of the files draws the project's unified egress quota (5 GB/month on the Free plan). A Local ZIP draws it per download; the managed path draws it once per export.
Why do some entries fail with "object not found"?
Almost always the folder placeholder rows from list() — they look like
objects but have id: null and no content. Filter them before signing, as
in the snippet above.
How many files can one browser ZIP include?
Practical limits are tab memory and shipping the URL list to the client — a 1,000-file export means 1,000 signed URLs in one response. Past that, sign server-side and use the managed path, or a backend-created session.
Download Cloudflare R2 Files as a ZIP in the Browser
Pass presigned R2 object URLs to @eazip/core and the user's browser fetches and packages them into one ZIP. R2 charges no egress, so the fetches cost nothing in bandwidth.
Create a ZIP File in the Browser with JavaScript
Turn browser files, blobs, and URLs into a ZIP entirely client-side — one createZip call, no server, no upload, with the options that actually matter.