← Back to DevBytes

How to Create an Image Gallery with React and Cloudinary

What Is an Image Gallery with React and Cloudinary?

An image gallery built with React and Cloudinary combines the power of React's component-based UI architecture with Cloudinary's cloud-based image management, optimization, and delivery platform. Instead of storing images on your own server and manually creating multiple sizes or formats, you upload images to Cloudinary once and dynamically transform them on the fly using URL parameters. React handles the interactive frontend — rendering thumbnails, lightboxes, infinite scroll grids, and upload interfaces — while Cloudinary handles the heavy lifting of image storage, resizing, cropping, format conversion, compression, and CDN delivery.

This pairing gives you a performant, scalable gallery that loads quickly on any device, supports modern image formats like WebP and AVIF automatically, and eliminates the need for manual image processing pipelines.

Why This Combination Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Modern image galleries face several challenges that React and Cloudinary solve together:

Setting Up the Project

Begin by creating a new React application using Vite (or Create React App). Then install the Cloudinary dependencies you'll need:

# Create a new React project with Vite
npm create vite@latest react-cloudinary-gallery -- --template react
cd react-cloudinary-gallery

# Install dependencies
npm install @cloudinary/react @cloudinary/url-gen react-dropzone
npm install

The @cloudinary/react package provides React components like AdvancedImage for rendering optimized images. The @cloudinary/url-gen package gives you a fluent API for building transformation URLs. The optional react-dropzone package helps create drag-and-drop upload areas.

Configuring Your Cloudinary Cloud

Create a free Cloudinary account at cloudinary.com. After signing up, you'll receive a cloud name, an API key, and an API secret. For this tutorial, you'll primarily use the cloud name for building public URLs. Store sensitive credentials in environment variables.

Create a .env file in your project root:

VITE_CLOUDINARY_CLOUD_NAME=your_cloud_name_here
VITE_CLOUDINARY_API_KEY=your_api_key_here
VITE_CLOUDINARY_UPLOAD_PRESET=your_upload_preset_here

In the Cloudinary dashboard, navigate to Settings > Upload and create an unsigned upload preset. This preset allows client-side uploads without exposing your API secret. Configure it to allow the file types you need (images only, for a gallery) and optionally set a folder destination.

Building the Cloudinary Configuration Module

Create a centralized Cloudinary configuration file that you'll reuse across components. This ensures consistent cloud settings and makes maintenance easier:

// src/lib/cloudinary.js
import { Cloudinary } from '@cloudinary/url-gen';

const cld = new Cloudinary({
  cloud: {
    cloudName: import.meta.env.VITE_CLOUDINARY_CLOUD_NAME
  }
});

export default cld;

This module exports a configured Cloudinary instance. Any component that needs to generate Cloudinary URLs will import this instance and use its transformation builders.

Creating the Image Data Source

For a gallery, you need a list of images. You can fetch these from Cloudinary's API or maintain a static list of public IDs. Here's a utility that fetches all images from a specific folder using Cloudinary's Admin API (for a production app, proxy this through your backend):

// src/lib/fetchImages.js
const CLOUD_NAME = import.meta.env.VITE_CLOUDINARY_CLOUD_NAME;
const API_KEY = import.meta.env.VITE_CLOUDINARY_API_KEY;

export async function fetchImagesFromFolder(folder = 'gallery') {
  // In production, proxy this call through your own backend
  // to avoid exposing the API secret
  const response = await fetch(
    `https://api.cloudinary.com/v1_1/${CLOUD_NAME}/resources/image/upload?prefix=${folder}&type=upload`,
    {
      headers: {
        Authorization: `Basic ${btoa(API_KEY + ':your_api_secret')}`
      }
    }
  );
  const data = await response.json();
  return data.resources.map(img => ({
    publicId: img.public_id,
    width: img.width,
    height: img.height,
    format: img.format,
    tags: img.tags || [],
    createdAt: img.created_at
  }));
}

Important: In a real production application, never expose your API secret in client-side code. Proxy this request through a backend endpoint (like a Next.js API route, Express server, or serverless function). For this tutorial, we'll use a static image list that you can populate after uploading images.

Building the Gallery Grid Component

The core of your gallery is a responsive grid that displays thumbnail images. Each image uses Cloudinary transformations to create appropriately sized thumbnails. Here's a complete gallery grid component:

