Building an Admin Media Library With Supabase Storage in Next.js
How a real admin media library was built on Supabase Storage for a Next.js admin dashboard, including the access mistake to avoid.
An admin CMS needs a media library the moment you have more than a handful of blog posts — otherwise every image upload becomes a manual file-naming exercise, and nobody can find an asset they uploaded three weeks ago. Here's how a real media library was built on top of Supabase Storage for a Next.js admin dashboard, including the parts that aren't obvious from the docs.
Why Supabase Storage instead of just Vercel's filesystem
Next.js on Vercel runs on a read-only filesystem in production — you cannot write an uploaded file to disk and expect it to still be there on the next request, because serverless functions don't share persistent local storage between invocations. Supabase Storage (backed by S3-compatible object storage) solves this the same way it solves it for any serverless deployment: uploads go to a bucket over the network, not to local disk, and a public or signed URL is what gets stored in your database — not a file path.
The bucket structure that actually matters
A single flat bucket works for a small number of files, but an admin media library benefits from folder-like key prefixes that mirror how content is actually organized:
media/
posts/{post-id}/{filename}
authors/{author-id}/{filename}
og-images/{slug}.png
Supabase Storage doesn't have real folders — object keys just contain / characters that most tools display as folders. Structuring keys this way means listing "everything for post X" is a simple prefix query (media/posts/{post-id}/) instead of needing a separate database table just to track which files belong to which post.
Upload flow: signed URLs, not routing files through your own API
The naive approach routes an uploaded file through a Next.js API route, which then forwards it to Supabase. That works, but it means every upload's bytes pass through your own serverless function twice — once in from the browser, once out to storage — burning function execution time and hitting body-size limits on larger images unnecessarily.
The better pattern requests a signed upload URL from Supabase directly, and has the browser upload straight to storage:
const { data, error } = await supabase.storage
.from('media')
.createSignedUploadUrl(`posts/${postId}/${filename}`);
// Browser then PUTs the file directly to data.signedUrl
Your Next.js backend never touches the file bytes at all — it only authorizes the upload path and records the resulting public URL in your posts table afterward. This is the difference between an upload flow that scales and one that quietly breaks the first time someone uploads a 15MB image.
The access-control detail that's easy to skip
A public bucket makes serving images to site visitors simple — no signed URL needed on the read side, since blog post images need to be publicly loadable by anyone. But "public bucket" doesn't mean "public write access." The bucket's read policy and its write policy are configured completely separately in Supabase: public SELECT (read) on the bucket's objects, combined with a Row Level Security policy on storage.objects that only allows INSERT from authenticated admin users. Getting this backwards — locking down reads while leaving writes open — is the mistake that's invisible until someone finds they can upload arbitrary files to your bucket anonymously.
The actual lesson
The parts of a media library that feel like implementation detail — key structure, upload flow, and which side of read/write actually needs the access restriction — are the parts that determine whether it holds up under real use or turns into an unmaintainable pile of oddly-named files with an accidentally open upload endpoint. Getting the schema and the UI right is the easy half; getting the storage access model right is the half that actually matters for security and long-term maintainability.
Frequently Asked Questions
Why can't uploaded files just be saved to the Next.js server's filesystem on Vercel?
Vercel's production filesystem is read-only and doesn't persist between serverless function invocations — a file written on one request won't exist on the next. Uploads need to go to persistent object storage like Supabase Storage instead.
Should file uploads be routed through a Next.js API route first?
Not ideally. Routing the file through your own API doubles the bytes your serverless function handles and risks body-size limits. Requesting a signed upload URL and having the browser upload directly to storage avoids both problems.
Does a public storage bucket mean anyone can upload files to it?
No — read access and write access are configured completely separately. A bucket can have public SELECT for serving images to visitors while still requiring an authenticated admin role for INSERT, via a Row Level Security policy on storage.objects.
What's the benefit of structuring storage keys like folders (e.g. posts/{id}/{filename})?
It lets you query 'everything for this post' as a simple prefix match on the object key, without needing a separate database table just to track which files belong to which content.
📬 Get the latest AI tool reviews
Expert picks and comparisons, weekly. No spam.