Backend & DevOps Blog

Real-world experiences with MongoDB, Docker, Kubernetes and more

Optimizing Images in Next.js

I recently implemented Next.js Image component to optimize our app's performance, but ran into several unexpected issues with external images. After hours of troubleshooting, I discovered the right configuration patterns that work reliably in both development and production environments.

Why I Chose next/image

Our web app was loading high-resolution images directly, causing slow page loads and poor Core Web Vitals scores. The Next.js Image component promised automatic:

  • Responsive sizing
  • WebP/AVIF format conversion
  • Lazy loading
  • Preventing layout shifts with correct aspect ratios

Implementing it seemed straightforward - just replace <img> with <Image>, right? Not quite.

First Attempt: The 400 Bad Request Error

I replaced our standard image tags with the Next.js Image component:

// Before
<img src="https://external-cdn.com/images/photo.jpg" alt="Product" />

// After
import Image from 'next/image';

<Image 
  src="https://external-cdn.com/images/photo.jpg"
  alt="Product"
  width={600}
  height={400}
/>

Everything worked fine in development, but in production, I got blank squares with console errors:

Error: Failed to load resource: the server responded with a status of 400 (Bad Request)
GET https://my-website.com/_next/image?url=https%3A%2F%2Fexternal-cdn.com%2Fimages%2Fphoto.jpg&w=1200&q=75

The Domain Configuration Issue

After searching through documentation, I realized Next.js requires explicit permission for external domains. By default, it only optimizes images from your own domain as a security measure.

I needed to update my next.config.js file:

// next.config.js
module.exports = {
  images: {
    domains: ['external-cdn.com'],
  },
};

But this created a new problem. We had multiple CDN domains, and hard-coding each one wasn't sustainable.

The Dev vs. Prod Environment Mystery

I noticed something odd - the images worked in development without the domains config. Why?

It turns out Next.js development server is more permissive with image optimization, but production builds enforce stricter security. This explained why our tests passed locally but failed in production.

A Better Solution: Using remotePatterns

In Next.js 13+, there's a better approach called remotePatterns that supports wildcards:

// next.config.js
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**.our-cdn-network.com',
      },
      {
        protocol: 'https',
        hostname: 'content-delivery.com',
      },
    ],
  },
};

This worked much better, allowing us to match multiple subdomains with a single rule.

The CDN Cache Problem

Just when I thought everything was working, we hit another issue: our CDN was caching the image optimizer's responses incorrectly.

Users were sometimes seeing wrong image sizes or formats because our CDN cached the first version requested and served it to everyone.

I had to adjust our CDN configuration to vary the cache based on the image query parameters:

# CDN Configuration 
# Vary cache based on width and quality parameters
cache-key-query-params: w,q

Final Production Solution

After all the troubleshooting, here's the configuration that worked reliably in production:

  1. Comprehensive remotePatterns in next.config.js:
    // next.config.js
    module.exports = {
      images: {
        remotePatterns: [
          {
            protocol: 'https',
            hostname: '**.*-cdn.com',
          },
        ],
        // Set reasonable defaults for image optimization
        deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
        imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
        formats: ['image/webp', 'image/avif'],
      },
    };
  2. Correct CDN configuration to respect varying image dimensions
  3. Client-side component usage:
    'use client'; // Mark as client component if in app router
    
    import Image from 'next/image';
    
    export default function ProductImage({ src, alt }) {
      return (
        <div className="relative w-full h-[300px]">
          <Image
            src={src}
            alt={alt}
            fill
            sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
            className="object-cover rounded-lg"
            priority={false}
            quality={80}
          />
        </div>
      );
    }

Lessons Learned

The experience taught me several valuable lessons:

  • Always test image optimization in a production build, not just development
  • Use remotePatterns instead of domains for more flexible configuration
  • Consider how CDN caching interacts with dynamic image optimization
  • The sizes attribute is crucial for optimal responsive image loading
  • In app router, remember to mark Image-using components as client components when needed

After implementing these changes, our Lighthouse performance score improved from 72 to 94, and our Largest Contentful Paint metric dropped from 2.8s to 1.2s. The effort was definitely worth it.