The WordPress media library in a headless architecture

WordPress manages media files (images, videos, documents) through its media library. When an image is uploaded, WordPress automatically generates several sizes (thumbnail, medium, large, full) and stores the associated metadata (dimensions, weight, alt text). In headless mode, this data is accessible via the REST API (/wp-json/wp/v2/media) or via WPGraphQL.

The Next.js frontend retrieves image URLs from the API and displays them with the next/image component, which adds an automatic optimization layer: on-the-fly resizing, conversion to modern formats (WebP, AVIF), lazy loading and blurred placeholder generation.

The REST API media endpoint

Retrieving media

# List the 20 most recent media items
curl https://your-site.com/wp-json/wp/v2/media?per_page=20

# A specific media item by ID
curl https://your-site.com/wp-json/wp/v2/media/156

A media item's JSON response contains the following information:

{
  "id": 156,
  "date": "2024-11-15T10:30:00",
  "slug": "portfolio-project-screenshot",
  "type": "attachment",
  "title": { "rendered": "Portfolio project screenshot" },
  "alt_text": "Portfolio site home page",
  "mime_type": "image/jpeg",
  "source_url": "https://your-site.com/wp-content/uploads/2024/11/project-screenshot.jpg",
  "media_details": {
    "width": 2400,
    "height": 1600,
    "file": "2024/11/project-screenshot.jpg",
    "sizes": {
      "thumbnail": {
        "file": "project-screenshot-150x150.jpg",
        "width": 150,
        "height": 150,
        "source_url": "https://your-site.com/wp-content/uploads/2024/11/project-screenshot-150x150.jpg"
      },
      "medium": {
        "file": "project-screenshot-300x200.jpg",
        "width": 300,
        "height": 200,
        "source_url": "https://your-site.com/wp-content/uploads/2024/11/project-screenshot-300x200.jpg"
      },
      "large": {
        "file": "project-screenshot-1024x683.jpg",
        "width": 1024,
        "height": 683,
        "source_url": "https://your-site.com/wp-content/uploads/2024/11/project-screenshot-1024x683.jpg"
      },
      "full": {
        "file": "project-screenshot.jpg",
        "width": 2400,
        "height": 1600,
        "source_url": "https://your-site.com/wp-content/uploads/2024/11/project-screenshot.jpg"
      }
    }
  }
}

Retrieving media via WPGraphQL

query ProjectImage($id: ID!) {
  mediaItem(id: $id, idType: DATABASE_ID) {
    sourceUrl
    altText
    title
    mediaDetails {
      width
      height
      sizes {
        name
        sourceUrl
        width
        height
      }
    }
  }
}

WordPress image sizes

WordPress generates predefined sizes on upload. Additional sizes can be registered in functions.php.

Adding custom sizes

// functions.php
add_action('after_setup_theme', function () {
  // Size for project cards (centered crop)
  add_image_size('card', 600, 400, true);

  // Size for the hero (fixed width, proportional height)
  add_image_size('hero', 1920, 0, false);

  // Size for gallery thumbnails
  add_image_size('gallery-thumb', 400, 400, true);
});

For these sizes to appear in the REST API and WPGraphQL, no additional configuration is required: they are automatically included in the media_details.sizes object of the response.

Integrating WordPress images with next/image

The Next.js next/image component automatically optimizes images: resizing, format conversion, lazy loading and caching. To use it with images from WordPress, the remote domain must be configured.

Configuring next.config.js

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'your-site.com',
        pathname: '/wp-content/uploads/**',
      },
    ],
    // Optimized output formats
    formats: ['image/avif', 'image/webp'],
    // Breakpoint sizes for the srcset
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
};

module.exports = nextConfig;

Mandatory configuration for remote images

Without the remotePatterns property in next.config.js, the next/image component refuses to load images from external domains. This configuration is a security measure that prevents optimization of images from unauthorized sources.

Reusable WordPress image component

import Image from 'next/image';

interface WPImage {
  sourceUrl: string;
  altText: string;
  mediaDetails: {
    width: number;
    height: number;
  };
}

interface WordPressImageProps {
  image: WPImage;
  priority?: boolean;
  className?: string;
  sizes?: string;
}

