Next/Image with Supabase Storage: The Pattern That Saves 70% of Hero Image Bandwidth
4/15/2026 · John
next/image is the single biggest perf lever in a Next.js app you’re not using. It auto-resizes, auto-converts to WebP/AVIF, and serves the right size for the device. The catch: it has to know the source host, and if your images live in Supabase Storage, that means configuring `images.remotePatterns` correctly. This is the pattern we use on every FH client site.
Why next/image earns its keep
A homepage hero from an unoptimized JPEG is typically 800KB–2MB. The same hero served by next/image in AVIF at the right resolution for the device is 60–180KB. On mobile that’s the difference between a 2.4s LCP and a 4.1s LCP. Core Web Vitals are a ranking factor, so this isn’t cosmetic.
Step 1: configure remotePatterns
next/image refuses to optimize any URL whose host isn’t allowlisted in `next.config.ts`. The Supabase storage URL pattern is `<project-id>.supabase.co/storage/v1/object/public/<bucket>/<path>`. Allowlist it.
// next.config.tsimport type { NextConfig } from "next";const config: NextConfig = { output: "standalone", reactCompiler: true, images: { remotePatterns: [ { protocol: "https", hostname: "iymmsrexwwqwilmbpvna.supabase.co", pathname: "/storage/v1/object/public/**", }, ], },};export default config;
Step 2: use a typed URL helper
Don’t hand-roll Supabase URLs. They break when buckets get renamed. The FH `imageUrl()` helper in lib/storage.ts is the single source of truth — pass it a bucket-relative path and it returns a fully-qualified URL. If the bucket changes, one file changes.
// lib/storage.tsexport function imageUrl(path: string, opts: { bucket?: string } = {}) { const bucket = opts.bucket ?? "fh-images"; const cleaned = path.replace(/^\/+/, ""); return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/${bucket}/${cleaned}`;}
Step 3: use Image with explicit width/height
next/image needs width and height to reserve layout space — otherwise you get Cumulative Layout Shift when the image loads. Use the natural dimensions of the source image, or the rendered dimensions if you’re using `fill`.
import Image from "next/image";import { imageUrl } from "@/lib/storage";export function HeroImage() { return ( <Image src={imageUrl("home/hero.jpg")} alt="FH team at work" width={2400} height={1350} priority sizes="100vw" className="w-full h-auto" /> );}
Step 4: priority on the LCP image, lazy on the rest
Set `priority` on the single image most likely to be your Largest Contentful Paint candidate — usually the homepage hero. That image gets preloaded. Every other image stays lazy by default. We use `priority` on exactly one image per route.
Step 5: get sizes right
The `sizes` prop tells next/image which width to serve at each breakpoint. For a full-width hero, `sizes="100vw"`. For a half-width image at desktop, full-width on mobile, `sizes="(min-width: 768px) 50vw, 100vw"`. Getting this wrong means serving a 2400-wide hero to a 360-wide phone — 6x the bytes for no visual benefit.
Bucket organization across FH clients
We use one Supabase project for the entire client book, with a bucket per client (`fh-images`, `cab-images`, `tivey-images`). Each bucket is public-read, service-role-write. RLS is `site_id`-scoped so a client’s site can never accidentally read another client’s bucket through the API layer. The `imageUrl()` helper takes a `bucket` option for shared components.
Never expose the service-role key
Service-role keys bypass RLS. They should never appear in any file that ends up in the client bundle. Use them only in server actions, API routes marked `'server-only'`, and edge functions. We have the RLS post covering the wider posture — read it before you ship a Supabase-backed form.
Step 6: pre-process images before upload
next/image will resize at request time and cache the result. But it won’t fix a bad source. If your source image is a 14MB raw export, the first request to optimize it can time out on small Coolify VPSes. Pre-process: resize to 2400px max width, save as a high-quality JPEG, and let next/image handle the rest. We use a simple Sharp script in our content pipeline for this.
Cache control on optimized images
next/image caches optimized variants in `.next/cache/images`. By default the cache TTL is 60s for remote images, which is too short. Set `minimumCacheTTL` to a longer value to keep the cache warm.
images: { minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days remotePatterns: [/* … */],}
When to fall back to a plain <img>
Two cases. SVG icons inline — next/image is wasted on SVG, just inline it or use a plain `<img>`. And third-party images you don’t control (a partner logo from a CDN you don’t own) — those need to be allowlisted in remotePatterns, which spreads your trust boundary. For untrusted images, use a plain `<img>` with `loading="lazy"` and accept the bandwidth cost.
Verifying the win
After wiring next/image, open the Network tab in DevTools and look at the homepage hero. You should see a URL like `/_next/image?url=...&w=1920&q=75` with a content-type of `image/avif` or `image/webp`. The file size should be a fraction of the original. Run PageSpeed Insights and confirm LCP dropped.
Real numbers from the FH client book
BHR Construction homepage hero went from 1.7MB (JPEG, served directly) to 142KB (AVIF, next/image-served). LCP improved 0.9s. Page weight dropped 28%. Search Console impressions climbed steadily over the following 60 days as the Vitals improvement propagated.
Pulling it together
next/image + Supabase Storage + the `imageUrl()` helper is the FH image stack. Forty lines of config, one helper, and you’re shipping right-sized images on every page. If your site is still serving raw JPEGs or has next/image misconfigured, book a consultation — this is the kind of focused engagement that ships in 3 days and pays back in measurable ranking lift over the next quarter.
This is how your site would load
Image bandwidth is the first thing we cut on a slow small-business site. If yours is heavy, the fix ships as part of web design and build.
Frequently asked questions
Why does next/image earn its keep?
It resizes, re-encodes to modern formats, and serves the right variant per device, which routinely cuts hero bandwidth dramatically against an unoptimized upload. It also reserves layout space, which removes a common source of layout shift. Both effects show up in Core Web Vitals.
Why do my bucket images fail to optimize?
Because the storage host is not in the image config's remote patterns, so the optimizer refuses it. The symptom is either an error or a silently unoptimized image, and it is the single most common misconfiguration when images move from the public folder to a bucket.
Should I build image URLs by hand?
No. Hand-rolled public URLs spread the storage path across the codebase, so a bucket rename or a route change means finding every string. A typed helper keeps it in one place, which is the difference between a five-minute change and an afternoon.
Why does width and height matter if the image is responsive?
Because they establish the aspect ratio the browser reserves before the image arrives, which prevents layout shift. Responsive sizing still works; the explicit dimensions are about geometry rather than display size, and omitting them is a common cause of a poor visual-stability score.
What does priority do and where should it go?
It preloads the image and opts it out of lazy loading, and it belongs on exactly one image per page: the largest one visible on load. Marking several defeats the purpose by competing for bandwidth, and marking none leaves the main image queued behind everything else.
Why does the sizes attribute matter?
Because without it the browser assumes the image is full viewport width and downloads a much larger variant than a grid thumbnail needs. Getting sizes right is often a larger saving than any encoding change, and it is the setting most projects leave at the default.
Should images be processed before upload?
Yes. The optimizer resizes and re-encodes but cannot undo a badly cropped or wildly oversized original, and every request pays for the work. Uploading a sensibly sized master keeps the optimizer's job cheap and the results predictable.
How should buckets be organized across multiple client sites?
One bucket per client with access scoped per tenant, so a misconfiguration exposes one client rather than all of them. Service-role keys stay server-side. The organizing principle is that the blast radius of a mistake should be one tenant.
What cache headers should optimized images have?
Long-lived, because the optimized variants are content-addressed and safe to cache aggressively. Short cache lifetimes here mean re-doing the optimization work repeatedly, which shows up as slow first paints on repeat visits and unnecessary compute.
When should I use a plain img tag instead?
For SVGs, for tiny decorative icons, and for anything where the optimizer adds latency without saving bytes. The component is the default, not a rule, and forcing it onto every image produces cases where the machinery costs more than the image did.
How do I verify the optimization is actually happening?
Load the page and check what was transferred: format, dimensions, and size per image. If the bytes match the original upload, the image is bypassing the optimizer, which usually means a configuration miss rather than a component misuse.
Does this affect SEO or only performance?
Both, because the performance is the SEO. Largest Contentful Paint is usually a hero image on a marketing page, and it is a measured input to page experience. Getting image delivery right is one of the few changes that moves rankings and conversion with the same work.