Image Optimization in Headless WordPress With Next.js

Headless WordPress solves a lot of problems. Images aren’t one of them.

You keep the editor your team already knows, you get a Next.js frontend that feels instant, and everyone’s happy, right up until someone runs Lighthouse on a post with a hero image and a gallery.

Here’s what nobody mentions when you decouple: most of WordPress’s responsive image delivery happens at render time. And in a headless setup, WordPress doesn’t render anything anymore. It hands over JSON and walks away.

What actually breaks

Your media library is fine. Uploads work, thumbnails get generated, the REST API and WPGraphQL still return image URLs.

What breaks is the delivery layer, every mechanism that depends on WordPress producing HTML.

MechanismWorks headless?Why
ShortPixel Image Optimizer compressing your libraryRuns on upload, server-side. Nothing to do with rendering.
WebP/AVIF served via .htaccess✅, if configured at the originNegotiated on the image request itself, not on the HTML.
<picture> tag replacementIt rewrites WordPress’s HTML output. There isn’t any.
Automatic CDN URL rewriting in the pluginSame reason. The rewrite hooks into page output.
Adaptive Images on-the-fly DOM rewritingIts JavaScript runs on the WordPress-rendered page.
The Adaptive Images APIPlain URLs. Stack-agnostic by design.

That last row is the one to hold on to.

The practical version: install an optimization plugin on a headless WordPress, check the frontend, see no difference, conclude the plugin is broken. It isn’t. It’s optimizing files that a Next.js app requests in a way the plugin never gets to influence.

The next/image trap

Next.js has a great <Image> component, and the natural assumption is that it handles everything. It doesn’t.

It isn’t free. On Vercel, image optimization is metered. Self-hosted, you’re running sharp on your own CPU with a cache directory you now have to monitor and clean up.

It can’t undo a bad original. next/image re-encodes whatever WordPress hands it. If an editor uploaded a 6 MB phone photo, you get a smaller version of a 6 MB phone photo, and your storage, backups, staging syncs, and every cold origin fetch still carry the full-size file.

The default optimizer doesn’t work with static export. Under output: ‘export’, Next.js can’t use its built-in Image Optimization API, because optimization happens on demand as users request images rather than at build time. The build errors out. Your two documented ways out are unoptimized: true, which gives up on optimization entirely, or a custom loader backed by an external service. Hold that thought.

It only covers what you render through it. Anything inside dangerouslySetInnerHTML bypasses it completely. More on that below.

Lazy loading, srcset generation, aspect-ratio reservation: all worth keeping. It just isn’t a strategy on its own.

Two layers, two jobs

Layer 1 — the library on disk. Every file in wp-content/uploads should already be as small as it reasonably can be. When we compressed 11,000 images through the ShortPixel API, the library went from 536.6 MB to 287.2 MB, a 46.5% reduction on mixed photographic content. That’s the baseline you leave on the table by skipping this layer.

Layer 2 — delivery. Per-request resizing and format negotiation. A visitor on a 390px phone shouldn’t download a 1920px image, and if their browser takes AVIF they should get AVIF.

On a normal WordPress site the two layers blur together, because whatever compresses your files is also wired into WordPress’s output. Headless breaks that coupling, so you configure them separately.

Step 1: Optimize the media library

Install ShortPixel Image Optimizer and run a bulk optimization. Three settings matter here:

  • Compression level. Lossy for most sites, Glossy for portfolios and product shots where clients pin you on detail.
  • Resize large images. Cap the maximum dimension at 2048px. Where editors upload straight from a DSLR, this does more than compression.
  • Skip the <picture> tag delivery method. It adds a script to a page that doesn’t exist.

Keep backups on for the first run. Always.

Step 2: Audit the sizes WordPress generates

WordPress generates multiple resized copies of every upload. A site with 1,000 images often has 5,000 to 15,000 files on disk once you count them all.

In a headless setup where every frontend image is resized on demand, many of those may be redundant. Next.js asks for the widths it needs, the CDN produces them, and the medium_large copy on your disk may never be requested by anyone.

May. Not definitely.

// mu-plugin or functions.php on the WordPress side
add_filter('intermediate_image_sizes_advanced', function ($sizes) {
    unset($sizes['medium_large'], $sizes['1536x1536'], $sizes['2048x2048']);
    return $sizes;
});

Audit before you disable. WordPress uses intermediate sizes to build responsive markup, and the editor, feeds, and any other client consuming the same CMS may depend on them. Keep thumbnail regardless — the admin media grid uses it.

One dependency to flag now: WordPress builds the srcset for images inside post content out of these exact sizes. Strip them and that markup gets thinner. Which route you take depends on how you handle content images.

Step 3: Pull the right data out of WordPress

