Next.js static export images: the default loader...

Next.js static export images: the default loader breaks them and unoptimized ships the original

By SJ
7 min read
Bar chart of bytes Chrome downloaded at phone size for five 4K JPEGs on a Next.js static export page: 55.2 MB as they are, 10.2 MB as Smart JPG at full size, 1.29 MB at 1200 px wide, 767 KB through next-image-export-optimizer.
On this page

We built a page with five photographs in a Next.js 16.4.3 App Router app, set output: 'export', left the image settings alone, and next build finished with exit code 0. In Chrome none of the five images loaded: every src pointed at /_next/image, an endpoint a static export doesn't contain. Setting images: { unoptimized: true } fixes that, and the export then serves the file you put in public/. Chrome at phone size downloaded 2.4 MB for five 1800 px photographs, and 55.2 MB when the same page held five 4K Gemini JPEGs.

This post compares that flag, next-image-export-optimizer and resizing the files first by what Chrome downloaded at phone size and at desktop size. The table is in the second section, and the choice we'd make follows the three options.

The App Router build passes and the images 404

Next's error page says the default loader "relies on the Image Optimization API which is not available for exported applications", and its static export guide lists "Image Optimization with the default loader" as unsupported. So we ran both commands in 16.4.3. next dev answered HTTP 500 with the error text, and a Pages Router build with one image exited 1 with a similar message. Our App Router next build exited 0 after 3 seconds and printed no warning. out/index.html mentioned /_next/image 53 times and out/ had no folder for it, so our static server answered 404, and in Chrome all five images had a naturalWidth of 0.

To catch it before you deploy, search the HTML in out/:

grep -rl --include='*.html' '/_next/image' out

It printed out/index.html for the default loader and nothing for the unoptimized, optimizer and full-size Smart exports. Without --include, all four also matched a JavaScript chunk. It exits 0 when it finds a match, so a CI step wants a ! in front.

basePath is a second way to get 404s. With basePath: '/site' and unoptimized, the exported tag kept src="/images/landscape.jpg". Served under /site/, that address returned 404 and /site/images/landscape.jpg returned 200. Next's basePath docs say you add the prefix to src yourself.

unoptimized ships the file as it is and drops srcset

The setting the error page lists goes in next.config.mjs:

// next.config.mjs
export default {
  output: 'export',
  images: { unoptimized: true },
}

The image docs call the result "served as-is". Our out/images/ held the sources byte for byte (SHA-256 matched on all ten files), and the exported HTML had no srcset and no sizes attribute, although the page sets sizes. Every screen gets the one file.

The table is what Chrome downloaded for the same page in four setups, once with the five 1800 px photographs of our first post and once with five 4K Gemini JPEGs of 5504 by 3072 pixels. Smart is Iminify's default compression level, a higher score means closer to the original, and the last row is the optimizer described below:

Setup Photographs, phone / desktop 4K JPEGs, phone / desktop Score, photographs / 4K
unoptimized, files as they are 2.40 MB / 2.40 MB 55.2 MB / 55.2 MB 100 / 100
unoptimized, Smart JPG at full size 1.09 MB / 1.09 MB 10.2 MB / 10.2 MB 77.1 / 83.0
unoptimized, Smart JPG 1200 px wide 580 KB / 580 KB 1.29 MB / 1.29 MB 80.8 / 79.1
next-image-export-optimizer, WebP 75 315 KB / 180 KB 767 KB / 221 KB 68.0 / 73.2

Phone means 390 px wide at 3x and desktop 1440 px at 1x, both scrolled to the end. Scores are the median SSIMULACRA 2 of the five delivered files against the master at the delivered size, so 100 is the master itself. For the optimizer, the scores are of the 1200 px files the phone received.

The first row is 72 times what the optimizer sent the phone for the 4K files, and about 250 times what it sent the desktop.

next-image-export-optimizer gives a phone one file and a desktop another

next-image-export-optimizer had 58,022 npm downloads in the week to 29 October 2026, and we ran version 1.21.2. It runs after next build and writes each image at every width in your imageSizes and deviceSizes. This is its README configuration, written as a next.config.mjs:

// next.config.mjs
export default {
  output: 'export',
  images: {
    loader: 'custom',
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
  },
  transpilePackages: ['next-image-export-optimizer'],
  env: {
    nextImageExportOptimizer_imageFolderPath: 'public/images',
    nextImageExportOptimizer_exportFolderPath: 'out',
    nextImageExportOptimizer_quality: '75',
    nextImageExportOptimizer_storePicturesInWEBP: 'true',
    nextImageExportOptimizer_exportFolderName: 'nextImageExportOptimizer',
    nextImageExportOptimizer_generateAndUseBlurImages: 'true',
    nextImageExportOptimizer_remoteImageCacheTTL: '0',
  },
}

Install it with npm install next-image-export-optimizer, make the build script next build && next-image-export-optimizer, and import ExportedImage from the package where the page used Image. For each image it writes a -opt-<width>.WEBP file at 17 widths: the 8 in imageSizes, the 8 in deviceSizes and a 10 px placeholder, 85 files for our five images. The <img> gets a srcset: the phone took the 1200 px file for all five images, and the desktop took 1200 px for the hero and 384 px for each card.

In our test its WebP at quality 75 was the one next/image makes: the 1080 px and full-size files for the five photographs were SHA-256 identical to what our self-hosted Next.js 16.4.3 returned for q=75 on 1 November, 10 of 10 (post 26 covers that pipeline). The WebP scored 68.0 at the median on the 1200 px photographs, below the 70 that the scale calls high quality.

It costs files and disk. out/ kept the originals beside the 85 new files, so the export of the photographs grew from 3.04 MB to 6.05 MB and the 4K export from 55.8 MB to 65.7 MB. From a clean public/images, the optimizer took 3 to 4 seconds on each of three builds for the photographs and 15 to 20 seconds for the 4K files.

Quality is one global number. At quality 85 the phone downloaded 495 KB of photographs at a score of 77.3, and 1.08 MB of 4K files at 80.4. Smart at 1200 px sent 580 KB at 80.8 and 1.29 MB at 79.1, so on the photographs the optimizer was smaller and scored lower, and on the 4K files it was smaller and scored higher.

Resizing first gives every screen the same file

The other road keeps unoptimized: true and sends files that are already the displayed size. Any tool that resizes and compresses does this step. We used Iminify, our compressor, through its API: level smart, format jpg, width 1200. The compress JPEG page has the same Smart level and a width setting under Size, for a batch dropped in the browser.

unoptimized leaves the <img> without a srcset, so one file serves both screens, and a 1200 px hero on a 2x desktop would be drawn at half the display's density. Iminify's Pro plan can also write a responsive set with a srcset snippet, which we didn't test.

What we'd pick

One width for every screen: resize and compress before the build, and keep unoptimized: true. It adds nothing to next build.

Different widths on different screens, as with the hero and cards of our test page: next-image-export-optimizer, with a quality you choose and a deploy folder that also holds the originals.

Either way, don't deploy 4K originals under unoptimized.

How we measured

One page, built in Docker with Next.js 16.4.3 (Turbopack, App Router), React 19.3.1 and sharp 0.35.5: a hero up to 1200 px wide and four cards, sizes set, lazy loading left on. Chrome 156 was driven through DevTools at 390 by 844 at 3x and 1440 by 900 at 1x from a local static server with caching off, scrolled to the bottom. Each currentSrc was mapped to its file's bytes in out/, so the figures are file bytes, with KB and MB at 1,000 and 1,000,000 bytes; post 1 lists its photographs in 1,024-byte KB. The optimizer's pages were loaded twice and picked the same files.

We ran 15 of the 20 Smart uploads twice, and every pair of outputs was byte for byte identical. The 4K files are 16.9 megapixels, and Smart's SSIMULACRA 2 check stops at 8 megapixels, so Smart held them to its PSNR floor alone at full size. Scores are SSIMULACRA 2 (libjxl 0.12.0) after decoding with djpeg and dwebp, against the master resized by sharp for the resized files. The optimizer resizes with sharp too, so its scores leave out the difference between Iminify's resize and sharp's, which the Smart 1200 px scores include. The 4K files come from our September Gemini sample set (Nano Banana 2 and Pro): four are among the 52 files post 15 measured, and the fifth is a Nano Banana 2 file from the same set, made through generateContent. Google's image page now lists Nano Banana 2.1 as the current model, which we did not use.

