Serving and downloading files
Serve Supabase Storage files on SupaHost with public URLs, signed URLs and authenticated downloads, and set cache-control so browsers cache files served from your instance.
There are three ways to get a file out of Storage: a public URL for files in public buckets, a signed URL that grants temporary access to a private file, and an authenticated download made with the user's token. Which one you use depends on who should see the file and for how long.
On SupaHost, files are served directly from your instance. There's no CDN in front of Storage, so the cacheControl value you set at upload time is what tells browsers how long they can keep a copy.
Public URLs
Files in a public bucket have a fixed URL that anyone can open. No API key is needed:
https://app-one.acme.supahost.space/storage/v1/object/public/recipe-photos/r-118/lemon-tart.jpg
getPublicUrl() builds that URL for you. It doesn't make a network request and doesn't check whether the file exists.
const { data } = supabase.storage
.from('recipe-photos')
.getPublicUrl('r-118/lemon-tart.jpg')
console.log(data.publicUrl)
// Ask the browser to download instead of display
const { data: dl } = supabase.storage
.from('recipe-photos')
.getPublicUrl('r-118/lemon-tart.jpg', { download: 'lemon-tart.jpg' })
Public URLs are the cheapest option for your instance: no RLS check, no token, and browsers can cache them.
Signed URLs for private files
A signed URL works for one file until it expires. Use it to hand a private file to a browser, an <img> tag, or a third party without giving them your keys.
// The caller needs a select policy on this object (or use service_role on a server)
const { data, error } = await supabase.storage
.from('invoices')
.createSignedUrl('team-7/invoice-1042.pdf', 60 * 10) // valid for 10 minutes
if (error) throw error
window.open(data.signedUrl)
For lists of files, createSignedUrls() signs many paths in one request:
const paths = invoices.map((inv) => inv.pdf_path)
const { data } = await supabase.storage.from('invoices').createSignedUrls(paths, 600)
// data is an array of { path, signedUrl, error }
Options worth knowing:
{ download: true }or{ download: 'name.pdf' }makes the browser save the file instead of opening it.- Keep expiry times short for sensitive files. A signed URL keeps working until it expires, even if the user signs out or loses access in the meantime. See Private files still accessible after logout.
Authenticated downloads
download() fetches the file with the current user's access token and returns a Blob. RLS decides whether it's allowed. This suits files you process in code rather than link to.
const { data: blob, error } = await supabase.storage
.from('invoices')
.download('team-7/invoice-1042.pdf')
if (error) throw error
const url = URL.createObjectURL(blob)
// show it in an <iframe>, or trigger a save
On a server you can do the same with the service_role key for admin tasks, or with the user's token when acting on their behalf.
Choosing an approach
| Situation | Use |
|---|---|
| Product images, avatars, anything meant to be public | Public bucket + getPublicUrl() |
A private file shown in an <img> or opened in a new tab |
createSignedUrl() with a short expiry |
| A gallery of private files | createSignedUrls() |
| Code that needs the bytes (parsing, re-encoding, zipping) | download() |
| Emailing a link to a customer | A signed URL with an expiry that fits the email (hours or days) |
Caching
Each file's cache-control header comes from the cacheControl option you passed when uploading it, in seconds.
await supabase.storage.from('recipe-photos').upload(path, file, {
contentType: 'image/jpeg',
cacheControl: '31536000', // one year: safe because the path is unique
})
Guidelines:
- Unique paths, long cache. If each version of a file gets a new path (a UUID or a hash in the name), you can cache for a year. Changing the image means saving a new path in your table.
- Fixed paths, short cache. If you overwrite files in place (like
avatar.webp), keepcacheControlshort, such as'60'or'300', or add a version query string (?v=3) when you show it. - Private files. Signed URLs change each time you create them, so browsers treat each one as a new resource.
Because there's no CDN, every uncached download is served by your instance and counts toward your plan's monthly egress. Good cache headers reduce both load and egress. See Files not caching (cache-control) and Egress on SupaHost explained.
Content type and file names
Browsers use the stored Content-Type to decide whether to display or download a file. If PDFs download instead of opening, or images show as text, check the contentType used at upload:
select name, metadata->>'mimetype' as mimetype, metadata->>'size' as bytes
from storage.objects
where bucket_id = 'invoices'
order by created_at desc
limit 20;
To fix a wrong type, upload the file again with the right contentType and upsert: true.