Skip to content

Supabase Storage for Marketing Sites: The Bucket-Per-Tenant Pattern

Most teams store images in their build artifact. That doesn’t scale. Supabase Storage with the right bucket layout does.

John Cravey with AIFounder5 min readUpdated Jul 6, 2026

Static images committed to a git repo work fine — until they don’t. Once a marketing site has 200+ photos, your repo size balloons, your Coolify build slows down to copy the assets, and you can’t update an image without a redeploy. Supabase Storage solves all three. Here’s the bucket layout, the access posture, and the helper that makes it ergonomic across the FH client book.

Free estimate · 2 minutes

Slow pages lose rankings and leads. Price the fix.

Build the estimate to see what a fast rebuild would look like for your site and what we would ship in the first thirty days.

Why Storage beats /public for media

  • Git repo stays small. Marketing photos do not belong in version control.
  • Image updates don’t require a redeploy. Upload a new file to the bucket and it’s live (with next/image caching headers respecting the change).
  • CDN-backed delivery. Supabase Storage is fronted by a CDN already; pair it with Cloudflare and you have two layers of caching.
  • Per-tenant isolation. Different buckets for different clients, with RLS-scoped access if you need it.

The bucket layout we use across FH

One public bucket per client (`fh-images`, `cab-images`, `tivey-images`, `bhr-images`, `bestbarns-images`, `james-marina-images`, `rrmw-images`). Each bucket is public-read for anon, write for service-role only. Inside the bucket, paths mirror the site’s information architecture: `home/hero.jpg`, `team/john.avif`, `portfolio/bhr/exterior-1.jpg`, `blog/seo-cost/cover.jpg`.

We don’t version the path. If we replace `home/hero.jpg` with a new image, the URL doesn’t change. CDN cache invalidation is handled by the deploy script — or, in practice, by the next/image cache TTL expiring.

Bucket creation and policies

Create the bucket in the Supabase dashboard, mark it public, then write the RLS policy for write access. Anon read is automatic on public buckets.

-- Storage RLS policy: only service-role can upload
create policy "service-role uploads" on storage.objects
  for insert
  with check (auth.jwt() ->> 'role' = 'service_role');

create policy "service-role updates" on storage.objects
  for update
  using (auth.jwt() ->> 'role' = 'service_role');

create policy "service-role deletes" on storage.objects
  for delete
  using (auth.jwt() ->> 'role' = 'service_role');

Uploading: the FH workflow

We never upload through the dashboard for production assets. The workflow is a small Node script that takes a local file, runs it through Sharp to resize and convert to multiple formats (AVIF, WebP, original), then uploads each variant to the bucket. The script writes a `meta.json` alongside each asset with license, source, intended channel, and the curation score from the intake-image flow.

import { createClient } from "@supabase/supabase-js";
import sharp from "sharp";
import { readFile } from "node:fs/promises";

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

async function uploadHero(localPath: string, bucketPath: string, bucket: string) {
  const buffer = await readFile(localPath);
  const resized = await sharp(buffer)
    .resize({ width: 2400, withoutEnlargement: true })
    .jpeg({ quality: 88 })
    .toBuffer();
  await admin.storage.from(bucket).upload(bucketPath, resized, {
    contentType: "image/jpeg",
    cacheControl: "public, max-age=31536000, immutable",
    upsert: true,
  });
}

Resolving URLs: the imageUrl() helper

Every site has a `lib/storage.ts` with an `imageUrl()` function that resolves a bucket-relative path into a fully-qualified Supabase URL. Pass it a path; get back a URL. The bucket defaults to the per-site bucket but can be overridden for shared components that pull images from multiple tenant buckets.

// lib/storage.ts
const FALLBACK = "https://iymmsrexwwqwilmbpvna.supabase.co";
const URL = process.env.NEXT_PUBLIC_SUPABASE_URL ?? FALLBACK;
export const DEFAULT_BUCKET = "fh-images";

export function imageUrl(path: string, opts: { bucket?: string } = {}) {
  const bucket = opts.bucket ?? DEFAULT_BUCKET;
  const cleaned = path.replace(/^\/+/, "");
  return `${URL}/storage/v1/object/public/${bucket}/${cleaned}`;
}

Cache headers

Supabase Storage respects the `cacheControl` you set on upload. We default to `public, max-age=31536000, immutable` for any image whose URL won’t change. If the URL might change (a header that gets re-uploaded with the same name during a site refresh), drop the `immutable` and lower max-age — otherwise CDN intermediaries will hold the old version for a year.

Private buckets for sensitive media

For client-portal documents, signed-URL gated photos, or anything that shouldn’t be world-readable, use a private bucket with RLS policies on `storage.objects`. The client SDK requests a signed URL with `createSignedUrl(path, expiresIn)`, the URL is valid for the requested duration, and outside that window the asset is unreachable.

const { data, error } = await supabase
  .storage
  .from("bhr-private")
  .createSignedUrl("contracts/2026-q2-statement.pdf", 600);
// data.signedUrl is valid for 10 minutes

Asset curation: meta.json sidecar