export function WordPressImage({
  image,
  priority = false,
  className,
  sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
}: WordPressImageProps) {
  return (
    <Image
      src={image.sourceUrl}
      alt={image.altText || ''}
      width={image.mediaDetails.width}
      height={image.mediaDetails.height}
      priority={priority}
      className={className}
      sizes={sizes}
      placeholder="blur"
      blurDataURL={`data:image/svg+xml;base64,...`}
    />
  );
}

Blurred placeholder for progressive loading

The next/image component supports a blurred placeholder that displays while the image is loading. For remote images (WordPress), the placeholder must be generated manually.

Method 1: lightweight SVG placeholder

function generateBlurPlaceholder(width: number, height: number): string {
  const svg = `
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">
      <filter id="b" color-interpolation-filters="sRGB">
        <feGaussianBlur stdDeviation="20" />
      </filter>
      <rect width="100%" height="100%" fill="#e2e8f0" filter="url(#b)" />
    </svg>
  `;
  return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
}

Method 2: generation with plaiceholder

import { getPlaiceholder } from 'plaiceholder';

async function getImageWithBlur(imageUrl: string) {
  const response = await fetch(imageUrl);
  const buffer = Buffer.from(await response.arrayBuffer());
  const { base64 } = await getPlaiceholder(buffer, { size: 10 });

  return { src: imageUrl, blurDataURL: base64 };
}

Lazy loading and load priority

Above the fold images: priority={true}

Images visible without scrolling (hero, logo, first image of a listing) should receive the priority property. This triggers a preload in the HTML head and disables lazy loading for these images.

Below the fold images: automatic lazy loading

By default, next/image applies native lazy loading (loading="lazy"). Images are only loaded when they approach the viewport. No additional configuration is required.

sizes attribute for srcset

The sizes attribute tells the browser what width the image will occupy depending on the viewport. This lets it pick the optimal size from the srcset without waiting for CSS rendering. Example: sizes="(max-width: 768px) 100vw, 50vw".

Modern formats: WebP and AVIF

With formats: ['image/avif', 'image/webp'] in next.config.js, Next.js automatically converts JPEG/PNG images to AVIF (or WebP as a fallback) if the browser supports it. Weight savings range from 30 to 50% compared with JPEG.

Performance: measurable gains

-50%

image weight

Automatic JPEG to AVIF conversion by next/image

×2

LCP improved

Largest Contentful Paint reduced thanks to preload and correct sizing

0 kb

client JavaScript

next/image generates native HTML (img + srcset), with no client-side JS runtime

CDN strategies for WordPress media

In a headless architecture, WordPress images can be served in three ways:

| Strategy | How it works | Benefit | |----------|--------------|---------| | Direct | The frontend points to your-site.com/wp-content/uploads/ | Simple, no configuration | | Next.js proxy | Images go through the Next.js server (/_next/image) | Automatic optimization, integrated cache | | Dedicated CDN | Media is replicated on a CDN (Cloudflare, CloudFront) | Minimal latency, offloads the WordPress server |

Recommendation: Next.js proxy + CDN

The optimal configuration combines the Next.js image proxy (which handles optimization and resizing) with an upstream CDN (which caches the optimized versions closest to users). The Vercel CDN handles this combination natively if the frontend is deployed on Vercel.

In the REST API, an article's featured image is referenced by its ID (featured_media). To avoid an additional request per article, use the _embed parameter, which includes the full image data in the response.

// Without _embed: featured_media contains an ID (requires a 2nd request)
const posts = await fetch('/wp-json/wp/v2/posts').then(r => r.json());
// posts[0].featured_media = 156

// With _embed: image data is included
const posts = await fetch('/wp-json/wp/v2/posts?_embed').then(r => r.json());
// posts[0]._embedded['wp:featuredmedia'][0].source_url = "https://..."
// posts[0]._embedded['wp:featuredmedia'][0].media_details.sizes = {...}

In GraphQL, image data is retrieved directly in the query:

query {
  posts {
    nodes {
      title
      featuredImage {
        node {
          sourceUrl
          altText
          mediaDetails {
            width
            height
          }
        }
      }
    }
  }
}