DEV Community

Ryan
Ryan

Posted on Fully Autonomous

5 Ways to Download Supabase Storage Files as a ZIP

A "download all" button sounds simple until it has to combine private files, survive a slow connection, and deliver the same export twice.

For Supabase Storage, the important choice is where the ZIP is built, and whether you keep the finished archive. Signing several objects is not the same thing as building an archive: createSignedUrls returns individual signed URLs.

Disclosure: I build Eazip and its open-source browser library, used in methods 4 and 5. The first three approaches do not need either.

The short version

Approach Good fit Main constraint
Container or background worker Scheduled or large exports Worker resources, retries, archive cleanup
Edge Function, pre-built Small, bounded exports Function limits and upload reliability
Edge Function, streamed Small, immediate exports Function lifetime and the client's connection
Browser ZIP A simple download-all button Browser resources, CORS, tab lifetime
Managed ZIP API Exports you do not want to operate yourself Vendor dependency and usage charges

There is no universal archive-size cutoff. File count, origin throughput, compression, and client hardware all matter.

1. Build an archive in a container or background worker

The straightforward batch design is:

  1. Authenticate the request and resolve the files the user may export.
  2. Queue a job, fetch those files, and build a ZIP.
  3. Upload the archive to a private bucket.
  4. Return a signed link when the job completes.

This can support an on-demand button too: the UI polls job status instead of holding the original request open.

Budget for temporary storage if your implementation materializes both the originals and the ZIP. Add retries, idempotency, and an archive-expiry policy. Transfer charges depend on the runner's provider, region, and network path; do not assume a universal per-GB price.

The useful property: subsequent downloads can reuse the stored archive. They still transfer archive bytes, but they do not require fetching and zipping every original again.

2. Pre-build the ZIP in a Supabase Edge Function

A function can build an archive, upload it, and issue a signed link. This reduces the infrastructure you manage, but it does not remove resource limits.

Supabase currently documents 256 MB memory, 2 seconds of CPU time per request, worker lifetimes of 150 seconds on Free / 400 seconds on paid plans, and a separate 150-second request idle timeout. Worker lifetime is not a promise that every HTTP request can wait 400 seconds. See the official limits.

A streaming ZIP writer can reduce buffering, but only if the entire pipeline streams. For example, downloading an object into a Blob and then calling .stream() does not undo the earlier buffering.

For already-compressed photos or videos, storing entries without deflate is often a sensible starting point. Measure CPU and memory with representative files.

Supabase's standard upload documentation recommends resumable uploads for files above 6 MB, even though standard uploads support up to 5 GB. Upload capability is not proof an Edge Function can safely build an archive that large.

Use this for bounded exports you have tested, not as an unbounded "zip any bucket" endpoint.

3. Stream the ZIP from an Edge Function

Instead of uploading the ZIP, write it directly to the HTTP response.

You avoid retaining another copy, and the browser can start receiving bytes before the archive finishes. However, a slow client keeps the transfer open, and the function still has resource and lifetime limits.

A late source failure is awkward: after response headers have been sent, you cannot turn the partial ZIP into a clean JSON error response. Handle cancellation and stream errors explicitly. Do not assume byte-range resume works for a dynamically generated archive.

An unknown final size prevents a precise percentage based on total bytes, but your UI can still report transferred bytes or completed files.

Choose this only when the expected archive and client connections fit a measured budget. A stored artifact is easier to retry downloading without rebuilding.

4. Zip signed URLs in the browser

This avoids a ZIP worker entirely. The browser fetches the authorized objects and builds the archive.

The example below assumes supabase is your existing, signed-in browser client using a publishable/anon key, with Storage RLS policies configured. It must not contain a service-role key. paths is the user's selected object-path list, not an automatically recursive folder listing.

import { createZip } from '@eazip/core';

async function downloadSelection(paths: string[]) {
  if (paths.length === 0) return;

  const { data, error } = await supabase.storage
    .from('uploads')
    .createSignedUrls(paths, 3600);

  if (error) throw error;
  if (!data || data.length !== paths.length) {
    throw new Error('Could not sign every selected file');
  }

  const files = data.map((item) => {
    if (item.error || !item.signedUrl || !item.path) {
      throw new Error('A selected file could not be signed');
    }
    return { url: item.signedUrl, filename: item.path };
  });

  const result = await createZip({
    strategy: 'local',
    files,
    zipName: 'your-files.zip',
    compressionLevel: 0,
    failOnUrlError: true,
  });

  result.download();
}
Enter fullscreen mode Exit fullscreen mode

Install the library with npm i @eazip/core. This example uses its local strategy and requires no Eazip account. Catch errors in your UI and test CORS from the actual app origin.

Signed URLs are bearer credentials: anyone who has one can access its object until it expires. Avoid logging them. Give them enough lifetime for the download, without making them unnecessarily long-lived.

Tradeoff: browser resources and tab lifetime constrain the job. Starting another export fetches the originals again. Test mobile browsers too, not just your development laptop.

5. Hand the job to a managed ZIP API

Your authenticated backend can submit the same authorized file list to a service such as Eazip. Keep its API key server-side.

For Eazip, the request body for POST https://api.eazip.io/jobs can be:

{
  "mode": "stored",
  "files": [
    {
      "url": "<signed Supabase object URL>",
      "filename": "photos/photo.jpg"
    }
  ],
  "zip_filename": "your-files.zip",
  "expires_in": 3600
}
Enter fullscreen mode Exit fullscreen mode

Send it with your server-side X-API-Key and Content-Type: application/json. The response provides a job ID, not an immediately completed ZIP. Poll GET /jobs/{id} or use a completion webhook, then display the completed archive links. Handle multiple archives if the job is split.

The mode matters. In stored mode, repeat downloads of the same completed job reuse its retained archive. They do not refetch every Supabase object. Stream mode or submitting a new job does not provide that same reuse. Retries during preparation can also cause extra source reads, so "exactly one fetch" is not a safe promise.

The source URLs must survive queueing and fetching. Their expiry is separate from the finished archive's retention.

A managed service adds a vendor dependency, pricing, and another processor for your users' files. If browser ZIP works comfortably, you may not need one. The Supabase integration guide covers the Eazip path.

Two mistakes worth avoiding

Confusing stored reuse with zero egress. A stored archive served from Supabase still generates Supabase delivery traffic. An archive served elsewhere changes which provider serves subsequent downloads; it does not make the initial Supabase reads disappear. Supabase tracks cached and uncached egress separately, with organization-level quotas shared across projects and services. Check the current egress documentation against your actual path.

Trusting a bucket and prefix from the request body. A privileged server client must authenticate the caller and authorize every requested object. Never expose a generic service-role-backed export endpoint. Listing is also paginated; if your product supports folders, handle pagination and nested paths deliberately.

My default is browser ZIP for a modest interactive selection, a background job for a durable export, and an Edge Function only after testing the intended workload. The real distinction is not "server versus serverless" — it is whether the export can fail and resume independently of the user's tab.

Documentation and library interface reviewed September 9, 2026. Code syntax and the JSON payload were checked; the examples were not end-to-end tested against a live Supabase project. They are integration fragments, not a complete authentication or production job system.

Top comments (0)