Ship WebP in React apps. Next.js Image component, Vite/Webpack pipelines, picture-element patterns, and lazy loading for modern React stacks.
Serving WebP from React Applications
React doesn't ship its own image component, but the modern React ecosystem makes WebP delivery straightforward through framework-level image components (Next.js, Remix, Astro) or build pipeline tooling (Vite, Webpack). This guide covers the major patterns and the trade-offs between them.
For the underlying performance principles, see WebP Optimisation. For loading strategy, see WebP Lazy Loading.
Next.js: the path of least resistance
If you're on Next.js 13+, the next/image component handles WebP automatically:
import Image from "next/image";
export default function Hero() {
return (
<Image
src="/hero.jpg"
alt="..."
width={1600}
height={900}
priority
/>
);
}
What Next.js does behind the scenes:
- Generates multiple variants of the source image at request time (or build time if statically rendered).
- Serves WebP to capable browsers, original format to others.
- Picks the right resolution based on the device and the
sizesprop. - Lazy-loads by default; opts out when
priorityis set. - Adds explicit
widthandheightfor CLS prevention.
The single priority prop is the most important: it should be set on exactly one image per page — the LCP candidate. Forgetting it on the hero image is the most common Next.js performance regression. See Core Web Vitals & Images.
Configuring the image optimisation
In next.config.js:
module.exports = {
images: {
formats: ["image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
remotePatterns: [
{ protocol: "https", hostname: "cdn.example.com" },
],
},
};
formats— to also serve AVIF, add"image/avif"before"image/webp". Browsers that support AVIF get it; others fall back to WebP; others to the original.deviceSizesandimageSizes— the variants Next.js generates. Defaults are fine for most sites.remotePatterns— required if you load images from external hosts.
Static export caveat
If you use output: "export" for static hosting (S3, GitHub Pages), next/image requires a custom loader because the default loader needs the Next.js server:
module.exports = {
images: {
loader: "custom",
loaderFile: "./image-loader.js",
},
};
// image-loader.js
export default function loader({ src, width, quality }) {
return `${src}?w=${width}&q=${quality || 75}`;
}
The loader returns a URL pointing to whatever image service does the conversion (an external CDN like Cloudinary, Imgix, or your own pipeline). The build-time image optimisation doesn't run in export mode.
Remix and React Router
Remix doesn't ship an image component. The two common approaches:
unpic library
unpic provides a framework-agnostic image component that works with most modern image CDNs:
import { Image } from "@unpic/react";
export default function Hero() {
return (
<Image
src="https://cdn.example.com/hero.jpg"
width={1600}
height={900}
alt="..."
priority
/>
);
}
unpic detects the CDN from the URL and generates the right srcset automatically — works with Cloudinary, Imgix, Shopify, Vercel, and others.
Manual <picture> element
If you're not on an image CDN, manage the pattern manually:
export function ResponsiveImage({ src, alt, width, height }: Props) {
const base = src.replace(/\.(jpg|jpeg|png)$/, "");
return (
<picture>
<source
type="image/webp"
srcSet={`
${base}-400.webp 400w,
${base}-800.webp 800w,
${base}-1200.webp 1200w,
${base}-1800.webp 1800w
`}
sizes="(max-width: 768px) 100vw, 1200px"
/>
<img
src={`${base}-1200.${src.split(".").pop()}`}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
/>
</picture>
);
}
The WebP variants are generated at build time (next section) and referenced through this component everywhere images appear.
Vite + plain React
Vite's image handling is minimal by default. The standard pattern is to add a plugin that processes images at build time.
vite-plugin-image-optimizer
npm install -D vite-plugin-image-optimizer sharp
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { ViteImageOptimizer } from "vite-plugin-image-optimizer";
export default defineConfig({
plugins: [
react(),
ViteImageOptimizer({
png: { quality: 100 },
jpeg: { quality: 80 },
jpg: { quality: 80 },
webp: { lossless: false, quality: 80 },
}),
],
});
This optimises images at build time but doesn't generate WebP alternates from JPEG/PNG sources. For that, pair it with vite-imagetools:
import imagetools from "vite-imagetools";
export default defineConfig({
plugins: [react(), imagetools()],
});
Then import with query parameters:
import heroWebp from "./hero.jpg?w=800;1200;1800&format=webp&as=picture";
export default function Hero() {
return <picture>{/* heroWebp metadata used to render */}</picture>;
}
Webpack pipelines
For projects on plain Webpack (Create React App, custom configs), use responsive-loader:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.(jpe?g|png)$/,
use: [
{
loader: "responsive-loader",
options: {
adapter: require("responsive-loader/sharp"),
format: "webp",
sizes: [400, 800, 1200, 1800],
},
},
],
},
],
},
};
Import returns metadata:
import hero from "./hero.jpg";
export default function Hero() {
return (
<img
srcSet={hero.srcSet}
sizes="(max-width: 768px) 100vw, 1200px"
src={hero.src}
alt="..."
width={hero.width}
height={hero.height}
/>
);
}
CDN-based approaches
For React apps that load images from a CDN that supports format negotiation (Cloudinary, Imgix, Vercel, Shopify CDN), most of the build-time pipeline is unnecessary. The CDN serves WebP to capable browsers automatically.
export function Image({ src, width, ...rest }: ImageProps) {
const cdnUrl = `https://res.cloudinary.com/your-cloud/image/upload/w_${width},f_auto,q_auto/${src}`;
return <img src={cdnUrl} loading="lazy" decoding="async" {...rest} />;
}
f_auto tells Cloudinary to serve WebP (or AVIF) when the browser supports it. The build pipeline stays simple; the CDN does the work.
Lazy loading patterns
For any React stack, the loading strategy:
<img
src={...}
alt="..."
width={1200}
height={675}
loading="lazy" // below-the-fold
decoding="async"
/>
<img
src={...}
alt="..."
width={1600}
height={900}
fetchPriority="high" // LCP image
decoding="async"
/>
Note React uses camelCase: fetchPriority, not fetchpriority. The browser receives lowercase via React's prop conversion.
For programmatic IntersectionObserver-based lazy loading (CSS backgrounds, custom thresholds), the standard React pattern:
import { useEffect, useRef, useState } from "react";
export function LazyBackground({ src, children }: Props) {
const ref = useRef<HTMLDivElement>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setLoaded(true);
observer.disconnect();
}
},
{ rootMargin: "200px 0px" }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
style={{ backgroundImage: loaded ? `url(${src})` : "none" }}
>
{children}
</div>
);
}
But for ordinary <img> lazy loading, prefer the native loading="lazy" attribute over an IntersectionObserver implementation. See WebP Lazy Loading.
Server Components (Next.js App Router)
In a React Server Component, the image component works identically to client components:
import Image from "next/image";
import { getProduct } from "@/lib/data";
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return (
<Image
src={product.imageUrl}
alt={product.name}
width={800}
height={800}
priority
/>
);
}
Server-rendered images are visible in the initial HTML, which helps both LCP and SEO crawl performance.
Common React + WebP issues
Hydration mismatches
If your image component returns different markup on server vs client (for example, conditional rendering based on a feature-detection hook), React will throw a hydration warning. Keep image markup deterministic — let the browser handle format negotiation, not React.
Build times balloon after adding image processing
Sharp-based image processing is CPU-bound. If your build now takes minutes:
- Cache image outputs between builds (most plugins do this; verify the cache is enabled).
- Move image processing to a CDN if the build server is the bottleneck.
next/image warns about missing dimensions
Next.js requires width and height unless you use fill. The warning is correct — without dimensions, you ship a CLS hit. Get the dimensions from your image source (CMS, file metadata) or use fill with a sized parent.
Lazy loading the LCP image
The most common React performance regression. Always set priority on the LCP <Image> in Next.js. For other React frameworks, omit loading="lazy" and add fetchPriority="high".
Recommended setup
For a Next.js app: use next/image with priority on LCP candidates. Configure formats: ["image/webp"] (add "image/avif" if you want AVIF). That covers the entire pipeline.
For a Remix app: use unpic if you're on a supported image CDN; otherwise add a build-time image pipeline (Sharp-based) and a manual <picture> component.
For Vite + React: add vite-imagetools for build-time WebP generation and import images with format-query parameters.
For Webpack + React: add responsive-loader with the Sharp adapter.
For a React app on any image CDN: skip the build-time pipeline and let the CDN handle format negotiation.
Where to go from here
- WebP Optimisation — performance fundamentals
- WebP Lazy Loading — loading strategy detail
- WebP Compression Settings — encoder parameters
- Core Web Vitals & Images — measuring impact
- WebP Browser Support — fallback patterns
- Convert content: PNG to WebP, JPG to WebP
The React ecosystem gives you several layers to choose from — framework component, build plugin, or CDN. Pick the layer that fits your stack and the WebP work is largely solved.