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:
npm install @easycdn/server sharpUploading 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:
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:
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:
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:
// 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:
<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.
