WEBPery

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:

  1. Generates multiple variants of the source image at request time (or build time if statically rendered).
  2. Serves WebP to capable browsers, original format to others.
  3. Picks the right resolution based on the device and the sizes prop.
  4. Lazy-loads by default; opts out when priority is set.
  5. Adds explicit width and height for 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.
  • deviceSizes and imageSizes — 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".

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

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.

WebP on WordPress: Plugins & Optimisation Guide

Add WebP to your WordPress site. Native support, plugin choices, automatic conversion, lazy loading, and serving WebP with fallbacks.

WebP on Shopify: Theme Setup & Image Optimisation

Shopify serves WebP automatically — here's how the CDN works, what theme code you control, and how to optimise product imagery for speed.

WebP Optimisation: Complete Guide to Faster Image Loading

Optimise WebP images for fast page loads. Practical guide to quality settings, responsive delivery, lazy loading, and Core Web Vitals.

WebP Lazy Loading: Defer Images the Right Way

Lazy load WebP images correctly. Native attribute, IntersectionObserver, priority hints, and the rules that decide which images should never lazy load.