Building an Image Gallery with React and CDN Storage

Back to Blog
Lukas Gisder-Dubé
6 min read
cdnreactimage-gallerylazy-loadingtutorial

Building an Image Gallery with React and CDN Storage

Ever built a photo gallery only to watch it crawl under the weight of hundreds of images? That's exactly what happened with a client's portfolio site—200+ photos, 12 seconds to load, and users bouncing before seeing a single shot.

The fix? Lazy loading combined with CDN delivery. The result was a gallery that felt instant, even on slow connections. Let me walk you through building your own.

Why CDN + Lazy Loading Is the Winning Combo

Before we dive into code, let's understand why this combination works so well:

CDN benefits:

  • Images load from the nearest edge server (400+ locations globally)
  • Automatic caching means repeat visitors get near-instant loads
  • Your origin server handles zero image traffic

Lazy loading benefits:

  • Only visible images are loaded initially
  • Users don't download images they'll never scroll to
  • Initial page load is dramatically faster

Together, you get a gallery that loads quickly and stays snappy no matter how many images you add.

Setting Up the Project

Let's build a responsive image gallery with React. We'll use easyCDN for storage and delivery, and native lazy loading for performance.

First, install the dependencies:

bash
npm install @easycdn/server sharp

Uploading Images to the CDN

Before we can display images, we need to get them onto the CDN. Here's a simple script to upload a folder of images, converting them to WebP for better compression:

ts
import { createClient } from '@easycdn/server'
import { readdir, readFile } from 'fs/promises'
import { join, extname } from 'path'
import sharp from 'sharp'

const client = createClient({
  secretKey: process.env.EASYCDN_SECRET_KEY!,
})

async function uploadGalleryImages(sourceDir: string) {
  const files = await readdir(sourceDir)
  const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif']

  const uploadedImages: { name: string; url: string }[] = []

  for (const file of files) {
    const ext = extname(file).toLowerCase()
    if (!imageExtensions.includes(ext)) continue

    const filePath = join(sourceDir, file)
    const buffer = await readFile(filePath)

    try {
      // Convert to WebP locally before uploading
      const optimized = await sharp(buffer)
        .webp({ quality: 85 })
        .toBuffer()

      const result = await client.upload(optimized, {
        fileName: `gallery/${file.replace(/\.[^.]+$/, '.webp')}`,
      })

      uploadedImages.push({
        name: file,
        url: result.asset.url,
      })

      console.log(`Uploaded: ${file}`)
    } catch (error) {
      console.error(`Failed to upload ${file}:`, error)
    }
  }

  return uploadedImages
}

// Run the upload
uploadGalleryImages('./images').then((images) => {
  console.log('Gallery images:', JSON.stringify(images, null, 2))
})

Using sharp to convert to WebP locally reduces file sizes by 25-35% compared to JPEG without noticeable quality loss.

Building the Gallery Component

Now let's create a React component that displays these images with lazy loading:

tsx
import { useState } from 'react'

interface GalleryImage {
  id: string
  url: string
  alt: string
  width: number
  height: number
}

interface ImageGalleryProps {
  images: GalleryImage[]
}

export function ImageGallery({ images }: ImageGalleryProps) {
  const [selectedImage, setSelectedImage] = useState<GalleryImage | null>(null)

  return (
    <>
      <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
        {images.map((image) => (
          <button
            key={image.id}
            onClick={() => setSelectedImage(image)}
            className="relative aspect-square overflow-hidden rounded-lg hover:opacity-90 transition-opacity"
          >
            <img
              src={image.url}
              alt={image.alt}
              loading="lazy"
              className="w-full h-full object-cover"
            />
          </button>
        ))}
      </div>

      {/* Lightbox */}
      {selectedImage && (
        <div
          className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
          onClick={() => setSelectedImage(null)}
        >
          <img
            src={selectedImage.url}
            alt={selectedImage.alt}
            className="max-w-[90vw] max-h-[90vh] object-contain"
          />
        </div>
      )}
    </>
  )
}

The magic here is loading="lazy" on the <img> tags. This native browser feature defers loading images until they're about to enter the viewport—no JavaScript libraries required, and it's supported in all modern browsers.

Adding a Loading Skeleton

For a polished feel, add a loading skeleton that shows while images load:

tsx
import { useState } from 'react'

function GalleryImage({ image, onClick }: {
  image: GalleryImage
  onClick: () => void
}) {
  const [loaded, setLoaded] = useState(false)

  return (
    <button
      onClick={onClick}
      className="relative aspect-square overflow-hidden rounded-lg"
    >
      {/* Skeleton */}
      {!loaded && (
        <div className="absolute inset-0 bg-gray-200 animate-pulse" />
      )}

      <img
        src={image.url}
        alt={image.alt}
        loading="lazy"
        onLoad={() => setLoaded(true)}
        className={`w-full h-full object-cover transition-opacity duration-300 ${
          loaded ? 'opacity-100' : 'opacity-0'
        }`}
      />
    </button>
  )
}

Handling User Uploads

If your gallery accepts user uploads, handle them through an API route:

ts
// app/api/gallery/upload/route.ts
import { createClient } from '@easycdn/server'
import { NextRequest, NextResponse } from 'next/server'
import sharp from 'sharp'

const client = createClient({
  secretKey: process.env.EASYCDN_SECRET_KEY!,
})

export async function POST(request: NextRequest) {
  const formData = await request.formData()
  const file = formData.get('image') as File

  if (!file) {
    return NextResponse.json({ error: 'No image provided' }, { status: 400 })
  }

  // Validate file type
  if (!file.type.startsWith('image/')) {
    return NextResponse.json({ error: 'Invalid file type' }, { status: 400 })
  }

  // Validate file size (10MB limit)
  const MAX_SIZE = 10 * 1024 * 1024
  if (file.size > MAX_SIZE) {
    return NextResponse.json({ error: 'File too large (max 10MB)' }, { status: 400 })
  }

  const buffer = Buffer.from(await file.arrayBuffer())

  try {
    // Optimize image before uploading
    const optimized = await sharp(buffer)
      .resize(1920, null, { withoutEnlargement: true })
      .webp({ quality: 85 })
      .toBuffer()

    const result = await client.upload(optimized, {
      fileName: `gallery/${Date.now()}.webp`,
    })

    return NextResponse.json({
      url: result.asset.url,
      name: result.asset.name,
    })
  } catch (error) {
    console.error('Upload failed:', error)
    return NextResponse.json({ error: 'Upload failed' }, { status: 500 })
  }
}

Performance Tips

Here are some quick wins to make your gallery even faster:

1. Use appropriate image sizes. Don't serve 4000px images for thumbnails. Use sharp to resize images before uploading.

2. Preload the first few images. For above-the-fold content:

html
<link rel="preload" as="image" href="https://cdn.easycdn.co/gallery/hero.webp" />

3. Use blur placeholders. Generate tiny base64 placeholders to show while full images load.

4. Implement infinite scroll. For large galleries, load images in batches as users scroll.

Wrapping Up

A performant image gallery comes down to two things: serving images from a CDN for fast delivery, and lazy loading to avoid unnecessary downloads. Combined with React's component model, you can build galleries that handle thousands of images without breaking a sweat.

The best part? Once your images are on a CDN, you never think about image delivery again. Upload once, serve globally forever.

Got a gallery project in mind? easyCDN handles the storage and global delivery so you can focus on building. The free tier is perfect for getting started—upload a few images, see how it works, and scale up when you're ready.

Ready to host your assets?

Create your free account and start serving your assets in minutes.

No credit card required • Get started in under 2 minutes