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.ts
import 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.ts
export 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.
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.
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.