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?

Sign up, connect your bucket, and start serving your assets.

14-day free trial — no credit card required • Flat $9/month • Bring your own bucket