Optimising Images in Next.js: Beyond next/image Next.js image optimisation with next/image, custom loaders, and Cloudinary for web performance

When developers first discover next/image, there's a moment of relief — automatic WebP, lazy loading, no layout shift. Then they deploy to production, run a Lighthouse audit, and find images are still the largest contributor to LCP. The issue isn't that next/image is broken; it's that the defaults only go so far. This guide covers what the component actually does under the hood, where it falls short, and how to configure it precisely for the performance characteristics your specific project needs.

What next/image Actually Does

The next/image component handles four things automatically. First, it converts source images to WebP (or AVIF if configured) on demand, serving the optimised format to browsers that support it. Second, it applies lazy loading by default, adding loading="lazy" to the underlying <img> element so images below the fold don't block the initial page load. Third, it generates a srcset with multiple sizes so the browser can request an appropriately scaled image rather than downloading a 4000px wide photo for a 400px thumbnail slot. Fourth, it reserves layout space using the width and height props you provide, preventing cumulative layout shift as images load.

What it does not do is equally important to understand. It does not compress your source images — if you upload a 15 MB unoptimised JPEG, Next.js optimises it on first request and caches the result, but your source stays large. It does not handle art direction, meaning you cannot serve a fundamentally different image composition on mobile versus desktop using next/image alone. And it does not manage cache invalidation on external CDNs — if you update an image at the same URL on Cloudinary, Next.js's internal cache may serve the old version until the minimumCacheTTL expires.

Priority Loading: Getting LCP Right

The priority prop on next/image is the single highest-impact change you can make for Largest Contentful Paint. Without it, Next.js applies loading="lazy" to every image. The browser won't even begin fetching a lazy image until it determines the image is close to the viewport — which means your hero image, the one the user sees first, starts downloading late.

Adding priority removes the lazy attribute and adds a <link rel="preload"> in the document head, telling the browser to fetch this image at the highest priority during the initial resource scan:

import Image from 'next/image';

// Your hero image — the LCP element
<Image
  src="/hero-backwaters-kerala.jpg"
  alt="Kerala backwaters at sunrise — Alleppey houseboat tour"
  width={1200}
  height={630}
  priority  // ← this is the critical addition
  sizes="100vw"
/>

To identify your actual LCP element, open Chrome DevTools, go to the Performance panel, record a page load, and look for the "LCP" marker in the timeline. Click it and DevTools highlights the element. Alternatively, run Lighthouse and expand the "Largest Contentful Paint" opportunity — it names the element.

A common mistake is adding priority to every image above the fold. The browser only has so much concurrent bandwidth. If you mark three images as priority, you're telling the browser to fetch all three at maximum priority simultaneously, which can actually slow down the single most important image. Mark only the true LCP element — usually the hero image or featured photo — with priority.

Blur Placeholders Without Performance Cost

Blur placeholders give users immediate visual feedback that an image is loading, reducing perceived wait time. Next.js supports two approaches, and choosing the wrong one for your use case adds unnecessary weight to your pages.

For statically imported images, placeholder="blur" works automatically — Next.js generates a tiny base64-encoded preview at build time:

import Image from 'next/image';
import heroPhoto from '../public/hero-backwaters.jpg';

<Image
  src={heroPhoto}
  alt="Kerala backwaters houseboat"
  placeholder="blur"  // base64 preview auto-generated from static import
  priority
/>

For dynamic images — URLs from a CMS or database — you need to supply the blurDataURL yourself. Generating a proper blur hash at request time would be slow, so the right approach is to generate it at build time or store it alongside the image URL in your database. The plaiceholder library handles this well:

import { getPlaiceholder } from 'plaiceholder';

// Run this at build time in getStaticProps or a data pipeline
export async function getStaticProps() {
  const imageUrl = 'https://example.com/product-photo.jpg';
  const { base64 } = await getPlaiceholder(imageUrl);

  return {
    props: {
      imageUrl,
      blurDataURL: base64,  // store this with your product data
    },
  };
}

