Skip to main content
paulund

5 min read

#nextjs#images#performance

Image Optimisation in Next.js

Images are usually the biggest contributor to page weight. Get them wrong and your Core Web Vitals suffer. Get them right and pages feel noticeably faster. Next.js ships a next/image component that handles most of the hard work for you, but there's a catch when you're running a static export: the default optimisation pipeline requires a server, and a static site doesn't have one.

What next/image Does

The next/image component wraps a standard <img> tag and adds several things automatically:

Resize and format conversion. When a request comes in, Next.js resizes the image to the width you asked for and converts it to WebP (or AVIF, if the browser supports it). Serving WebP over JPEG typically cuts file size by 25-35% with no visible quality loss.

Lazy loading. Images below the fold are loaded with loading="lazy" by default. The browser skips them until the user scrolls close enough to need them, which cuts the amount of data fetched on initial load.

CLS prevention. The component requires width and height props (or the fill layout). Those dimensions are used to set an aspect-ratio box in the DOM before the image loads, so the page doesn't shift when the image arrives. This directly affects your Cumulative Layout Shift score.

Priority loading. Add priority to any image that's above the fold (hero images, profile photos). This removes the lazy loading and adds a <link rel="preload"> in the <head>, so the browser fetches it as early as possible.

A typical usage looks like this:

import Image from 'next/image'

export default function Hero() {
    return (
        <Image
            src="/images/hero.jpg"
            alt="A descriptive label for the image"
            width={1200}
            height={630}
            priority
        />
    )
}

Layout Modes: fill vs Fixed vs Responsive

The width and height props give you a fixed-size image. That works well for profile photos or thumbnails where you know the exact dimensions upfront.

For images that need to stretch to fill a container, use the fill prop instead. You drop the width and height, wrap the image in a positioned container, and the image fills it:

<div style={{ position: 'relative', width: '100%', height: '400px' }}>
    <Image
        src="/images/cover.jpg"
        alt="Article cover"
        fill
        style={{ objectFit: 'cover' }}
    />
</div>

The sizes prop matters here. It tells the browser which image width to request at each breakpoint, and it feeds directly into the srcset attribute:

<Image
    src="/images/cover.jpg"
    alt="Article cover"
    fill
    sizes="(max-width: 768px) 100vw, 50vw"
    style={{ objectFit: 'cover' }}
/>

Without sizes, the browser defaults to requesting the full viewport width every time, which often means downloading a much larger image than needed.

The Static Export Problem

Here's where things get concrete. This site uses output: 'export' in next.config.ts, which generates plain HTML and static files with no Node server involved at runtime. The next/image optimiser is a server-side process. It intercepts requests to /_next/image, resizes and converts the image on the fly, and serves the result. With no server, that endpoint doesn't exist.

To get around the missing server, you set unoptimized: true:

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
    output: 'export',
    images: {
        unoptimized: true,
    },
}

export default nextConfig

This lets the build succeed, but it means next/image passes your source files through as-is. You still get lazy loading and the CLS-prevention layout, but you lose the resize and WebP conversion. A 2MB JPEG stays a 2MB JPEG.

Practical Alternatives for Static Sites

There are three reasonable ways to compensate.

Pre-optimise images at build time. Run your images through a script before they land in public/. Tools like sharp or squoosh can convert to WebP and generate multiple sizes ahead of time. I keep a local script for this:

npm run image  # runs scripts/optimise-image.sh

The result lands in public/images/ already in WebP format, already at the right dimensions. next/image passes it through unchanged, but the file is already optimised. This works well for content images that change infrequently.

Cloudflare Image Resizing. If you're hosting on Cloudflare Pages, you can enable Image Resizing in your Cloudflare dashboard. Requests to your images go through Cloudflare's edge, which resizes and converts them on the fly, similar to how the Next.js server-side optimiser works. You reference images as normal and Cloudflare handles the transformation based on request headers or URL parameters. This gives you on-demand resizing without running your own server.

An external image CDN. Services like Cloudinary, Imgix, or Bunny.net act as image transformation proxies. You upload the original file and they serve responsive, format-converted versions via URL parameters. If your site has a lot of images or you need fine-grained control over transformations, this is the most capable option. You'd point next/image at the CDN domain using a custom loader or simply use the CDN URLs directly in <img> tags.

What to Actually Do

For a small content site on Cloudflare Pages, the practical answer is: pre-optimise your images before committing them and enable Cloudflare Image Resizing if you want automatic format conversion at the edge. The unoptimized: true flag is a necessary configuration for static export, not a reason to skip image optimisation altogether. The optimisation just moves from runtime to build time, or to the CDN layer, rather than happening inside Next.js.

The lazy loading and CLS prevention from next/image still work with unoptimized: true, so you're not giving those up. You're only giving up the automatic resize and format conversion, which you replace with another tool in the pipeline.

Related notes

  • API Caching

    How to use Cache-Control headers and ETags in your REST API to reduce bandwidth, lower server load, ...

  • Avoiding N+1 Queries in Laravel

    Learn what the N+1 query problem is, why it silently degrades Laravel application performance, and h...

  • Caching in Laravel

    An overview of Laravel's cache system covering cache drivers, storing and retrieving data, cache tag...


Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.