Next.js Static Architecture Developer Experience (DX)

Image Optimization Is a Solved Problem (If Your Site Is Static)

Static Signal
A mechanical assembly line of brass gears and neon conveyor belts compressing oversized photographs into razor-thin luminous strips against a dark industrial backdrop

Images are the number one reason your Largest Contentful Paint is slow. Not your JavaScript bundle. Not your CSS. Not your server response time. Images.

The HTTP Archive reports that images account for roughly half the total weight of the median web page. Your hero image alone is often the single largest asset the browser has to download before it can paint anything meaningful above the fold. If that image is an unoptimized 2.4MB JPEG served from your origin server, no amount of code splitting or lazy loading is going to save your LCP score.

This used to be a genuinely hard problem. You’d either resize and compress images manually in Photoshop, wire up a fragile Gulp pipeline with imagemin plugins that broke on every OS update, or pay for a cloud service like Cloudinary or Imgix to transform images on the fly. Each approach had tradeoffs — manual work doesn’t scale, build pipelines rot, and cloud services add per-request costs and a runtime dependency.

None of that is necessary anymore. If your site is statically generated, image optimization is a build step. You process every image once, at build time, into the exact formats and sizes you need. The output is a set of static files that get deployed to a CDN and served with immutable cache headers. No runtime transformation. No per-request cost. No service dependency.

The toolchain for this is mature, fast, and free. Let’s walk through it.


Why Images Kill Your LCP

Largest Contentful Paint measures when the largest visible element in the viewport finishes rendering. For content-heavy pages — blogs, landing pages, portfolio sites — that element is almost always an image. Usually the hero image.

The browser’s critical rendering path for an image looks like this: parse the HTML, discover the <img> tag, queue the network request, download the bytes, decode the image, paint it to the screen. Every millisecond in that chain pushes your LCP further out.

Three things make this worse than it needs to be:

Oversized dimensions. A 3000x2000 JPEG looks great on your 27-inch monitor. On a mobile viewport that’s 375 pixels wide, the browser downloads all those pixels and then throws away 85% of them during rendering. You shipped 2MB of data to display 300KB worth of pixels.

Uncompressed formats. JPEG and PNG are legacy formats with legacy compression ratios. WebP delivers 25-35% smaller files than JPEG at equivalent visual quality. AVIF pushes that to 40-50% smaller. If you’re still serving JPEG to browsers that support WebP and AVIF, you’re leaving bandwidth on the table.

No placeholder. While the image downloads, the user sees either nothing (a white gap that causes layout shift) or a flash when the image pops in. A blur placeholder — a tiny, inlined base64 image that approximates the final image — fills that gap and eliminates the perceived loading delay.

All three of these problems are solvable at build time. You don’t need a runtime service to solve any of them.


Sharp: The Engine Under the Hood

Sharp is the image processing library that powers most of the Node.js image optimization ecosystem. It’s a native binding to libvips, which is the fastest open-source image processing library available. When Next.js optimizes your images, Sharp is what’s doing the actual work.

Install it as a dev dependency:

npm install -D sharp

Sharp can resize, crop, convert formats, generate blur hashes, and adjust quality — all in a single pipeline call that processes the image in streaming fashion without loading the entire decoded bitmap into memory.

Here’s a build script that takes a source image and generates WebP and AVIF variants at multiple widths:

// scripts/optimize-images.ts
import sharp from "sharp";
import { readdir, mkdir } from "node:fs/promises";
import path from "node:path";

const SOURCE_DIR = "content/images";
const OUTPUT_DIR = "public/media";
const WIDTHS = [640, 960, 1280, 1920];
const FORMATS = ["webp", "avif"] as const;

async function optimizeImage(inputPath: string) {
  const name = path.parse(inputPath).name;

  for (const width of WIDTHS) {
    for (const format of FORMATS) {
      const outputPath = path.join(OUTPUT_DIR, `${name}-${width}w.${format}`);
      const pipeline = sharp(inputPath).resize(width, null, {
        withoutEnlargement: true,
        fit: "inside",
      });

      if (format === "webp") {
        pipeline.webp({ quality: 80, effort: 6 });
      } else {
        pipeline.avif({ quality: 65, effort: 6 });
      }

      await pipeline.toFile(outputPath);
    }
  }
}