We did not test a CDN loader, which the Next.js guide uses for its own image example, or AVIF, Firefox, Safari or a 2x desktop.

Questions this post answers

Share this post

Share on X
Share on Facebook
Share on LinkedIn
Share on Reddit
Share on Hacker News
Email
Copy link
Link copied
Iminify blog cover on a blue gradient with a pixel pattern: "Should you compress before your CDN does?"

Should you compress images before your CDN re-encodes them? Measured through Next.js, wsrv.nl and Photon

The Fjord, one of our five test photographs, is an 852,461-byte JPEG, the master every score below is measured against. Iminify's Smart level makes it a 568,598-byte JPEG. We sent both through Next.js 16.4.3's image optimizer at its default, WebP at quality 75. Next's WebP of the master weighed 321,790 bytes and scored 66.98 against the master on SSIMULACRA 2, where 100 means no difference from the master. Its WebP of the Smart file weighed 314,578 bytes and scored 64.30: 2.2 percent fewer bytes for 2.7 points. Should you compress before a CDN or optimizer re-encodes your image? In our tests, on five JPEG photographs through Next and wsrv.nl, only losslessly. A lossless repack, which made our uploads 2.4 to 5.6 percent smaller, changed nothing in 80 of 80 Next responses. And in 40 of 40 comparisons at no more bytes, asking Next for a lower quality setting scored higher than the lossy first pass. If nothing re-encodes after you, compress first.

7 min read
Four teal circles on a transparent background, above the same image converted to JPG with Pillow, where the strips behind them came out magenta, white, black and black.

Why does a transparent PNG turn black as a JPG? The colour hiding underneath

We made an 800x400 test PNG with four strips that look alike: the same teal circle on a transparent background, four times over (the fourth with a soft shadow). Then we turned it into a JPG with Pillow, Python's imaging library, using its convert("RGB"). The four strips came back magenta, white, black and black. Nothing was added. Those colours were already in the PNG, sitting under the transparency. A JPG can't store transparency, so whatever writes it has to put something behind the picture. Some tools paint white. Some just reveal the colour stored under the transparent pixels, which is often black. And Chrome's canvas, the drawing surface web pages use to edit images, paints black whatever was there, because the HTML standard says it must. If you want white, or any other colour, you have to choose it before the JPG is written. Iminify's JPG output puts white behind the picture, and PNG to WebP keeps the transparency instead.

8 min read
Ten squares: 4 filled for tools that kept the IPTC AI tag at their defaults, 6 hollow for tools that dropped it, beside the title Does compression remove the AI tag?

Does compression remove the AI tag Merchant Center asks for? 10 tools, WordPress and Iminify tested

Google's Merchant Center help says: "Don't remove embedded metadata tags such as trainedAlgorithmicMedia from such images. All AI-generated images must contain the IPTC DigitalSourceType trainedAlgorithmicMedia tag." (Merchant Center's help page is dated February 2024; we read it on 26 October 2026.) So we put that tag on two AI images, a harbour scene from Nano Banana 2 (a JPEG) and a bakery illustration from the original Nano Banana (a PNG), ran them through 42 command-line runs and Iminify, added three larger JPEGs for a WordPress 7.1.3 site, and read the tag back with ExifTool each time. Each tool's default decides whether the tag survives, and neither lossy nor lossless predicts it. Of the ten tools in our table, four keep the tag at their defaults and six drop it, and for nine of them we found an option that flips that. Iminify, which is our product, lost it at every level that compresses: it survived in 6 of 51 uploads, and those six came back byte for byte unchanged. One ExifTool command puts the tag back for 514 to 568 bytes and, on the nine files we checked, leaves every pixel alone.

8 min read

Try it on your own images

Drop a photograph in, pick Smart, and compare the result with the original before you download. Free, no account needed.