Platform Guide 24 min read

React Image Optimization: Complete Guide

Optimize images in React applications. Learn lazy loading, responsive images, WebP/AVIF delivery, CDN integration, and performance patterns for React, Vite, and CRA projects.

By ImageGuide Team ยท Published February 15, 2026
reactimage optimizationjavascriptperformancevitewebpack

React applications face unique image optimization challenges. Unlike server-rendered frameworks with built-in image components (like Next.js), vanilla React projects require you to build your own optimization pipeline. This guide covers everything from native HTML patterns in JSX to building production-ready image components with TypeScript.

Why Image Optimization Matters in React

SPA-Specific Challenges

Single Page Applications built with React introduce image problems that traditional server-rendered sites do not have:

ChallengeDescriptionImpact
Bundle bloatStatic imports add images to JS bundleLarger initial download
Hydration delayHeavy images block interactive readinessPoor FID/INP scores
Client-side routingImages not prefetched for new routesVisible loading on navigation
Re-render re-fetchesPoor state management causes duplicate requestsWasted bandwidth
No server-side resizingBrowser receives full-size imagesSlow on mobile networks
Memory pressureAll route images may stay in memoryCrashes on low-end devices

The Cost of Unoptimized Images

Unoptimized:
  - Hero image: 2.4MB (4000x3000 JPEG)
  - 12 product thumbnails: 3.6MB total (300KB each)
  - Background patterns: 800KB
  - Total image weight: 6.8MB
  - LCP: 4.2s on 4G

Optimized:
  - Hero image: 180KB (1920x1080 WebP, lazy: false)
  - 12 product thumbnails: 360KB total (30KB each WebP)
  - Background patterns: 45KB (SVG or tiny WebP)
  - Total image weight: 585KB (91% reduction)
  - LCP: 1.4s on 4G

Native HTML Image Optimization in React

Before reaching for libraries, use the native HTML attributes that React supports directly. These cost zero bundle size and work in every browser.

Essential Image Attributes

function HeroImage() {
  return (
    <img
      src="/images/hero-1920.webp"
      srcSet="
        /images/hero-480.webp 480w,
        /images/hero-768.webp 768w,
        /images/hero-1200.webp 1200w,
        /images/hero-1920.webp 1920w
      "
      sizes="100vw"
      alt="Mountain landscape at golden hour"
      width={1920}
      height={1080}
      loading="eager"
      fetchPriority="high"
      decoding="async"
    />
  );
}

Attribute Reference

AttributePurposeValuesWhen to Use
srcSetResponsive image sourcesurl widthW, ...Every content image
sizesViewport-relative display width(media) size, ...With srcSet
loadingLoading strategylazy, eagerlazy for below-fold
fetchPriorityNetwork priority hinthigh, low, autohigh for LCP image
decodingDecode strategyasync, sync, autoasync for most images
width / heightIntrinsic dimensionsPixels (number)Always (prevents CLS)

The sizes Attribute

The sizes attribute tells the browser how wide the image will display before CSS is parsed:

// Full-width hero
<img sizes="100vw" ... />

// Two-column layout on desktop, full-width on mobile
<img sizes="(min-width: 768px) 50vw, 100vw" ... />

// Three-column grid with padding
<img sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" ... />

Picture Element for Format Fallbacks

function OptimizedHero() {
  return (
    <picture>
      <source
        type="image/avif"
        srcSet="/images/hero-480.avif 480w, /images/hero-1200.avif 1200w, /images/hero-1920.avif 1920w"
        sizes="100vw"
      />
      <source
        type="image/webp"
        srcSet="/images/hero-480.webp 480w, /images/hero-1200.webp 1200w, /images/hero-1920.webp 1920w"
        sizes="100vw"
      />
      <img
        src="/images/hero-1920.jpg"
        srcSet="/images/hero-480.jpg 480w, /images/hero-1200.jpg 1200w, /images/hero-1920.jpg 1920w"
        sizes="100vw"
        alt="Mountain landscape at golden hour"
        width={1920}
        height={1080}
        loading="eager"
        fetchPriority="high"
        decoding="async"
      />
    </picture>
  );
}

Building a Reusable Image Component

TypeScript Image Component with Blur Placeholder