Every image in an FH bucket has a sibling `meta.json` with the source, license, prompt or camera details, intended channel, and a 0–10 score per the FH curation rubric. The rubric blocks AI-slop tells (melted hands, six-fingered hands, plastic skin) and stock-photo cliches (handshake, generic team-around-laptop). Curation gates run automatically when a file lands in the bucket’s inbox prefix.

Bandwidth math at scale

Supabase Pro includes 250GB egress per month. At an average optimized hero of 60KB, that covers roughly 4M hero loads per month across all clients. If you exceed it, overage is $0.09/GB. Even at 4× the included egress, the bill is $90 — still cheaper than ten separate CMSs charging $40 each.

Migrating from /public to Supabase Storage

We’ve done this migration on three legacy client sites. The pattern: (1) script that uploads every file in `/public/images/` to the appropriate bucket; (2) global find-and-replace from `/images/...` to `imageUrl("...")`; (3) verify with a build that no `/images/` references remain; (4) deploy. Total time on a 200-asset site: 90 minutes.

How this lands across FH client work

Every FH client site reads images from Supabase Storage through the `imageUrl()` helper. Total storage across the client book is around 40GB. Egress is tracked monthly; we’ve never exceeded the included allowance. If your site is committing 300MB of marketing images to git on every deploy, book a consultation — the migration to Storage usually pays for itself in build-time savings inside two months.

Answers

Frequently asked questions

Why use object storage instead of the public folder?

Because images in the repository are deployed rather than uploaded, which means every image change is a build, the repository grows forever, and non-developers cannot add anything. Storage separates the media lifecycle from the code lifecycle, which is what makes routine image updates routine.

Why a bucket per tenant?

Blast radius. A misconfigured policy on a per-tenant bucket exposes one client. The same mistake on a shared bucket with path prefixes exposes everyone, and path-based isolation fails the moment a helper accepts a path from somewhere it should not.

Should buckets be public or private?

Public for marketing media that the site serves anyway, private for anything a user uploaded or anything with personal information. The default should be private, with public a deliberate choice per bucket, because the cost of getting that backwards is asymmetric.

Why use a URL helper instead of building paths inline?

Because hand-built public URLs scatter the storage host and bucket name through the codebase, and every rename or migration becomes a search-and-replace across files. One typed helper is the difference between a config change and an afternoon.

What cache headers should storage objects carry?

Long-lived, with the filename changing when the content does. Media that never changes under a stable name is the ideal case for aggressive caching, and short lifetimes here mean paying for the same bytes repeatedly with no benefit to anyone.

Do images still need next/image if they come from a bucket?

Yes. Storage serves the original; the image component resizes, re-encodes, and picks the variant per device. Without it you serve a full-resolution master to a phone, which is the bandwidth problem people assume storage solved on its own.

What is the meta.json sidecar for?

Recording where an asset came from, its licence, its intended channel, and whether a release exists for anyone in it. Six months later that record is the only thing standing between you and guessing, and guessing about licensing is how a cheap image becomes an expensive one.

How much bandwidth does this actually save?

The saving comes from optimization rather than from storage itself: serving a right-sized modern-format image instead of a multi-megabyte original is where the multiple lives. Storage makes that pipeline possible; the format and sizing decisions are what produce the number.

How do I migrate from the public folder?

Upload in place, switch references to the helper, verify the pages render, then remove the originals in a separate commit. Deleting first turns a mistake into an outage, and doing both in one commit makes the review impossible to read.

What stops a service-role key leaking to the browser?

Keeping it in server-only modules and never in anything a client component imports. The framework will not stop you; a key referenced in a module that gets pulled across the client boundary ships to the browser, and it grants full access with RLS bypassed.

Should client sites share a storage project?

They can share the project while keeping a bucket each, which is the pattern here. What must not be shared is credentials: a key that reads every bucket held by anything client-specific removes the isolation the bucket layout was for.

How do I know if the storage setup is working?

Load a page and inspect what was transferred: the host, the format, and the size. Bytes matching the original upload mean the optimizer is being bypassed. A storage host missing from the image config is the usual cause, and it fails quietly.

Question we did not answer? Ask us directly and we will answer it here.

John Cravey, Founder
Written by
John Cravey
Founder

Founder of Frontend Horizon. Writes most of the long-form work on the FH blog.

Newer post
Server Components vs Client Components: The Mental Model That Stops You Reaching for ‘use client’
Older post
Privacy-First Analytics in 2026: GDPR, CCPA, AI Act, and What SMBs Actually Need
Keep reading

More from the blog

Next.js·4 min

Next/Image with Supabase Storage: The Pattern That Saves 70% of Hero Image Bandwidth

Most teams either skip next/image (and ship 4MB heroes) or misconfigure it (and break Coolify deploys). Here’s the pattern that works.

Supabase·4 min

Supabase Performance: Indexing, Connection Pooling, and the Postgres Settings That Matter

Supabase is Postgres. Most performance issues are Postgres issues with Postgres solutions.

SEO·10 min

Automated Technical SEO Audits: Crawl, Score, and Fix With AI

A once-a-year audit finds problems a year too late. The point of automating it is that it never stops looking.