// src/components/ImageGallery.jsx
import { useState } from 'react';
import { AdvancedImage, responsive, placeholder } from '@cloudinary/react';
import { thumbnail, fill, crop } from '@cloudinary/url-gen/actions/resize';
import { quality, format } from '@cloudinary/url-gen/actions/delivery';
import cld from '../lib/cloudinary';
import Lightbox from './Lightbox'; // We'll build this next

// Static image data — replace with your own public IDs
const GALLERY_IMAGES = [
  { publicId: 'gallery/landscape-01', alt: 'Mountain landscape at sunset' },
  { publicId: 'gallery/portrait-02', alt: 'Urban street photography' },
  { publicId: 'gallery/macro-03', alt: 'Close-up of dew on flower petals' },
  { publicId: 'gallery/aerial-04', alt: 'Drone shot of coastline' },
  { publicId: 'gallery/wildlife-05', alt: 'Fox in winter forest' },
  { publicId: 'gallery/abstract-06', alt: 'Colorful light painting' },
];

export default function ImageGallery() {
  const [selectedIndex, setSelectedIndex] = useState(null);

  const openLightbox = (index) => setSelectedIndex(index);
  const closeLightbox = () => setSelectedIndex(null);

  const goToNext = () => {
    setSelectedIndex((prev) =>
      prev < GALLERY_IMAGES.length - 1 ? prev + 1 : 0
    );
  };

  const goToPrev = () => {
    setSelectedIndex((prev) =>
      prev > 0 ? prev - 1 : GALLERY_IMAGES.length - 1
    );
  };

  return (
    <>
      
{GALLERY_IMAGES.map((image, index) => { // Build the Cloudinary image object with transformations const imgObject = cld.image(image.publicId); // Apply thumbnail transformations imgObject .resize( fill() .width(400) .height(300) .gravity('auto') ) .delivery(quality('auto')) .delivery(format('auto')); return (
openLightbox(index)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openLightbox(index); } }} aria-label={`Open ${image.alt} in lightbox`} >
{image.alt}
); })}
{selectedIndex !== null && ( )} ); }

Let's examine the key Cloudinary pieces in this component. The cld.image(image.publicId) call creates a reference to the image stored in your Cloudinary account. The resize(fill().width(400).height(300).gravity('auto')) chain instructs Cloudinary to crop the image to exactly 400×300 pixels, using automatic gravity detection to keep the most important part of the image in frame. The delivery(quality('auto')) and delivery(format('auto')) directives enable automatic quality compression and format selection — Cloudinary serves WebP to Chrome users and AVIF to Firefox users, falling back to JPEG/PNG for older browsers.

The AdvancedImage component from @cloudinary/react handles rendering, including the responsive() plugin that generates srcset attributes for different screen sizes, and the placeholder() plugin that shows a low-quality preview while the full image loads.

Building the Lightbox Component

A proper gallery needs a lightbox for viewing full-resolution images. Here's a complete lightbox component with keyboard navigation and smooth transitions:

// src/components/Lightbox.jsx
import { useEffect, useCallback } from 'react';
import { AdvancedImage } from '@cloudinary/react';
import { scale } from '@cloudinary/url-gen/actions/resize';
import { quality, format } from '@cloudinary/url-gen/actions/delivery';
import cld from '../lib/cloudinary';

export default function Lightbox({
  images,
  currentIndex,
  onClose,
  onNext,
  onPrev,
}) {
  const currentImage = images[currentIndex];

  // Build a high-resolution version for the lightbox
  const lightboxImage = cld.image(currentImage.publicId);
  lightboxImage
    .resize(scale().width(1920)) // Constrain to 1920px width
    .delivery(quality('auto'))
    .delivery(format('auto'));

  const handleKeyDown = useCallback(
    (e) => {
      switch (e.key) {
        case 'Escape':
          onClose();
          break;
        case 'ArrowRight':
          onNext();
          break;
        case 'ArrowLeft':
          onPrev();
          break;
        default:
          break;
      }
    },
    [onClose, onNext, onPrev]
  );

  useEffect(() => {
    document.addEventListener('keydown', handleKeyDown);
    // Prevent background scrolling while lightbox is open
    document.body.style.overflow = 'hidden';
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      document.body.style.overflow = '';
    };
  }, [handleKeyDown]);

  return (
    
e.stopPropagation()} // Prevent closing when clicking the image > {/* Close button */} {/* Previous button */} {/* Next button */} {/* The image */}
{/* Caption and counter */}

{currentImage.alt}

{currentIndex + 1} / {images.length}
); }

