Eazip
Eazip.jsPricingSign inStart free
Guides

Zip Supabase Storage Files into a Download Link via API

Create signed URLs for any set of Supabase Storage objects with one createSignedUrls call, POST the list to Eazip, and hand out an expiring ZIP download link — with the egress-quota math that decides the approach.

This guide turns any set of Supabase Storage objects — a folder, one user's uploads, or a hand-picked list — into a ZIP download link from your backend. Sign the paths, POST them as one job, and give the link to whoever needs it. No bucket CORS configuration, no archive bytes through your server, no browser tab involved.

Building a button the user clicks in your web app instead? See the integration decision guide. Still comparing approaches? Start with 3 Ways to Download Supabase Storage Files as a ZIP.

Sign the objects

Supabase makes this step easier than any S3-style store: createSignedUrls signs a whole array of paths in one call — no per-object round trips:

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY,
);

export async function listFolderAsSignedUrls(bucket: string, folder: string) {
  const { data: objects, error: listError } = await supabase.storage
    .from(bucket)
    .list(folder, { limit: 1000 });
  if (listError) throw listError;

  const paths = objects
    .filter((object) => object.id !== null) // folders come back as null-id rows
    .map((object) => `${folder}/${object.name}`);

  const { data, error } = await supabase.storage
    .from(bucket)
    .createSignedUrls(paths, 3600);
  if (error) throw error;

  return data
    .filter((entry) => entry.signedUrl)
    .map((entry) => ({ url: entry.signedUrl, filename: entry.path ?? '' }));
}

Two things to know about this snippet:

  • It runs with the service role key, which bypasses Row Level Security — so the endpoint that calls it must do its own authorization (does this user own this folder?) before signing anything. Never expose the service role key to the browser.
  • list() paginates with limit/offset and defaults to 100 rows; loop the offset for folders beyond the limit you set.

Create the job

const files = await listFolderAsSignedUrls('uploads', `user-${userId}`);

const response = await fetch('https://api.eazip.io/jobs', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.EAZIP_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    files,
    zip_filename: 'your-files.zip',
    expires_in: 172800,
  }),
});

const { job_id } = await response.json();

A webhook or a GET /jobs/:id poll returns the expiring download link when the job completes. The full flow, delivery options, and webhook payloads live in Create a ZIP from URLs with One API Call; this page stays on what is specific to Supabase.

What an export costs you on Supabase

Supabase bills egress against a unified project quota — database, API, and Storage all draw from the same pool (at the time of writing: 5 GB per month on the Free plan, 250 GB included on Pro, then $0.09/GB):

  • Every byte Eazip fetches counts against that quota once, when the archive is prepared (stored mode, the default). A 10 GB gallery export consumes 10 GB of quota — the same as one user downloading those files directly.
  • Repeat downloads consume nothing further. The archive is served from Eazip's zero-egress storage, so a customer downloading the ZIP five times draws your Supabase quota once, not five times. If your current "download all" implementation proxies files per download, this is usually the single biggest quota saving available.
  • On the Free plan, mind the 5 GB ceiling: a couple of large exports can exhaust the whole project's monthly egress. That is a Supabase property, not a ZIP one — but exports make it visible fast.

Supabase-specific limits and gotchas

  • Signed URL lifetime is yours to choose (expiresIn in seconds — they are JWT-based, with no S3-style 7-day ceiling). Short is still right: sign just before creating the job.
  • Per-file upload limit: projects default to 50 MB per object, raisable in project settings on paid plans. Exports never hit this, but it shapes what's in the bucket in the first place.
  • list() returns folder placeholder rows (id: null) mixed with real objects — filter them, or the job records fetch failures for folder "objects".
  • RLS does not protect service-role signing. The security boundary is your endpoint's own check. If you would rather keep authorization in RLS, sign with the user's own JWT client instead and accept per-user policies as the limit.
  • Archives cap at 50 GB each; set max_zip_size_bytes to auto-split larger exports, up to 500 GB of total output per job on the largest plan.

FAQ

Does the export flow through my server?

No. Your server sends only the signed URL list. Eazip fetches the objects from Supabase Storage directly and serves the download itself.

Can I do this without the service role key?

Yes — a client authenticated as the user can call createSignedUrls under that user's RLS policies. The service role path is for backend endpoints that authorize the request themselves.

Does a big export eat my Supabase egress quota?

Once per export, yes — the preparation fetch counts like any other Storage egress. What it saves you is every download after that: repeat downloads of the finished ZIP never touch Supabase.

How many files can one job include?

Plan-bound on the Eazip side: 100 on the free tier, up to 20,000 on the largest plan. On the Supabase side, remember list() pagination when a folder holds more than your listing limit.

My export is bigger than 50 GB — can this still work?

Yes. 50 GB is the per-archive cap. Set max_zip_size_bytes and the job splits into numbered ZIPs, each with its own download link.