This guide walks you through building a complete image optimisation pipeline in a Next.js 15 app. By the end, you will have automated format conversion, responsive sizing, lazy loading, and CDN delivery working together in production. Plan for about 90 minutes if you are starting from an existing project.

What You'll Build

  • A Next.js 15 image pipeline that serves AVIF and WebP automatically based on browser support
  • Responsive image sets generated at build time and on demand, with correct srcset and sizes attributes
  • A custom loader that routes images through Cloudflare Images or a self-hosted Sharp instance
  • A Lighthouse CI check (tested on Node 20+ and pnpm 9) that gates deploys when image scores drop below 90
  • A measurable reduction in total image payload, typically 40-60% compared to unoptimised JPEG delivery

Prerequisites

  • A Next.js 15 project (App Router). This guide uses Next.js 15.2 and React 19.
  • Node 20+ and pnpm 9 installed locally
  • A Vercel account, or a VPS with Docker, for deployment
  • Basic familiarity with React Server Components
  • Optional: a Cloudflare account if you want the CDN loader path

Step 1: Audit Your Current Image Weight

Why does the audit come first?

Without a baseline, you cannot prove the pipeline made a difference. The audit takes five minutes and gives you a number to beat.

Run Lighthouse against your homepage using the CLI:

pnpm dlx lighthouse https://your-site.com \
  --only-categories=performance \
  --output=json \
  --output-path=./lighthouse-baseline.json

Open lighthouse-baseline.json and note two fields: total-byte-weight and uses-optimized-images. Write them down. You will compare against these numbers in Step 7.

Common pitfall: auditing localhost instead of a staging URL gives you no CDN latency data and inflates your score artificially.

Step 2: Configure the Next.js Image Component Correctly

What does the default config miss?

Out of the box, next/image in Next.js 15 serves WebP but not AVIF by default, because AVIF encoding is slower at build time. You need to opt in explicitly.

Open next.config.ts and update the images block:

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 31536000, // 1 year in seconds
    dangerouslyAllowSVG: false,
    contentDispositionType: 'attachment',
  },
};

export default nextConfig;

The formats array is ordered by preference. Browsers that support AVIF get AVIF. Others get WebP. Old browsers fall back to the original format.

Pro tip: set minimumCacheTTL to 31536000 (one year) for static assets. Next.js appends a content hash to image URLs, so cache busting is automatic.

Step 3: Replace Raw img Tags With the Next.js Image Component

When should you skip this step?

Skip it only for images rendered inside third-party iframe embeds or inside a legacy CMS widget you cannot touch. Everything else should use next/image.

Here is the pattern for a hero image in a Server Component:

import Image from 'next/image';

export default function HeroSection() {
  return (
    <section>
      <Image
        src="/images/hero.jpg"
        alt="A team collaborating on a digital product roadmap"
        width={1920}
        height={1080}
        priority
        sizes="100vw"
        quality={80}
      />
    </section>
  );
}

Use priority only on the largest above-the-fold image. Next.js adds a <link rel="preload"> for it, which can improve LCP by 200-400ms on real devices.

For images below the fold, omit priority. The component lazy loads them by default using the native loading="lazy" attribute, which is now supported in all major browsers as of 2026.

Common pitfall: setting sizes="100vw" on a sidebar image that is actually 300px wide. Use an accurate sizes value so the browser downloads the right source. For example: sizes="(max-width: 768px) 100vw, 300px".

Step 4: Set Up a Custom Image Loader for Cloudflare Images

Why use a custom loader instead of the default?

The default Next.js image API runs on your server or serverless function. Under high traffic, this adds CPU cost and cold start latency. Cloudflare Images handles resizing and format conversion at the edge, closer to your users in Sydney, Singapore, Toronto, or Chicago.

Create a file at lib/cloudflareLoader.ts:

type ImageLoaderProps = {
  src: string;
  width: number;
  quality?: number;
};

export default function cloudflareLoader({
  src,
  width,
  quality,
}: ImageLoaderProps): string {
  const params = [`width=${width}`, `quality=${quality ?? 80}`, 'format=auto'];
  return `https://your-account.cloudflareaccess.com/cdn-cgi/image/${params.join(',')}/${src}`;
}

Then register the loader in next.config.ts:

const nextConfig: NextConfig = {
  images: {
    loader: 'custom',
    loaderFile: './lib/cloudflareLoader.ts',
    formats: ['image/avif', 'image/webp'],
    minimumCacheTTL: 31536000,
  },
};

Replace your-account.cloudflareaccess.com with your actual Cloudflare Images delivery domain. You can find it in the Cloudflare dashboard under Images > Overview.

Pro tip: if you prefer a self-hosted option, Sharp running as a separate microservice behind an Nginx reverse proxy gives you equivalent control without a Cloudflare subscription. The loader URL pattern is the same; only the base URL changes.

Step 5: Handle Remote Images Safely

What if your images come from a CMS or user uploads?

Next.js 15 blocks remote image domains by default to prevent server-side request forgery. You must declare allowed patterns explicitly.

