With Next.js and React Server Components, we can generate blur placeholders for images at build time and cache them efficiently using the plaiceholder library.
Installation
npm install plaiceholder sharp
npm install plaiceholder sharp
Implementation
Here's a simple example using Cloudinary images:
import { getPlaiceholder } from "plaiceholder";
import Image from "next/image";
async function getImageWithPlaceholder(src: string) {
const buffer = await fetch(src, {
cache: 'force-cache', // Cache the image fetch
}).then(async (res) =>
Buffer.from(await res.arrayBuffer())
);
const { base64 } = await getPlaiceholder(buffer);
return { src, base64 };
}
export default async function MyImage() {
const imageUrl = "https://res.cloudinary.com/dialoghmari/image/upload/v1716596284/hello_world_cover_88d282cfe2.jpg";
const { src, base64 } = await getImageWithPlaceholder(imageUrl);
return (
<Image
src={src}
alt="My image"
width={800}
height={600}
placeholder="blur"
blurDataURL={base64}
/>
);
}
import { getPlaiceholder } from "plaiceholder";
import Image from "next/image";
async function getImageWithPlaceholder(src: string) {
const buffer = await fetch(src, {
cache: 'force-cache', // Cache the image fetch
}).then(async (res) =>
Buffer.from(await res.arrayBuffer())
);
const { base64 } = await getPlaiceholder(buffer);
return { src, base64 };
}
export default async function MyImage() {
const imageUrl = "https://res.cloudinary.com/dialoghmari/image/upload/v1716596284/hello_world_cover_88d282cfe2.jpg";
const { src, base64 } = await getImageWithPlaceholder(imageUrl);
return (
<Image
src={src}
alt="My image"
width={800}
height={600}
placeholder="blur"
blurDataURL={base64}
/>
);
}
Optimization: Fetch a smaller image for placeholder
Since we only need a blurred placeholder, we don't need to fetch the full-size image. With Cloudinary, you can transform the URL to request a much smaller version while preserving aspect ratio by extracting dimensions from the URL itself:
async function getImageWithPlaceholder(src: string) {
// Extract width and height from Cloudinary URL transformations
const widthMatch = src.match(/w_(\d+)/);
const heightMatch = src.match(/h_(\d+)/);
const originalWidth = widthMatch ? parseInt(widthMatch[1]) : null;
const originalHeight = heightMatch ? parseInt(heightMatch[1]) : null;
// Calculate dimensions for placeholder (max 100px wide, preserve aspect ratio)
const placeholderWidth = 100;
let placeholderHeight: number | undefined;
if (originalWidth && originalHeight) {
// Calculate proportional height based on aspect ratio from URL
const aspectRatio = originalHeight / originalWidth;
placeholderHeight = Math.round(placeholderWidth * aspectRatio);
}
// Build transformation string
const transformations = placeholderHeight
? `w_${placeholderWidth},h_${placeholderHeight},c_fill`
: `w_${placeholderWidth}`;
// Transform Cloudinary URL to fetch a tiny version
// Example transformations:
// https://res.cloudinary.com/dialoghmari/image/upload/w_1200,h_600,c_fill/v1716596284/hello_world_cover_88d282cfe2.jpg
// becomes: https://res.cloudinary.com/dialoghmari/image/upload/w_100,h_50,c_fill/v1716596284/hello_world_cover_88d282cfe2.jpg
const placeholderUrl = src.includes('/upload/')
? src.replace(/\/upload\/(?:.*?\/)?/, `/upload/${transformations}/`)
: src.replace('/upload/', `/upload/${transformations}/`);
const buffer = await fetch(placeholderUrl, {
cache: 'force-cache', // Cache the image fetch
}).then(async (res) =>
Buffer.from(await res.arrayBuffer())
);
const { base64 } = await getPlaiceholder(buffer);
return { src, base64 };
}
async function getImageWithPlaceholder(src: string) {
// Extract width and height from Cloudinary URL transformations
const widthMatch = src.match(/w_(\d+)/);
const heightMatch = src.match(/h_(\d+)/);
const originalWidth = widthMatch ? parseInt(widthMatch[1]) : null;
const originalHeight = heightMatch ? parseInt(heightMatch[1]) : null;
// Calculate dimensions for placeholder (max 100px wide, preserve aspect ratio)
const placeholderWidth = 100;
let placeholderHeight: number | undefined;
if (originalWidth && originalHeight) {
// Calculate proportional height based on aspect ratio from URL
const aspectRatio = originalHeight / originalWidth;
placeholderHeight = Math.round(placeholderWidth * aspectRatio);
}
// Build transformation string
const transformations = placeholderHeight
? `w_${placeholderWidth},h_${placeholderHeight},c_fill`
: `w_${placeholderWidth}`;
// Transform Cloudinary URL to fetch a tiny version
// Example transformations:
// https://res.cloudinary.com/dialoghmari/image/upload/w_1200,h_600,c_fill/v1716596284/hello_world_cover_88d282cfe2.jpg
// becomes: https://res.cloudinary.com/dialoghmari/image/upload/w_100,h_50,c_fill/v1716596284/hello_world_cover_88d282cfe2.jpg
const placeholderUrl = src.includes('/upload/')
? src.replace(/\/upload\/(?:.*?\/)?/, `/upload/${transformations}/`)
: src.replace('/upload/', `/upload/${transformations}/`);
const buffer = await fetch(placeholderUrl, {
cache: 'force-cache', // Cache the image fetch
}).then(async (res) =>
Buffer.from(await res.arrayBuffer())
);
const { base64 } = await getPlaiceholder(buffer);
return { src, base64 };
}
What happens:
- Function parses the Cloudinary URL to extract
w_1200 and h_600 transformations
- Server fetches: A 100px wide thumbnail with preserved aspect ratio (e.g., 100x50 for 1200x600) - only ~2KB
- User sees: The full-size image with a smooth blur-to-sharp transition
Examples:
- URL with
w_1200,h_600 → fetches 100x50 placeholder (2:1 ratio preserved)
- URL with
w_800,h_600 → fetches 100x75 placeholder (4:3 ratio preserved)
- URL without transformations → fetches 100px wide, height auto
How it works
- Server-side generation: The blur placeholder is generated on the server during the component render
- Fetch caching:
cache: 'force-cache' ensures the image is fetched once and cached for subsequent builds
- Component caching: Next.js caches the result automatically with React Server Components
- Better UX: Users see a blurred preview while the full image loads
This approach eliminates layout shift and provides a smooth loading experience without any client-side JavaScript overhead.