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.
Retrieving featured images with _embed
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
}
}
}
}
}
}
SSG, SSR, and ISR with Next.js
Article suivantManaging content easily in headless mode