Add a remotePatterns array to next.config.ts:

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.yoursanityproject.io',
        pathname: '/images/**',
      },
      {
        protocol: 'https',
        hostname: '*.supabase.co',
        pathname: '/storage/v1/object/public/**',
      },
    ],
    formats: ['image/avif', 'image/webp'],
    minimumCacheTTL: 31536000,
  },
};

Use specific hostnames rather than wildcards wherever possible. A wildcard like ** in the hostname field accepts any subdomain, which is acceptable for your own CDN but should not be used for user-generated content from external sources.

Common pitfall: adding a new CMS hostname to remotePatterns but forgetting to redeploy. Next.js reads next.config.ts at build time, not at runtime.

Step 6: Generate a Blur Placeholder for Layout Stability

How does this affect Core Web Vitals?

Images without a placeholder cause layout shift while the browser calculates dimensions. This hurts your Cumulative Layout Shift (CLS) score. Google's Core Web Vitals threshold for a good CLS score is below 0.1.

For local images, Next.js can generate a Base64 blur placeholder automatically:

import Image from 'next/image';
import heroImage from '@/public/images/hero.jpg';

export default function HeroSection() {
  return (
    <Image
      src={heroImage}
      alt="Product dashboard overview"
      placeholder="blur"
      priority
      sizes="100vw"
      quality={80}
    />
  );
}

Importing the image as a module gives Next.js access to the file at build time. It generates a 10px Base64 thumbnail and embeds it inline as the placeholder. The user sees a blurred shape instead of empty space while the full image loads.

For remote images, you need to provide a blurDataURL manually. Generate one using the plaiceholder package during a build step or at request time in a Server Component.

Step 7: Add a Lighthouse CI Gate to Your Deploy Workflow

Why automate the performance check?

Manual audits get skipped under deadline pressure. A CI gate runs every time someone opens a pull request, so regressions are caught before they reach production.

Install the Lighthouse CI GitHub Action:

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on:
  pull_request:
    branches: [main]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: pnpm install
      - run: pnpm build
      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v11
        with:
          urls: |
            https://staging.your-site.com
          budgetPath: ./lighthouse-budget.json
          uploadArtifacts: true

Create lighthouse-budget.json in your project root:

[
  {
    "path": "/*",
    "timings": [
      { "metric": "interactive", "budget": 3500 },
      { "metric": "first-contentful-paint", "budget": 1500 }
    ],
    "resourceSizes": [
      { "resourceType": "image", "budget": 500 }
    ],
    "resourceCounts": [
      { "resourceType": "image", "budget": 20 }
    ]
  }
]

The image resource size budget of 500KB is a reasonable starting point for a marketing homepage. Adjust it based on the baseline you measured in Step 1.

Step 8: Verify Results and Compare to Baseline

Deploy to staging, then run the same Lighthouse command from Step 1 against the staging URL. Compare total-byte-weight and uses-optimized-images to your baseline numbers.

Projects at Lenka Studio typically see a 40-55% reduction in image payload after completing this pipeline on existing Next.js apps. AVIF delivers an additional 20-30% size saving over WebP for photographic images at equivalent perceived quality.

If your score has not improved, check the Network tab in Chrome DevTools. Filter by Img. Confirm the response Content-Type header shows image/avif or image/webp, not image/jpeg. If it still shows JPEG, your custom loader URL is probably malformed.

Frequently Asked Questions

Does this pipeline work with the Next.js Pages Router as well as the App Router?

Yes. The next/image component and next.config.ts settings work identically in both routers. The Server Component blur placeholder import pattern is App Router only, but the rest of the guide applies to both.

What if Cloudflare Images is outside my budget?

The default Next.js image optimisation API works without Cloudflare. It runs on Vercel's edge network at no extra cost within the Vercel free and Pro plans. The custom loader in Step 4 is optional. Skip Steps 4 and update next.config.ts without the loader and loaderFile fields.

How is this different from just compressing images manually before uploading?

Manual compression produces one file at one size. This pipeline generates multiple sizes and formats at request time and serves the right one based on the user's browser and screen width. A user on a 375px mobile screen gets a 400px-wide AVIF. A desktop user gets a 1920px-wide AVIF. Manual compression cannot do this automatically.

Will AVIF encoding slow down my build?

It can, for large image libraries. Next.js caches optimised images in .next/cache/images between builds, so the slowdown only happens once per unique image and size combination. On Vercel, this cache persists across deployments. On self-hosted setups, mount the cache directory as a persistent volume.

What if the Lighthouse CI action fails on the first run?

The most common cause is that the staging URL is not publicly accessible from GitHub Actions runners. Either make the staging URL public, or use a Lighthouse CI server with a token-based setup. The official LHCI documentation covers both options in detail.

Next Steps

With your image pipeline in place, the next logical area to address is third-party script loading, which is often responsible for more LCP delay than images. Look at the next/script component's strategy prop, specifically the lazyOnload and afterInteractive options.

If your site serves users across Australia, Singapore, Canada, and the US, also review your CDN configuration to confirm cache hit rates above 90% in each region. A single-region origin with no CDN can add 300-500ms of latency for users who are geographically far from your server.

If you want a second set of eyes on your current setup, the team at Lenka Studio reviews Next.js performance configurations regularly as part of development audits. Reach out through the contact page and describe your current stack. We are happy to take a look.