DEV Community

Cover image for Image Caching and CDN Strategies in Next.js — Getting Fast Image Delivery Right
Aon infotech
Aon infotech

Posted on

Image Caching and CDN Strategies in Next.js — Getting Fast Image Delivery Right

Image performance is usually the largest opportunity in a Next.js application. Images are often the heaviest assets on the page, the most likely LCP element, and the area where caching strategy has the most impact on user experience.

Here's what actually works for image caching and CDN delivery in Next.js App Router — including the patterns I've found necessary building an application where image delivery is literally the core product at pixova.io/blog/ai-generated-images-no-copyright.


How Next.js Image Optimization Works

Next.js built-in image optimization (next/image) handles several things automatically:

  1. Format conversion — serves WebP or AVIF to browsers that support them, falling back to the original format
  2. Size optimization — generates appropriately sized images based on the sizes attribute
  3. Lazy loading — defers loading of off-screen images by default
  4. Blur placeholder — shows a blurred version while the full image loads What it doesn't do: manage CDN caching headers in most deployment configurations, or give you control over the cache-control headers on generated images.

Cache-Control Headers — What Next.js Sets by Default

When Next.js serves an optimized image, it sets a cache-control header based on the minimumCacheTTL configuration:

// next.config.js
module.exports = {
  images: {
    minimumCacheTTL: 60, // 60 seconds (default)
  },
};
Enter fullscreen mode Exit fullscreen mode

The default is 60 seconds — which means optimized images are only cached for 1 minute. For static images that don't change, this is extremely conservative and causes unnecessary re-requests.

Increase this for production:

module.exports = {
  images: {
    minimumCacheTTL: 31536000, // 1 year in seconds
    formats: ['image/avif', 'image/webp'],
  },
};
Enter fullscreen mode Exit fullscreen mode

A year-long TTL is appropriate for images with content-addressed URLs (where the filename changes when the content changes). For images with stable filenames that might change, use a shorter TTL and rely on cache invalidation.


Remote Images and CDN Configuration

For images served from an external CDN or storage service, you need to explicitly allow the domain in next.config.js:

// next.config.js
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'your-cdn.example.com',
        port: '',
        pathname: '/images/**',
      },
      {
        protocol: 'https',
        hostname: 'storage.example.com',
      },
    ],
  },
};
Enter fullscreen mode Exit fullscreen mode