Three things per image: the URL, the intrinsic dimensions, and the alt text. Dimensions are non-negotiable, without them next/image can’t reserve space and you ship layout shift.

query PostBySlug($slug: ID!) {
  post(id: $slug, idType: SLUG) {
    title
    featuredImage {
      node {
        sourceUrl
        altText
        mediaDetails {
          width
          height
        }
      }
    }
  }
}

On REST, the same data lives at /wp-json/wp/v2/media/<id> under source_url, alt_text, and media_details.

Two details worth getting right. Request the full-size sourceUrl, not a named thumbnail size, you want WordPress’s largest web-facing version as the base for every transformation, because resizing an already-recompressed 300px thumbnail looks exactly like what it is. And carry altText through as WordPress gives it to you: informative images need a real text alternative, decorative ones should keep alt=””, and for the ones that do carry meaning, vague alt text is increasingly unhelpful now that AI crawlers can interpret the image itself.

Step 4: Point next/image at ShortPixel

The Adaptive Images API is a URL-based transformation API, documented and usable from any stack, and its shape maps almost perfectly onto what a Next.js loader needs to produce.

One setup step first, and it’s the one that trips people up: the domain hosting your images has to be associated with your ShortPixel account. Log in, open Associate Domains, add it. In a headless build that’s your WordPress install, cms.example.com, or your S3 bucket, not your Next.js frontend.

The URL looks like this:

https://cdn.shortpixel.ai/client/to_auto,w_800,q_glossy,ret_img/https://cms.example.com/wp-content/uploads/2026/03/photo.jpg

Four parts: the CDN host, an arbitrary alphanumeric cache key, the transformation parameters, and the absolute URL of your original. The parameters worth knowing are w_ and h_ for dimensions, q_lossy / q_glossy / q_lossless for compression, ret_img to redirect to the original while a variant is still generating, and to_auto, which serves AVIF where supported, WebP where not, and the original format otherwise, the whole format-negotiation problem in nine characters.

Now the loader:

// lib/shortpixel-loader.js
'use client'

const CDN = 'https://cdn.shortpixel.ai'
const CACHE_KEY = 'headlesswp' // any alphanumeric string; keep it stable

export default function shortPixelLoader({ src, width }) {
  const absolute = src.startsWith('http')
    ? src
    : `${process.env.NEXT_PUBLIC_WP_URL}${src}`

  // The CDN can't reach your machine, so don't transform in dev
  if (process.env.NODE_ENV === 'development') return absolute

  const params = ['to_auto', `w_${width}`, 'q_glossy', 'ret_img'].join(',')

  return `${CDN}/${CACHE_KEY}/${params}/${absolute}`
}

The cache key has to be alphanumeric, no hyphens. And note what the loader doesn’t do: it ignores quality. ShortPixel takes named quality levels rather than a 1–100 number, so there’s nothing sensible to map onto, and Next.js 16 makes the mismatch worse, images.qualities now defaults to [75] and coerces anything outside that allowlist to the nearest entry. Set the compression level once in the loader and leave the quality prop off your components.

Wire it up:

// next.config.js
module.exports = {
  images: {
    loader: 'custom',
    loaderFile: './lib/shortpixel-loader.js',
    // Trim the default breakpoints, every extra width is another variant
    deviceSizes: [640, 828, 1080, 1280, 1920],
    imageSizes: [256, 384],
  },
}

With a custom loader, requests never touch /_next/image. Next generates the srcset, calls your loader once per width, and the browser fetches straight from the CDN. No sharp, no /_next/image disk cache, no Vercel image transformation usage, and it works under output: ‘export’. Not that images become free, ShortPixel meters its own usage, but you’re swapping a metered resource you have to operate for one you don’t.

Then the component:

import Image from 'next/image'

export function FeaturedImage({ image }) {
  return (
    <Image
      src={image.sourceUrl}
      alt={image.altText || ''}
      width={image.mediaDetails.width}
      height={image.mediaDetails.height}
      sizes="(max-width: 768px) 100vw, 720px"
      fetchPriority="high"
    />
  )
}

Two things there. priority is gone, Next.js 16 deprecated it in favor of preload, but the docs are clear that in most cases you want loading=”eager” or fetchPriority=”high” instead, with preload reserved for an unambiguous LCP element you need discovered before the parser reaches the <body>. And do not skip sizes: without it, Next assumes the image spans the full viewport and requests widths sized for a 4K monitor. It’s the most common reason a “properly optimized” headless site still ships oversized images.

Building headless and don’t want a plugin in the loop? The ShortPixel Adaptive Images API works with any stack, Next.js, Nuxt, Astro, a plain static site. There’s also a Node.js client and an Express middleware if you’d rather optimize inside your own pipeline. Grab an API key and try it.

The images inside your post content

Featured images are the easy case. The hard one is post.content,  a blob of HTML dropped into the page with dangerouslySetInnerHTML.