// components/Image.tsx
import { useState, useRef, useEffect, ImgHTMLAttributes } from 'react';

interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'placeholder'> {
  src: string;
  alt: string;
  width: number;
  height: number;
  priority?: boolean;
  placeholder?: 'blur' | 'empty';
  blurDataURL?: string;
  onLoadingComplete?: () => void;
}

export function Image({
  src,
  alt,
  width,
  height,
  srcSet,
  sizes,
  priority = false,
  placeholder = 'empty',
  blurDataURL,
  onLoadingComplete,
  className = '',
  ...rest
}: ImageProps) {
  const [isLoaded, setIsLoaded] = useState(false);
  const [hasError, setHasError] = useState(false);
  const imgRef = useRef<HTMLImageElement>(null);

  useEffect(() => {
    if (imgRef.current?.complete) setIsLoaded(true);
  }, []);

  if (hasError) {
    return (
      <div
        style={{ width, height, background: '#f5f5f5', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
        role="img"
        aria-label={alt}
      >
        <span>Failed to load image</span>
      </div>
    );
  }

  const showBlur = placeholder === 'blur' && blurDataURL && !isLoaded;

  return (
    <div style={{ position: 'relative', maxWidth: width, aspectRatio: `${width}/${height}`, width: '100%' }}>
      {showBlur && (
        <img
          src={blurDataURL}
          alt=""
          aria-hidden="true"
          style={{
            position: 'absolute', inset: 0, width: '100%', height: '100%',
            objectFit: 'cover', filter: 'blur(20px)', transform: 'scale(1.1)',
            transition: 'opacity 0.3s', opacity: isLoaded ? 0 : 1,
          }}
        />
      )}
      <img
        ref={imgRef}
        src={src}
        srcSet={srcSet}
        sizes={sizes}
        alt={alt}
        width={width}
        height={height}
        loading={priority ? 'eager' : 'lazy'}
        fetchPriority={priority ? 'high' : undefined}
        decoding="async"
        onLoad={() => { setIsLoaded(true); onLoadingComplete?.(); }}
        onError={() => setHasError(true)}
        className={className}
        style={{
          transition: 'opacity 0.3s', maxWidth: '100%', height: 'auto',
          opacity: showBlur && !isLoaded ? 0 : 1,
        }}
        {...rest}
      />
    </div>
  );
}

Usage Examples

// Hero image - loads immediately with blur placeholder
<Image
  src="/images/hero.webp"
  alt="Company headquarters exterior"
  width={1920}
  height={1080}
  sizes="100vw"
  priority
  placeholder="blur"
  blurDataURL="data:image/webp;base64,UklGRkYAAABXRUJQ..."
/>

// Product thumbnail - lazy loaded
<Image
  src="/images/product-sneaker.webp"
  alt="Running sneaker in midnight blue"
  width={400}
  height={400}
  sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw"
/>

Responsive Images in React

Art Direction with Picture Element

function HeroBanner() {
  return (
    <picture>
      <source media="(max-width: 639px)" srcSet="/images/hero-mobile.webp" width={640} height={800} />
      <source media="(max-width: 1023px)" srcSet="/images/hero-tablet.webp" width={1024} height={600} />
      <img
        src="/images/hero-desktop.webp"
        alt="Team collaboration in modern office"
        width={1920}
        height={800}
        loading="eager"
        fetchPriority="high"
        decoding="async"
      />
    </picture>
  );
}

Dynamic Responsive Image Helper

// utils/imageUtils.ts
interface ResponsiveImageConfig {
  basePath: string;
  widths: number[];
  formats: ('avif' | 'webp' | 'jpg')[];
}

export function buildResponsiveImage(config: ResponsiveImageConfig) {
  const { basePath, widths, formats } = config;
  const ext = basePath.split('.').pop();
  const base = basePath.replace(`.${ext}`, '');

  const sources = formats
    .filter(f => f !== 'jpg')
    .map(format => ({
      type: `image/${format}`,
      srcSet: widths.map(w => `${base}-${w}.${format} ${w}w`).join(', '),
    }));

  const fallback = formats.includes('jpg') ? 'jpg' : formats[formats.length - 1];

  return {
    src: `${base}-${widths[widths.length - 1]}.${fallback}`,
    srcSet: widths.map(w => `${base}-${w}.${fallback} ${w}w`).join(', '),
    sources,
  };
}

// Usage
function FeatureImage() {
  const img = buildResponsiveImage({
    basePath: '/images/feature.jpg',
    widths: [400, 800, 1200, 1600],
    formats: ['avif', 'webp', 'jpg'],
  });

  return (
    <picture>
      {img.sources.map(source => (
        <source key={source.type} type={source.type} srcSet={source.srcSet} sizes="(min-width: 768px) 50vw, 100vw" />
      ))}
      <img src={img.src} srcSet={img.srcSet} sizes="(min-width: 768px) 50vw, 100vw"
        alt="Feature showcase" width={1600} height={900} loading="lazy" decoding="async" />
    </picture>
  );
}

Image Format Strategy

Format Comparison

FormatCompressionBrowser SupportTransparencyBest For
JPEGLossyUniversalNoPhotographs
PNGLosslessUniversalYesGraphics, screenshots
WebPLossy + Lossless97%+YesGeneral purpose
AVIFLossy + Lossless92%+YesMaximum compression
SVGN/A (vector)UniversalYesIcons, logos

For most React projects, using the <picture> element with <source> tags is preferable to JavaScript-based format detection because it does not require JavaScript execution and works with native browser caching.

Build-Time Image Optimization

Vite Configuration

// 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({
      jpg: { quality: 80, progressive: true },
      png: { quality: 80, compressionLevel: 9 },
      webp: { quality: 80, lossless: false },
      avif: { quality: 65, lossless: false },
      svg: {
        multipass: true,
        plugins: [
          { name: 'removeViewBox', active: false },
          { name: 'removeDimensions', active: true },
        ],
      },
    }),
  ],
});