The pathname pattern limits which paths are allowed — good security practice to not permit /** on every domain.


Bypass Next.js Image Optimization for CDN-Served Images

If your images are already optimized and served from a CDN, Next.js's built-in optimization layer adds unnecessary processing overhead. You can bypass it with a custom loader:

// next.config.js
module.exports = {
  images: {
    loader: 'custom',
    loaderFile: './lib/imageLoader.js',
  },
};
Enter fullscreen mode Exit fullscreen mode
// lib/imageLoader.js
export default function cdnImageLoader({ src, width, quality }) {
  const url = new URL(src);
  url.searchParams.set('w', width.toString());
  url.searchParams.set('q', (quality || 75).toString());
  return url.toString();
}
Enter fullscreen mode Exit fullscreen mode

This tells Next.js to use your custom loader function instead of its built-in optimization. The loader receives the source URL, requested width, and quality, and returns the URL to actually fetch.

Your CDN handles the transformation rather than Next.js — which is appropriate when your CDN's image transformation is faster or already caching transformed variants.


Static Image Optimization — The public Directory

Images in /public are served directly without Next.js processing. They have no built-in optimization, but they're also served with simpler, more predictable caching behavior.

For static assets that don't change, public is often the right choice. Cache these aggressively at the CDN/server level:

// next.config.js
async headers() {
  return [
    {
      source: '/images/:path*',
      headers: [
        {
          key: 'Cache-Control',
          value: 'public, max-age=31536000, immutable',
        },
      ],
    },
    {
      source: '/fonts/:path*',
      headers: [
        {
          key: 'Cache-Control',
          value: 'public, max-age=31536000, immutable',
        },
      ],
    },
  ];
},
Enter fullscreen mode Exit fullscreen mode

The immutable directive tells browsers not to even send a validation request for the lifetime of the cache — the image will never change, so there's no point checking.


Vercel-Specific Considerations

On Vercel, Next.js image optimization runs on Vercel's infrastructure. The optimized images are cached at Vercel's edge CDN.

Key things to know:

Image optimization counts toward your Vercel usage. Each unique image+width combination that gets optimized counts toward your image optimization quota. Using sizes correctly reduces this by ensuring you're not generating more size variants than necessary.

Cache is per-region by default. A user in Singapore and a user in New York might both trigger optimization of the same image in different regions. The minimumCacheTTL helps here — longer TTL means the cached version in each region persists longer.

Vercel's Image Optimization API can be called directly if you need programmatic control:

// Construct a Vercel-optimized image URL directly
function getVercelOptimizedUrl(src, width) {
  return `/_next/image?url=${encodeURIComponent(src)}&w=${width}&q=75`;
}
Enter fullscreen mode Exit fullscreen mode

Preloading Critical Images

For images that will definitely be in the viewport on load (LCP candidates), preload them in the <head>:

// app/layout.js
export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <link
          rel="preload"
          as="image"
          href="/hero-image.webp"
          type="image/webp"
        />
      </head>
      <body>{children}</body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Or use <Image> with priority:

<Image
  src="/hero-image.webp"
  alt="Hero"
  width={1200}
  height={630}
  priority // Adds preload link automatically
/>
Enter fullscreen mode Exit fullscreen mode

The priority prop adds a preload link automatically — use it on the LCP image and nothing else.


Measuring Cache Performance

A few things worth monitoring:

Cache hit rate — what percentage of image requests are served from cache versus hitting origin. High miss rate suggests either TTL is too short or you have too many unique image variants.

Image size distribution — are you serving appropriately sized images for mobile vs desktop? The sizes attribute on <Image> should reflect actual rendered sizes.

LCP timing by device — mobile LCP is almost always worse than desktop for image-heavy pages. If mobile LCP is poor, the image is either not preloaded, too large, or the CDN isn't close enough to mobile users.

Check these in Chrome DevTools (Network tab, filter by Img, check response headers for Cache-Control and X-Cache headers) and in Search Console's Core Web Vitals report for field data.


The Configuration That Actually Works

After a lot of iteration, here's the Next.js image configuration that performs well in production:

// next.config.js
module.exports = {
  images: {
    minimumCacheTTL: 2592000, // 30 days
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    remotePatterns: [
      // Add your CDN domains here
    ],
  },
  async headers() {
    return [
      {
        source: '/_next/static/:path*',
        headers: [
          { key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
        ],
      },
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

30-day cache TTL for dynamically optimized images, year-long for static assets, AVIF and WebP format negotiation, explicit device size breakpoints that match your actual layout breakpoints.


Stale-While-Revalidate for Dynamic Images

For images that update occasionally but should load fast, the stale-while-revalidate cache directive is useful:

async headers() {
  return [
    {
      source: '/api/generated/:path*',
      headers: [
        {
          key: 'Cache-Control',
          // Serve stale for up to 24h while revalidating in background
          value: 'public, max-age=3600, stale-while-revalidate=86400',
        },
      ],
    },
  ];
},
Enter fullscreen mode Exit fullscreen mode

This tells clients: "This image is fresh for 1 hour. After that, serve the stale version immediately while fetching a fresh one in the background." The user gets a fast response, the cache stays reasonably fresh.


Content-Addressed URLs — The Clean Solution

The cleanest approach to image caching is content-addressed URLs: the filename includes a hash of the content, so the URL changes whenever the content changes.

// lib/imageUrl.ts
import crypto from 'crypto';

export function getContentAddressedUrl(content: Buffer): string {
  const hash = crypto
    .createHash('md5')
    .update(content)
    .digest('hex')
    .slice(0, 8);
  return `/images/${hash}.webp`;
}
Enter fullscreen mode Exit fullscreen mode

With content-addressed URLs, you can set very long cache TTLs (1 year or longer) because the URL literally changes when the content changes. Old URLs automatically become stale when content updates because nothing links to them anymore.

This is the pattern static site generators and bundlers use for JavaScript and CSS. Applying it to images gives you the same reliable caching behavior without worrying about stale content.

Top comments (0)