async function run() {
  await mkdir(OUTPUT_DIR, { recursive: true });
  const files = await readdir(SOURCE_DIR);
  const images = files.filter((f) =>
    /\.(jpe?g|png|webp|tiff)$/i.test(f)
  );

  for (const file of images) {
    console.log(`Processing ${file}...`);
    await optimizeImage(path.join(SOURCE_DIR, file));
  }
}

run();

Run this as a prebuild script and every image in your content directory gets processed into optimized variants before the site build even starts. A 2.4MB JPEG becomes a 140KB WebP at 1280 pixels wide. The AVIF variant comes in at around 100KB. Those are the files your users actually download.

The quality settings matter. WebP at quality 80 and AVIF at quality 65 are perceptually equivalent to JPEG at quality 85 for photographic content. You can go lower if you’re optimizing illustrations or graphics with large flat-color areas. Test with your actual images — the right quality setting depends on the content.


Generating Blur Placeholders

A blur placeholder is a tiny version of your image — typically 16-32 pixels wide — encoded as a base64 data URI and inlined directly in your HTML. The browser renders it instantly (no network request) and then crossfades to the full image once it loads. The effect is subtle: instead of a blank space or a layout shift, users see a soft, blurred preview that smoothly sharpens into the real image.

Here’s how to generate one with Sharp:

async function generateBlurDataURL(inputPath: string): Promise<string> {
  const buffer = await sharp(inputPath)
    .resize(20, null, { fit: "inside" })
    .webp({ quality: 20 })
    .toBuffer();

  return `data:image/webp;base64,${buffer.toString("base64")}`;
}

That returns a string like data:image/webp;base64,UklGRlYAAABXRUJQ... that you can inline as a CSS background-image or pass to next/image as the blurDataURL prop. The base64 string is typically 300-500 bytes — small enough to inline without measurably affecting your HTML payload.

For a content-driven site, you’d generate these at build time alongside your image variants and store them in your content’s metadata:

import matter from "gray-matter";
import { readFile, writeFile } from "node:fs/promises";

async function addBlurToFrontmatter(postPath: string) {
  const raw = await readFile(postPath, "utf-8");
  const { data, content } = matter(raw);

  if (data.heroImage && !data.heroBlurDataURL) {
    const imagePath = `content/images/${data.heroImage}`;
    data.heroBlurDataURL = await generateBlurDataURL(imagePath);

    const updated = matter.stringify(content, data);
    await writeFile(postPath, updated);
  }
}

Now every post has a blur placeholder baked into its frontmatter. No runtime generation. No external service. The placeholder travels with the content.


next/image in Static Export Mode

Next.js ships with a powerful <Image> component that handles responsive sizing, lazy loading, format negotiation, and blur placeholders out of the box. In server mode, it optimizes images on-demand through an image optimization API route. In static export mode (output: "export"), that API route doesn’t exist — which means you need to handle optimization yourself.

There are two approaches.

Approach 1: Pre-optimize and use unoptimized. This is the straightforward path. You run your Sharp build script to generate all your image variants, then tell next/image not to try optimizing them:

// next.config.ts
import type { NextConfig } from "next";

const config: NextConfig = {
  output: "export",
  images: {
    unoptimized: true,
  },
};

export default config;

Then in your component, you reference your pre-optimized images directly:

import Image from "next/image";

interface HeroImageProps {
  src: string;
  alt: string;
  blurDataURL?: string;
}

export function HeroImage({ src, alt, blurDataURL }: HeroImageProps) {
  return (
    <Image
      src={`/media/${src}`}
      alt={alt}
      width={1920}
      height={1080}
      priority
      placeholder={blurDataURL ? "blur" : "empty"}
      blurDataURL={blurDataURL}
      sizes="100vw"
    />
  );
}

The priority prop on your hero image is critical. It tells Next.js to add a <link rel="preload"> for this image, which moves it to the front of the browser’s download queue. Without it, the browser discovers the image only after parsing the HTML and evaluating the component tree — adding hundreds of milliseconds to your LCP.