Webpack Configuration (CRA / Custom)

// webpack.config.js (or craco.config.js for CRA)
const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');

module.exports = {
  optimization: {
    minimizer: [
      new ImageMinimizerPlugin({
        minimizer: {
          implementation: ImageMinimizerPlugin.sharpMinify,
          options: {
            encodeOptions: {
              jpeg: { quality: 80, progressive: true },
              webp: { quality: 80 },
              avif: { quality: 65 },
              png: { quality: 80, compressionLevel: 9 },
            },
          },
        },
      }),
    ],
  },
};

Build Script with Sharp

For maximum control, use a build script to generate all image variants:

// scripts/optimize-images.ts
import sharp from 'sharp';
import { globSync } from 'glob';
import path from 'path';
import fs from 'fs';

const config = {
  widths: [320, 480, 640, 768, 1024, 1280, 1920],
  formats: ['avif', 'webp', 'jpg'] as const,
  quality: { avif: 65, webp: 80, jpg: 82 },
};

async function optimizeImages() {
  const images = globSync('src/assets/images/**/*.{jpg,jpeg,png}');
  fs.mkdirSync('public/images', { recursive: true });

  for (const imagePath of images) {
    const basename = path.basename(imagePath, path.extname(imagePath));
    const metadata = await sharp(imagePath).metadata();
    const originalWidth = metadata.width || 1920;

    for (const width of config.widths) {
      if (width > originalWidth) continue;
      for (const format of config.formats) {
        const outputPath = `public/images/${basename}-${width}.${format}`;
        await sharp(imagePath)
          .resize(width, undefined, { withoutEnlargement: true })
          .toFormat(format, { quality: config.quality[format] })
          .toFile(outputPath);
      }
    }

    // Generate blur placeholder
    const blurBuffer = await sharp(imagePath)
      .resize(20, undefined, { withoutEnlargement: true })
      .webp({ quality: 20 })
      .toBuffer();
    fs.writeFileSync(
      `public/images/${basename}-blur.txt`,
      `data:image/webp;base64,${blurBuffer.toString('base64')}`
    );
  }
}

optimizeImages().then(() => console.log('Done'));

Add to package.json:

{
  "scripts": {
    "images": "tsx scripts/optimize-images.ts",
    "prebuild": "npm run images"
  }
}

Dynamic Image Loading

Lazy Component Loading

For heavy image-based components (galleries, carousels), use React.lazy:

import { lazy, Suspense } from 'react';