Those images bypass next/image completely, and on a long article they’re usually the bulk of the page weight. What they don’t necessarily lack is responsive markup: rendered content still runs through the_content, where wp_filter_content_tags() adds srcset, sizes, loading, decoding, and dimensions to any image it can match back to the media library. So that HTML may already be in reasonable shape. What it isn’t is yours, Next controls none of it, and none of those URLs pass through your loader.

Two ways to fix that, pulling in opposite directions.

Parse and replace. Swap <img> nodes for real Image components with html-react-parser:

import parse from 'html-react-parser'
import Image from 'next/image'

const options = {
  replace: (node) => {
    if (node.name !== 'img') return
    const { src, alt, width, height } = node.attribs
    return (
      <Image
        src={src}
        alt={alt || ''}
        width={Number(width) || 1200}
        height={Number(height) || 800}
        sizes="(max-width: 768px) 100vw, 720px"
      />
    )
  },
}

export function PostBody({ html }) {
  return <div className="prose">{parse(html, options)}</div>
}

One pipeline for everything, but you’re rebuilding WordPress’s markup from attributes that aren’t always there. widthand height go missing on older content, and a fallback guess is a layout shift.

Or keep the markup and rewrite the URLs inside it. Transform the HTML at fetch time and point every uploads URL at ShortPixel, including each one inside srcset:

// lib/rewrite-content-images.js
const CDN = 'https://cdn.shortpixel.ai/headlesswp'
const PARAMS = 'to_auto,q_glossy,ret_img'

const UPLOADS = /https?:\/\/cms\.example\.com\/wp-content\/uploads\/[^\s"']+/g

export function rewriteContentImages(html) {
  return html.replace(UPLOADS, (url) => `${CDN}/${PARAMS}/${url}`)
}

One pass covers src and srcset together, since both hold plain uploads URLs, and [^\s”‘]+ stops at whitespace so a srcset entry like photo-1024×683.jpg 1024w keeps its width descriptor. Note there’s no w_ here, and that’s the point: WordPress already generated a file per size and already told the browser which is which, so the descriptors do the sizing while ShortPixel compresses and format-negotiates whichever one gets picked.

The trade-off ties straight back to Step 2,  this route depends on those intermediate sizes existing. So it’s a genuine fork. Parse-and-replace if you want one pipeline and you’re willing to fix your content’s missing attributes; URL rewriting if you’d rather trust WordPress’s responsive markup and keep the thumbnails that feed it. What doesn’t work is stripping the sizes and leaving content images alone.

Don’t forget your public/ folder

Your logo, OG images, icons, and the hero illustration your designer exported at 2400px don’t live in WordPress, so nothing in this pipeline touches them. It’s easy to miss, because the WordPress side looks perfectly configured while your heaviest asset is an unoptimized PNG in /public. Add a compression step to CI, the ShortPixel command-line tool and the shortpixel-optimize.sh script both handle a folder in one pass.

Verifying it works

Open DevTools, go to the Network tab, filter by Img, reload, and check three things:

  1. The request URL goes to the CDN, not to your WordPress origin or /_next/image.
  2. The content-type is image/avif or image/webp. One caveat: ret_img means the first request can redirect to the original while the variant is still generating, so a JPEG on the first hit isn’t necessarily a problem. Give it time and retry. If Chrome keeps getting the original format after that, check to_auto, your domain association, and the CDN URL before blaming the browser.
  3. The transferred size is meaningfully smaller than the file in your media library, and the w_ value roughly matches the rendered width. If you asked for w_1920 on a phone-width layout, your sizes attribute is wrong.

Then run Lighthouse. LCP is where this work shows up, and the fix is usually a few hundred kilobytes rather than a few hundred milliseconds of JavaScript.

FAQs

Do I still need a WordPress optimization plugin if Next.js is optimizing images?

Yes, for a reason that isn’t about page speed. Next.js optimizes what gets delivered; the plugin optimizes what gets stored. Without it, your disk, backups, staging syncs, and migrations carry full-size originals forever.

Does this work with static export?

It’s arguably where it works best. Under output: ‘export’ the built-in optimizer isn’t available and the build errors out, and a custom loader is one of the two fixes Next.js documents, the other being unoptimized: true, which just gives up. The loader only builds URL strings, so it’s perfectly happy at build time.

Will the CDN work if my WordPress install isn’t public?

No. The transformation service fetches your original over HTTP, so if WordPress sits behind basic auth, an IP allowlist, or a VPN, it can’t reach the file. Expose the uploads directory publicly, or move your media to public object storage.

Try ShortPixel on WordPress for free!

Easily optimize your pictures and generate WebP/AVIF in bulk using ShortPixel Image Optimizer.

Bianca Rus
Bianca Rus
Articles: 43