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 EleviFounder5 min read

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.

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.

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.

AI·4 min

Anthropic API Prompt Caching: The Pattern That Saves Thousands on Content Generation

Prompt caching cuts our content-gen costs by an order of magnitude. Here’s how and where it works.