const ImageGallery = lazy(() => import('./components/ImageGallery'));

function ProductPage({ product }: { product: Product }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <img src={product.mainImage} alt={product.name}
        width={800} height={600} loading="eager" fetchPriority="high" />

      <Suspense fallback={<GallerySkeleton count={6} />}>
        <ImageGallery images={product.images} />
      </Suspense>
    </div>
  );
}

function GallerySkeleton({ count }: { count: number }) {
  return (
    <div className="gallery-grid">
      {Array.from({ length: count }, (_, i) => (
        <div key={i} style={{ aspectRatio: '1', background: '#f0f0f0' }} />
      ))}
    </div>
  );
}

IntersectionObserver Hook

// hooks/useIntersectionObserver.ts
import { useState, useEffect, useRef, RefObject } from 'react';

export function useIntersectionObserver<T extends HTMLElement>(
  options: { threshold?: number; rootMargin?: string; triggerOnce?: boolean } = {}
): [RefObject<T | null>, boolean] {
  const { threshold = 0, rootMargin = '200px', triggerOnce = true } = options;
  const ref = useRef<T | null>(null);
  const [isIntersecting, setIsIntersecting] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) { setIsIntersecting(true); if (triggerOnce) observer.disconnect(); }
        else if (!triggerOnce) setIsIntersecting(false);
      },
      { threshold, rootMargin }
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [threshold, rootMargin, triggerOnce]);

  return [ref, isIntersecting];
}

Progressive Image Loading

import { useState, useEffect } from 'react';

interface ProgressiveImageProps {
  lowSrc: string;
  highSrc: string;
  alt: string;
  width: number;
  height: number;
}

export function ProgressiveImage({ lowSrc, highSrc, alt, width, height }: ProgressiveImageProps) {
  const [currentSrc, setCurrentSrc] = useState(lowSrc);
  const [isHighRes, setIsHighRes] = useState(false);

  useEffect(() => {
    const img = new window.Image();
    img.onload = () => { setCurrentSrc(highSrc); setIsHighRes(true); };
    img.src = highSrc;
  }, [highSrc]);

  return (
    <img src={currentSrc} alt={alt} width={width} height={height}
      style={{ filter: isHighRes ? 'none' : 'blur(15px)', transition: 'filter 0.4s', maxWidth: '100%', height: 'auto' }} />
  );
}

CDN Integration

URL-Based Transforms with Sirv

Sirv provides URL-based image transformations. Instead of generating multiple variants at build time, request exactly the size and format you need via URL parameters:

// utils/sirvImage.ts
const SIRV_BASE = 'https://youraccount.sirv.com';

interface SirvOptions {
  width?: number;
  height?: number;
  quality?: number;
  format?: 'webp' | 'avif' | 'jpg' | 'png' | 'optimal';
  crop?: 'face' | 'center' | 'smart';
}

export function sirvUrl(path: string, options: SirvOptions = {}): string {
  const params = new URLSearchParams();
  if (options.width) params.set('w', String(options.width));
  if (options.height) params.set('h', String(options.height));
  if (options.quality) params.set('q', String(options.quality));
  if (options.format) params.set('format', options.format);
  if (options.crop) params.set('crop.type', options.crop);
  const qs = params.toString();
  return `${SIRV_BASE}${path}${qs ? '?' + qs : ''}`;
}

export function sirvSrcSet(path: string, widths: number[], quality = 80): string {
  return widths
    .map(w => `${sirvUrl(path, { width: w, quality, format: 'optimal' })} ${w}w`)
    .join(', ');
}
// Using Sirv in components
import { sirvUrl, sirvSrcSet } from '../utils/sirvImage';

function ProductCard({ product }: { product: Product }) {
  const imagePath = `/products/${product.slug}.jpg`;

  return (
    <div className="product-card">
      <img
        src={sirvUrl(imagePath, { width: 600, quality: 80, format: 'optimal' })}
        srcSet={sirvSrcSet(imagePath, [300, 400, 600, 800, 1200])}
        sizes="(min-width: 1280px) 25vw, (min-width: 768px) 33vw, 50vw"
        alt={product.name}
        width={600}
        height={600}
        loading="lazy"
        decoding="async"
      />
      <h3>{product.name}</h3>
    </div>
  );
}

Sirv Transformation Parameters

ParameterDescriptionExample
wResize width?w=800
hResize height?h=600
qQuality (1-100)?q=80
formatOutput format?format=optimal (auto WebP/AVIF)
crop.typeCrop strategy?crop.type=face
sharpenApply sharpening?sharpen=1
profileApply saved preset?profile=thumbnail
watermarkAdd watermark?watermark=/mark.png

Using format=optimal lets Sirv automatically serve AVIF or WebP based on browser support, eliminating the need for <picture> element format fallbacks entirely.

CDN Image Hook

// hooks/useCDNImage.ts
import { useMemo } from 'react';
import { sirvUrl, sirvSrcSet } from '../utils/sirvImage';

export function useCDNImage(path: string, options: {
  widths?: number[]; quality?: number; sizes?: string; displayWidth?: number;
} = {}) {
  const { widths = [320, 480, 640, 800, 1200, 1600], quality = 80, sizes = '100vw', displayWidth = 800 } = options;

  return useMemo(() => ({
    src: sirvUrl(path, { width: displayWidth, quality, format: 'optimal' }),
    srcSet: sirvSrcSet(path, widths, quality),
    sizes,
  }), [path, displayWidth, quality, widths, sizes]);
}

For batch processing large image libraries, Sirv AI Studio can remove backgrounds, apply consistent styling, and generate variants across hundreds of product images via API โ€” useful when your React app pulls images from a CMS.

State Management for Images

Preventing Re-Renders from Causing Re-Fetches

One of the most common React image performance issues is unnecessary re-renders causing image elements to unmount and remount:

// BAD: Generates a new URL object on every render
function ProductList({ products }: { products: Product[] }) {
  return (
    <div>
      {products.map(product => (
        <img
          key={product.id}
          src={new URL(`/products/${product.id}.webp`, CDN_BASE).toString()}
          alt={product.name}
        />
      ))}
    </div>
  );
}

// GOOD: Stable string reference
function ProductList({ products }: { products: Product[] }) {
  return (
    <div>
      {products.map(product => (
        <img
          key={product.id}
          src={`${CDN_BASE}/products/${product.id}.webp`}
          alt={product.name}
        />
      ))}
    </div>
  );
}

Image Error Boundary

import { Component, ReactNode } from 'react';

export class ImageErrorBoundary extends Component<
  { children: ReactNode; fallback?: ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };
  static getDerivedStateFromError() { return { hasError: true }; }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="image-error-boundary">
          <p>Images could not be loaded.</p>
          <button onClick={() => this.setState({ hasError: false })}>Retry</button>
        </div>
      );
    }
    return this.props.children;
  }
}

Performance Monitoring

Using the web-vitals Library

import { onLCP, onCLS } from 'web-vitals';

export function trackImagePerformance() {
  onLCP((metric) => {
    const lcpEntry = metric.entries[metric.entries.length - 1] as PerformanceEntry & { element?: Element };
    if (lcpEntry?.element?.tagName === 'IMG') {
      console.log('LCP image:', (lcpEntry.element as HTMLImageElement).src);
    }
  });

  onCLS((metric) => {
    if (metric.value > 0.1) {
      console.warn('CLS exceeds threshold:', metric.value);
    }
  });
}

Lighthouse CI Integration

# .lighthouserc.yml
ci:
  collect:
    numberOfRuns: 3
    url:
      - http://localhost:3000
  assert:
    assertions:
      uses-responsive-images: error
      offscreen-images: error
      uses-optimized-images: error
      unsized-images: error
      largest-contentful-paint: ["error", {"maxNumericValue": 2500}]
      cumulative-layout-shift: ["error", {"maxNumericValue": 0.1}]

Common React Image Anti-Patterns

1. Importing Images into the JavaScript Bundle

// BAD: Image becomes part of JS bundle (OK for < 10KB, bad for larger)
import heroImage from './images/hero.jpg';
function Hero() { return <img src={heroImage} alt="Hero" />; }

// GOOD: Image loaded separately via public directory or CDN
function Hero() {
  return <img src="/images/hero.webp" alt="Hero" width={1920} height={1080} loading="eager" fetchPriority="high" />;
}

2. Missing Width and Height