Approach 2: Use a custom image loader. If you want next/image to generate the srcset attribute with your pre-built width variants, you can wire up a custom loader:

// lib/image-loader.ts
import type { ImageLoaderProps } from "next/image";

export function staticImageLoader({ src, width, quality }: ImageLoaderProps): string {
  const name = src.replace(/\.[^.]+$/, "");
  // Map to nearest available width
  const widths = [640, 960, 1280, 1920];
  const targetWidth = widths.find((w) => w >= width) || widths[widths.length - 1];
  return `${name}-${targetWidth}w.webp`;
}
// next.config.ts
const config: NextConfig = {
  output: "export",
  images: {
    loader: "custom",
    loaderFile: "./lib/image-loader.ts",
  },
};

Now next/image generates a proper srcset that points to your pre-built variants. The browser picks the right size based on the viewport and device pixel ratio. A phone gets the 640-wide image. A laptop gets the 1280. A high-DPI desktop gets the 1920. All pre-optimized, all cached, no runtime transformation.


Responsive Images With srcset

If you’re not using next/image — maybe you’re on Astro, Eleventy, or a custom React setup — you still want responsive images. The srcset attribute is how you tell the browser “here are multiple sizes of this image, pick the one you need.”

interface ResponsiveImageProps {
  name: string;
  alt: string;
  sizes: string;
  blurDataURL?: string;
  priority?: boolean;
}

export function ResponsiveImage({
  name,
  alt,
  sizes,
  blurDataURL,
  priority,
}: ResponsiveImageProps) {
  return (
    <picture>
      <source
        type="image/avif"
        srcSet={`
          /media/${name}-640w.avif 640w,
          /media/${name}-960w.avif 960w,
          /media/${name}-1280w.avif 1280w,
          /media/${name}-1920w.avif 1920w
        `}
        sizes={sizes}
      />
      <source
        type="image/webp"
        srcSet={`
          /media/${name}-640w.webp 640w,
          /media/${name}-960w.webp 960w,
          /media/${name}-1280w.webp 1280w,
          /media/${name}-1920w.webp 1920w
        `}
        sizes={sizes}
      />
      <img
        src={`/media/${name}-1280w.webp`}
        alt={alt}
        width={1920}
        height={1080}
        loading={priority ? "eager" : "lazy"}
        decoding="async"
        fetchPriority={priority ? "high" : "auto"}
        style={
          blurDataURL
            ? {
                backgroundImage: `url(${blurDataURL})`,
                backgroundSize: "cover",
                backgroundRepeat: "no-repeat",
              }
            : undefined
        }
      />
    </picture>
  );
}

The <picture> element with <source> tags gives you format negotiation without JavaScript. Browsers that support AVIF get the AVIF. Browsers that support WebP get the WebP. Ancient browsers get the fallback <img> src. This happens at the HTML parsing level — no runtime format detection needed.

The sizes attribute tells the browser how wide the image will be rendered at different viewport widths. Without it, the browser assumes the image is the full viewport width and downloads the largest variant every time. A hero image might use sizes="100vw". A content image in a max-width container might use sizes="(max-width: 768px) 100vw, 720px". Get this right and you’ll see meaningful bandwidth savings on mobile.

The fetchPriority="high" attribute on your above-the-fold image is the native equivalent of Next.js’s priority prop. It tells the browser to prioritize this image in the resource queue. For LCP images, this single attribute can shave 200-400ms off your paint time.


WebP vs AVIF: Which Format and When

Both formats are better than JPEG. The question is whether you need both.

WebP has universal browser support (97%+ globally as of 2026). It compresses well, encodes fast, and has been stable for years. If you only want to deal with one modern format, WebP is the safe choice.

AVIF compresses 15-25% smaller than WebP for photographic content. Browser support is strong (93%+ globally) but not universal — some older Safari versions and niche browsers don’t support it. Encoding is significantly slower than WebP — roughly 5-10x slower at equivalent effort settings.

For a static site, the encoding speed doesn’t matter because you encode once at build time. The build might take an extra 30 seconds. Your users don’t care about your build time. They care about the 15-25% bandwidth savings they get from AVIF on every page load.