// Then in your component:
<Image
  src={imageUrl}
  alt="Product photo"
  width={800}
  height={600}
  placeholder="blur"
  blurDataURL={blurDataURL}
/>

On a Kerala tourism site with a photo gallery of 10 images loading below the fold, blur placeholders visually fill the space immediately as the page loads, making the page feel snappier even before a single full image has arrived. The base64 strings are tiny — typically 400–800 bytes each — so adding them to your data store is essentially free.

Custom Loaders for Cloudinary and Cloudflare Images

The loader prop lets you replace Next.js's built-in image optimisation with a function that constructs the URL for a third-party CDN. This is how you integrate Cloudinary or Cloudflare Images while still using the next/image component's sizing and layout behaviour.

A Cloudinary loader looks like this:

// lib/cloudinaryLoader.ts
const cloudinaryLoader = ({
  src,
  width,
  quality,
}: {
  src: string;
  width: number;
  quality?: number;
}) => {
  const params = [
    'f_auto',          // automatic format selection (WebP, AVIF)
    'c_limit',         // don't upscale
    `w_${width}`,
    `q_${quality ?? 75}`,
  ].join(',');

  return `https://res.cloudinary.com/your-cloud-name/image/fetch/${params}/${src}`;
};

// Usage
<Image
  loader={cloudinaryLoader}
  src="https://your-origin.com/product-photo.jpg"
  alt="Cotton saree, handwoven in Kochi"
  width={600}
  height={800}
/>

On pricing for an Indian e-commerce site with 10,000 product images at moderate traffic: Cloudinary's free tier includes 25 GB storage and 25,000 monthly transformations. At ₹0 for the free tier, it handles most small to medium stores. Their paid plans start at roughly ₹1,600/month (Plus plan). Cloudflare Images charges $5/month per 100,000 images stored, plus $1 per 100,000 delivery requests — for 10,000 products at 50,000 monthly page views, you're looking at approximately ₹500–₹800/month. Vercel's built-in optimisation is free up to 5,000 image optimisations per month on the Pro plan, then $5 per additional 5,000 — workable for small catalogues but expensive at scale.

The genuine advantage of a custom CDN over Next.js's built-in handling isn't just cost — it's edge caching. Cloudflare Images and Cloudinary both cache optimised images at CDN edge locations globally, meaning repeat visitors in Chennai or Dubai get the image from a nearby PoP rather than from your origin server or Vercel's optimisation infrastructure.

Art Direction: Different Images for Different Screens

The sizes prop on next/image controls which srcset entry the browser downloads at different viewport widths — but it always uses the same underlying image file, just at different resolutions. True art direction — showing a tall portrait on mobile and a wide landscape on desktop — requires the HTML <picture> element:

<picture>
  <source
    media="(min-width: 768px)"
    srcSet="/hotel-room-landscape-wide.webp"
    width="1200"
    height="630"
  />
  <source
    media="(max-width: 767px)"
    srcSet="/hotel-room-portrait-tall.webp"
    width="600"
    height="900"
  />
  {/* Fallback using next/image for optimization of the default */}
  <img
    src="/hotel-room-landscape-wide.jpg"
    alt="Deluxe room at a Kovalam beach resort, Kerala"
    loading="eager"
    width="1200"
    height="630"
    style={{ width: '100%', height: 'auto' }}
  />
</picture>

For a hotel booking page, this matters commercially. The portrait crop on mobile shows the full room height, bed, and amenities in a single shot. The landscape crop on desktop shows the room and sea view simultaneously. These are different compositions, not just different sizes — and next/image's sizes prop alone cannot produce this result.

Handling SVGs in Next.js

next/image does not accept SVG files by default, and for good reason — SVGs can contain executable JavaScript, making them a security risk when rendered from untrusted sources. Trying to force an SVG through next/image either throws an error or requires you to explicitly allow it in next.config.js with dangerouslyAllowSVG: true, which then also requires a contentDispositionType and contentSecurityPolicy header setup.