This lightbox uses the same Cloudinary instance but requests a larger image (scale().width(1920)) for detailed viewing. The scale transformation constrains the image to a maximum width while maintaining aspect ratio — Cloudinary processes this on-the-fly, so you never store a separate "full-size" copy. Keyboard navigation (Escape to close, arrow keys to navigate) and click-outside-to-close provide an accessible, intuitive experience.

Adding Image Upload Capabilities

Many galleries need user upload functionality. Here's a complete upload component using Cloudinary's unsigned upload endpoint and react-dropzone for drag-and-drop:

// src/components/ImageUploader.jsx
import { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';

const CLOUD_NAME = import.meta.env.VITE_CLOUDINARY_CLOUD_NAME;
const UPLOAD_PRESET = import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET;

export default function ImageUploader({ onUploadSuccess }) {
  const [uploading, setUploading] = useState(false);
  const [uploadProgress, setUploadProgress] = useState(0);
  const [previews, setPreviews] = useState([]);

  const onDrop = useCallback(
    async (acceptedFiles) => {
      // Generate local previews
      const localPreviews = acceptedFiles.map((file) => ({
        file,
        previewUrl: URL.createObjectURL(file),
        id: crypto.randomUUID(),
      }));
      setPreviews((prev) => [...prev, ...localPreviews]);

      // Upload each file to Cloudinary
      for (const preview of localPreviews) {
        setUploading(true);
        setUploadProgress(0);

        const formData = new FormData();
        formData.append('file', preview.file);
        formData.append('upload_preset', UPLOAD_PRESET);
        formData.append('folder', 'gallery');

        // Simulate progress (real progress requires XHR)
        const progressInterval = setInterval(() => {
          setUploadProgress((prev) => {
            if (prev >= 90) {
              clearInterval(progressInterval);
              return prev;
            }
            return prev + Math.random() * 15;
          });
        }, 300);

        try {
          const response = await fetch(
            `https://api.cloudinary.com/v1_1/${CLOUD_NAME}/image/upload`,
            {
              method: 'POST',
              body: formData,
            }
          );
          clearInterval(progressInterval);
          setUploadProgress(100);

          if (!response.ok) {
            const errorData = await response.json();
            throw new Error(errorData.error?.message || 'Upload failed');
          }

          const data = await response.json();

          // Notify parent component
          if (onUploadSuccess) {
            onUploadSuccess({
              publicId: data.public_id,
              width: data.width,
              height: data.height,
              format: data.format,
              alt: preview.file.name.replace(/\.[^.]+$/, ''),
            });
          }

          // Revoke the local preview URL
          URL.revokeObjectURL(preview.previewUrl);
          setPreviews((prev) =>
            prev.filter((p) => p.id !== preview.id)
          );
        } catch (error) {
          console.error('Upload error:', error);
          setPreviews((prev) =>
            prev.filter((p) => p.id !== preview.id)
          );
        } finally {
          setUploading(false);
          setUploadProgress(0);
        }
      }
    },
    [onUploadSuccess]
  );

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept: {
      'image/jpeg': ['.jpg', '.jpeg'],
      'image/png': ['.png'],
      'image/webp': ['.webp'],
      'image/avif': ['.avif'],
      'image/heic': ['.heic'],
    },
    maxSize: 20 * 1024 * 1024, // 20MB
  });

  return (
    
{isDragActive ? (

Drop images here...

) : ( <>

Drag & drop images here, or click to browse

JPEG, PNG, WebP, AVIF, HEIC — up to 20MB each )}
{/* Upload progress */} {uploading && (
Uploading... {Math.round(uploadProgress)}%
)} {/* Local previews */} {previews.length > 0 && (
{previews.map((preview) => (
Upload preview
Pending upload...
))}
)}
); }

This uploader component handles the full upload lifecycle: accepting files via drag-and-drop, validating file types and sizes, showing local previews before upload completes, posting to Cloudinary's unsigned upload endpoint, and reporting progress. The upload_preset configured in your Cloudinary dashboard enables secure client-side uploads without exposing your API secret.

Putting It All Together — The Main App Component

Wire everything together in your App component, managing the gallery image list with React state so new uploads appear instantly:

// src/App.jsx
import { useState } from 'react';
import ImageGallery from './components/ImageGallery';
import ImageUploader from './components/ImageUploader';