The practical answer: generate both. Serve AVIF to browsers that support it, fall back to WebP for everything else. The <picture> element handles this automatically. You’re adding a few extra files to your deploy, but they’re all static assets served from CDN cache. The storage cost is negligible. The bandwidth savings are real.

If you need to choose one format — maybe you’re keeping the build simple or storage is a concern — choose WebP. The universal support makes it the safer default, and the compression is good enough that you’re not leaving much on the table.


CDN Cache Headers for Immutable Assets

Once your images are optimized and deployed, the last piece is making sure browsers and CDN edge nodes cache them correctly. Static image assets are immutable — the file at /media/hero-1280w.webp will never change because if the image changes, the filename changes (via content hashing or a new name). This means you can cache them aggressively.

Set your cache headers for image assets to immutable with a long max-age:

Cache-Control: public, max-age=31536000, immutable

That’s one year. The immutable directive tells the browser not to even send a conditional revalidation request — the asset will never change, so there’s no point checking. This eliminates the 304 round-trip that would otherwise happen on repeat visits.

On most CDN providers, you configure this through headers or deployment rules. On Cloudflare Pages, it’s set via a _headers file:

/media/*
  Cache-Control: public, max-age=31536000, immutable

On Render or Netlify, similar header rules apply. The specifics vary by platform, but the principle is the same: static assets that are content-addressed (the filename changes when the content changes) should be cached forever.

The combination of a CDN edge cache and an immutable browser cache means that a returning visitor downloads zero image bytes on subsequent page loads. The first visit pays the download cost. Every visit after that hits the local browser cache directly — no network request, no latency, no bandwidth.


Putting It All Together

Here’s the complete image optimization pipeline for a static site:

Source images live in your content directory as high-resolution originals. These never get deployed.

Build-time processing runs Sharp to generate WebP and AVIF variants at 640, 960, 1280, and 1920 pixel widths. Same script generates base64 blur placeholders and writes them to your content metadata.

Responsive HTML uses <picture> with <source> tags for format negotiation and srcset for size selection. The hero image gets fetchPriority="high" and loading="eager". Everything below the fold gets loading="lazy".

CDN deployment serves the optimized variants with Cache-Control: public, max-age=31536000, immutable. Edge nodes cache the files globally. Browsers cache them locally.

The result: your hero image loads in under 200ms from a CDN edge, in the optimal format for the user’s browser, at the exact resolution their viewport needs, with a blur placeholder visible in the first paint frame. LCP drops below 2 seconds on real devices. Bandwidth per page drops by 60-70% compared to unoptimized images.

All of this happens at build time. There’s no runtime image optimization service to pay for. No API calls per request. No origin server processing. The entire image delivery pipeline is a set of static files on a CDN, and the optimization work is amortized across every visitor rather than repeated for every request.


The Build Script That Replaces a Service

Cloud image services like Cloudinary and Imgix are good products. They solve a real problem — dynamic image transformation for sites that can’t predict what sizes and formats they’ll need ahead of time. CMS-driven sites with user-uploaded images and unpredictable layouts benefit from on-demand transformation.

But if your site is statically generated, you know every image, every size, and every format at build time. You don’t need on-demand transformation because there’s nothing dynamic about your image requirements. A 200-line build script replaces a monthly service bill.

The economics are straightforward. A cloud image service charges per transformation or per bandwidth. A static build processes images once, for free, using open-source tools. Your CDN bandwidth costs are the same either way — the images still need to be delivered. The difference is that the transformation cost drops to zero.

This is the broader pattern of static sites: moving work from request time to build time. Every optimization you can do at build time is an optimization you never have to do again. Images are the highest-impact example because they’re the largest assets on most pages, and the optimization techniques are well-understood and deterministic.

The toolchain is mature. Sharp is battle-tested. The <picture> element and srcset have been supported in every major browser for years. WebP is universal. AVIF is nearly there. Blur placeholders are a solved pattern. CDN caching for immutable assets is trivial to configure.

Image optimization used to be a chore, a service, or a prayer. On a static site, it’s a build step. Run it once, deploy the output, and move on to problems that aren’t solved yet.


Static Signal is published by Neuron Web Development.