The cleaner approach is to handle SVGs by their actual use case:

Icons and UI elements that need CSS styling — use inline SVG or an SVGR import so you can apply fill, stroke, and currentColor:

// With SVGR configured in next.config.js
import ChevronIcon from '../public/icons/chevron.svg';

<ChevronIcon className="icon-chevron" aria-hidden="true" />

Decorative illustrations from a trusted source — use a regular <img> tag. SVGs don't need responsive srcsets because they're already resolution-independent:

<img
  src="/illustrations/kerala-map-outline.svg"
  alt="Outline map of Kerala showing district boundaries"
  width="400"
  height="600"
  loading="lazy"
/>

User-uploaded or untrusted SVGs — never render these inline. Sanitise with DOMPurify server-side before storage, or serve them behind a CSP that blocks script execution.

Performance Budget for Images

A useful target for most content pages is keeping total image weight under 500 KB per page load. This isn't an arbitrary number — it's roughly the point at which images stop being the dominant load time contributor on a 4G connection in Kerala (typical real-world throughput: 5–15 Mbps, with latency spikes). On fibre in Bangalore or Singapore, 2 MB of images is unnoticeable; on an Airtel 4G connection in Wayanad during evening peak hours, it's the difference between a 2-second and a 6-second load.

You can enforce this budget automatically with Lighthouse CI in a GitHub Actions workflow:

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]
jobs:
  lhci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci && npm run build
      - run: npx @lhci/cli@0.14.x autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
// lighthouserc.js
module.exports = {
  ci: {
    assert: {
      assertions: {
        'resource-summary:image:size': ['error', { maxNumericValue: 512000 }], // 500 KB
        'uses-optimized-images': 'error',
        'uses-webp-images': 'warn',
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
      },
    },
  },
};

When a pull request pushes image weight over the budget, the CI check fails before the code ships. This is far more effective than periodic Lighthouse audits because it catches regressions at the source.

Frequently Asked Questions

Why is next/image still showing large file sizes in the Network tab even with optimization?

The most likely cause is a mismatch between the sizes prop and the actual rendered width. If you don't set sizes, Next.js assumes the image fills the full viewport width and serves a correspondingly large srcset entry. For example, an image that only takes up 50% of the screen on desktop should have sizes="(min-width: 1024px) 50vw, 100vw". Without this hint, the browser picks a srcset entry designed for full-width display, inflating the download size significantly. Check the Network tab's Size column — if it's large, inspect which srcset entry was selected and adjust your sizes prop accordingly.

Should I use Cloudinary or Next.js's built-in image optimization for an e-commerce site with 5,000 product images?

At 50,000 pageviews per month, Next.js's built-in optimisation on Vercel costs roughly ₹0–₹400 because Vercel charges per 1,000 images optimised (first 5,000 are free per month). Cloudinary's free tier covers 25,000 transformations and 25 GB of managed storage — more than enough for 5,000 products. The real differentiator is not price at this scale, but control: Cloudinary gives you art direction, face-aware cropping, background removal, and a CDN with edge caching across 200+ PoPs. If your product images need consistent cropping and presentation, Cloudinary's transformation pipeline is worth the setup. If your images are already well-prepared and you just need WebP conversion and responsive srcsets, Next.js's built-in handling is simpler.

Does next/image work with images stored in Supabase Storage?

Yes. Add your Supabase Storage domain to the remotePatterns array in next.config.js, then use the public URL from Supabase Storage directly as the src prop. The config entry looks like: remotePatterns: [{ protocol: 'https', hostname: '*.supabase.co', pathname: '/storage/v1/object/public/**' }]. Alternatively, write a custom loader that constructs the Supabase Storage URL with transformation parameters if you use Supabase's image transformation add-on, which supports resizing and format conversion through URL query parameters.