// BAD: Causes layout shift (CLS)
<img src="/photo.webp" alt="Photo" />

// GOOD: Prevents CLS
<img src="/photo.webp" alt="Photo" width={800} height={600} />

3. Eager Loading Everything

// BAD: All images load immediately
{products.map(p => <img src={p.image} alt={p.name} loading="eager" />)}

// GOOD: Only first row loads eagerly
{products.map((p, i) => (
  <img src={p.image} alt={p.name}
    loading={i < 4 ? 'eager' : 'lazy'}
    fetchPriority={i === 0 ? 'high' : undefined} />
))}

4. Not Using srcSet

// BAD: Single 3840px image for all viewports
<img src="/images/hero-3840.jpg" alt="Hero" />

// GOOD: Browser picks the right size
<img
  src="/images/hero-1920.webp"
  srcSet="/images/hero-480.webp 480w, /images/hero-768.webp 768w, /images/hero-1200.webp 1200w, /images/hero-1920.webp 1920w"
  sizes="100vw"
  alt="Hero" width={1920} height={1080} />

5. Using JavaScript for What CSS Can Do

// BAD: JS-driven responsive image switching
function ResponsiveHero() {
  const [src, setSrc] = useState('/images/hero-mobile.jpg');
  useEffect(() => {
    setSrc(window.innerWidth > 768 ? '/images/hero-desktop.jpg' : '/images/hero-mobile.jpg');
  }, []);
  return <img src={src} alt="Hero" />;
}

// GOOD: Let HTML handle it natively
function ResponsiveHero() {
  return (
    <picture>
      <source media="(min-width: 768px)" srcSet="/images/hero-desktop.webp" />
      <img src="/images/hero-mobile.webp" alt="Hero" width={1920} height={1080} />
    </picture>
  );
}

6. Not Handling Loading and Error States

// BAD: No feedback, no error handling
<img src={userAvatar} alt={userName} />

// GOOD: Complete loading lifecycle
function Avatar({ src, name }: { src: string; name: string }) {
  const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading');

  return (
    <div style={{ width: 48, height: 48 }}>
      {state === 'loading' && <div className="avatar-skeleton" />}
      {state === 'error' && <div className="avatar-fallback">{name[0]}</div>}
      <img src={src} alt={name} width={48} height={48}
        onLoad={() => setState('loaded')}
        onError={() => setState('error')}
        style={{ display: state === 'loaded' ? 'block' : 'none' }} />
    </div>
  );
}

Summary

Quick Reference

ScenarioKey Attributes/Pattern
Hero/LCP imageloading="eager", fetchPriority="high", decoding="async"
Content imageloading="lazy", decoding="async", srcSet + sizes
Product gridloading="lazy", memoized component, CDN srcSet
Avatar/thumbnailFixed width/height, small sizes value
BackgroundCSS background-image, avoid JS-based switching
Format fallback<picture> with AVIF/WebP <source>, JPEG fallback
Blur placeholderTiny base64 image + transition on load
CDN integrationURL-based transforms via Sirv, format=optimal

Optimization Checklist

  1. Always include width and height on <img> elements
  2. Use loading="lazy" on all below-fold images
  3. Set fetchPriority="high" on the LCP image only
  4. Add decoding="async" to all images
  5. Provide srcSet and sizes for responsive images
  6. Use <picture> for AVIF/WebP with JPEG fallback
  7. Memoize image components to prevent re-render re-fetches
  8. Use stable string URLs, not computed objects
  9. Keep images out of the JS bundle (use /public or CDN)
  10. Implement blur or skeleton placeholders for perceived performance
  11. Handle error states gracefully with fallback UI
  12. Use a CDN like Sirv for URL-based transforms
  13. Run Lighthouse CI in your pipeline to catch regressions
  14. Consider Sirv AI Studio for batch processing product images
  15. Profile with React DevTools to find unnecessary image re-renders

React does not provide a built-in image component like Next.js, but that gives you full control. Use native HTML attributes first, build a reusable component for consistency, integrate a CDN for dynamic resizing, and monitor performance continuously.

Related Resources

Format References

Ready to optimize your images?

Sirv automatically optimizes, resizes, and converts your images. Try it free.

Start Free Trial