function App() {
  const [galleryImages, setGalleryImages] = useState([
    { publicId: 'gallery/landscape-01', alt: 'Mountain landscape at sunset' },
    { publicId: 'gallery/portrait-02', alt: 'Urban street photography' },
    { publicId: 'gallery/macro-03', alt: 'Close-up of dew on flower petals' },
    { publicId: 'gallery/aerial-04', alt: 'Drone shot of coastline' },
    { publicId: 'gallery/wildlife-05', alt: 'Fox in winter forest' },
    { publicId: 'gallery/abstract-06', alt: 'Colorful light painting' },
  ]);

  const handleUploadSuccess = (newImage) => {
    setGalleryImages((prev) => [newImage, ...prev]);
  };

  return (
    

Image Gallery

Powered by React & Cloudinary

); } export default App;

Now update ImageGallery to accept the images prop instead of using a hardcoded constant. Replace the GALLERY_IMAGES constant with props.images:

// In ImageGallery.jsx, replace the GALLERY_IMAGES constant with:
export default function ImageGallery({ images }) {
  // ... rest of the component uses "images" instead of "GALLERY_IMAGES"
}

Advanced Cloudinary Transformations for Galleries

Beyond basic resizing, Cloudinary offers powerful transformations that elevate your gallery. Here are practical examples you can integrate:

Blurred Thumbnail Placeholders (PAAI)

Instead of the placeholder() plugin, you can generate an inline base64 placeholder using Cloudinary's e_blur and w_50 transformations. This creates a tiny blurred preview embedded directly in your HTML:

// Generate a blurred placeholder URL
function getPlaceholderUrl(publicId) {
  return cld.image(publicId)
    .resize(scale().width(50))
    .delivery(quality(10))
    .effect('blur:200')
    .toURL();
}

// Use it in your component
const placeholderUrl = getPlaceholderUrl(image.publicId);
// Set this as a background-image on the gallery item container
// while the full AdvancedImage loads

Face Detection Cropping for Portrait Galleries

If your gallery contains portraits, use face detection gravity to ensure faces remain centered in thumbnails:

imgObject.resize(
  fill()
    .width(400)
    .height(400)
    .gravity('face')
);

Cloudinary's AI detects faces and centers the crop around them, even for group photos.

Watermarks for Protected Galleries

Add a watermark overlay to protect your images from unauthorized reuse:

// First, upload a watermark image to Cloudinary
// Then reference it in transformations
imgObject.addTransformation(
  `w_200,o_30,x_10,y_10,g_south_east/l_watermark_public_id`
);
// This overlays a 200px-wide watermark at 30% opacity
// positioned 10px from the bottom-right corner

Artistic Filters

Apply creative effects for stylized gallery presentations:

// Sepia effect
imgObject.effect('sepia');

// Grayscale with a color splash
imgObject.effect('grayscale');

// Duotone look
imgObject.effect('art:audrey');

// Oil painting effect
imgObject.effect('art:zorro');

// Vignette
imgObject.effect('vignette:40');

Building a Responsive srcSet with Cloudinary

The responsive() plugin from @cloudinary/react automatically generates srcset attributes. For manual control, you can build explicit srcset strings for different breakpoints:

// src/lib/buildSrcSet.js
import cld from './cloudinary';
import { fill } from '@cloudinary/url-gen/actions/resize';
import { quality, format } from '@cloudinary/url-gen/actions/delivery';

export function buildResponsiveSrcSet(publicId, aspectRatio = '4:3') {
  const widths = [400, 600, 800, 1200, 1600];

  const sources = widths.map((width) => {
    const height = Math.round(width * (3 / 4)); // for 4:3 aspect ratio
    const img = cld.image(publicId);
    img.resize(fill().width(width).height(height).gravity('auto'));
    img.delivery(quality('auto'));
    img.delivery(format('auto'));
    return `${img.toURL()} ${width}w`;
  });

  return sources.join(', ');
}

// Usage in an  tag:
// 

This approach gives you fine-grained control over which sizes are generated and how the browser selects them, while still leveraging Cloudinary's on-the-fly transformation engine.

Infinite Scroll Gallery with Pagination

For large galleries, implement infinite scroll using Cloudinary's paginated resource listing. Here's a hook that fetches images in pages:

// src/hooks/useCloudinaryImages.js
import { useState, useEffect, useCallback } from 'react';

const CLOUD_NAME = import.meta.env.VITE_CLOUDINARY_CLOUD_NAME;

export function useCloudinaryImages(folder = 'gallery', pageSize = 20) {
  const [images, setImages] = useState([]);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const [nextCursor, setNextCursor] = useState(null);

  const fetchImages = use

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles