Handling 500+ Images in a Gallery with Lazy Loading in Next.js 15

A practical guide to building a performant, bandwidth-friendly image gallery using Payload CMS, lazy loading, and native APIs in Next.js 15.

·Matija Žiberna·
Handling 500+ Images in a Gallery with Lazy Loading in Next.js 15

Have you ever tried loading a gallery with 300+ images and watched your browser crawl—or worse, burned through your database quota in a few refreshes? That’s exactly what happened to me. I'm using Neon on Vercel, and bandwidth took an immediate hit after just a couple of reloads.

The libraries are hit or miss—especially with React 19 and Vercel. Between hydration bugs and inconsistent behavior, I ran into all kinds of issues trying to get things working smoothly.

So, I ended up building a custom solution using native APIs and Shadcn’s carousel. No unnecessary dependencies, no over-fetching, just a simple gallery that loads efficiently and works the way it should.

View the complete working example on GitHub →

The Challenge: Performance vs User Experience

When you have a large image collection, you face a classic dilemma. Load everything at once and watch your performance metrics tank, or implement complex pagination that breaks the smooth browsing experience users expect.

Here's what we needed to solve:

  • Carousel: Load images in small batches to keep initial load fast
  • Lightbox: Show total count and allow navigation through ALL images
  • Performance: Only fetch images when actually needed
  • Compatibility: Work flawlessly with Next.js 15 and React 19
  • No Dependencies: Use only native browser APIs to avoid library conflicts

Our Technical Approach

We went with a hybrid solution that gives us the best of both worlds:

For the Carousel: Batch loading using Intersection Observer API to automatically fetch more images as users scroll through the gallery.

For the Lightbox: Independent on-demand loading that can access any image by ID, regardless of whether it's been loaded in the carousel.

Image Optimization: Smart size selection using Payload CMS's built-in image transformations.

Technical Constraints and Decisions

Our setup included some specific constraints that shaped our approach:

  • Payload CMS: For content management and image handling
  • Next.js 15: With the new App Router and server components
  • React 19: Latest version with its quirks around third-party libraries
  • Shadcn UI: For beautiful, accessible carousel components
  • Native APIs Only: No external gallery libraries to avoid compatibility issues

In this example, I’m using Payload CMS—but you can use anything. The key part is that I get a list of image IDs from the CMS and can fetch the image data for each one individually.

Step 1: Setting Up the Data Structure

First, we need to extract image IDs from our CMS data. In Payload CMS, relationships can be either IDs or populated objects, so we use a utility to handle both cases.

This function ensures that we have IDs of all images. It's for type checking mostly.

If you're not using Payload, you can skip this—it’s mainly for type safety and normalization.

// Extract image IDs from the images prop (memoized to prevent infinite re-renders)
const imageIds = useMemo(() => extractIds(images || []), [images]);

This utility function handles the relationship data and gives us a clean array of image IDs. The useMemo is crucial here to prevent infinite re-renders that would spam your API.

The carousel loads images in small batches to keep the initial load fast. We start with just 4-8 images and load more as needed.

const BATCH_SIZE = 4;
const [loadedImages, setLoadedImages] = useState<LoadedImage[]>([]);
const [currentIndex, setCurrentIndex] = useState(BATCH_SIZE);
const [hasMore, setHasMore] = useState(() => imageIds.length > BATCH_SIZE);

The initial load fetches the first batch, and we track how many images we've loaded so far. This keeps your initial page load lightning fast.

Step 3: Intersection Observer for Automatic Loading

Instead of forcing users to click "Load More" buttons, we use the Intersection Observer API to automatically fetch new images when they scroll near the end of the loaded set.

useEffect(() => {
  const observer = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting && hasMore && !isLoading) {
        loadMoreImages();
      }
    },
    { 
      threshold: 0.1,
      rootMargin: '50px'
    }
  );

  const currentLoaderRef = loaderRef.current;
  if (currentLoaderRef && hasMore) {
    observer.observe(currentLoaderRef);
  }

  return () => {
    if (currentLoaderRef) {
      observer.unobserve(currentLoaderRef);
    }
  };
}, [hasMore, isLoading, loadMoreImages]);

This creates a smooth, infinite scroll experience. When the user gets within 50px of the loader element, it automatically fetches the next batch.

Step 4: Smart Image Size Selection

Payload CMS lets you define custom image sizes when setting up your media collection. When an image is uploaded, Payload automatically compresses and resizes it according to your configuration. This allows you to serve the right image size for the right context—saving bandwidth without compromising quality.

For example, in a carousel, we don’t need full-resolution images. A smaller, lighter version (like a card or thumbnail size) is more than enough. But when a user opens an image in a lightbox, we can fetch a higher-resolution version like tablet or desktop.

The image size definitions are part of your collection setup in Payload. I cover that in a separate article—let me know if you’re interested in learning how to create an optimal media config for a responsive site.

// Get image source for carousel (using card size: 640x480)
const getCarouselImageSrc = (photo: LoadedImage) => {
  return photo.sizes?.card?.url || photo.sizes?.thumbnail?.url || photo.src;
};

// Get image source for lightbox (using tablet size: 1024x?)
const getLightboxImageSrc = (photo: LoadedImage) => {
  return photo.sizes?.tablet?.url || photo.sizes?.card?.url || photo.src;
};

The carousel uses smaller "card" sized images (640x480) for fast loading, while the lightbox uses higher-quality "tablet" sized images (1024px width) for detailed viewing.

Step 5: Independent Lightbox with On-Demand Loading

Here's where it gets interesting. The lightbox needs to work independently from the carousel, showing the total count and allowing navigation through ALL images, even ones not yet loaded.

// Get current lightbox image, loading it if necessary
const getCurrentLightboxImage = useCallback(async (index: number): Promise<LoadedImage | null> => {
  if (index < 0 || index >= imageIds.length) return null;
  
  const imageId = imageIds[index];
  
  // Check if image is already in cache
  if (lightboxImageCache.has(imageId)) {
    return lightboxImageCache.get(imageId)!;
  }
  
  // Check if image is in loaded images (from carousel)
  const loadedImage = loadedImages.find(img => img.id === imageId);
  if (loadedImage) {
    setLightboxImageCache(prev => new Map(prev).set(imageId, loadedImage));
    return loadedImage;
  }
  
  // Load the image on demand
  setLightboxLoading(true);
  const newImage = await loadSingleImage(imageId);
  setLightboxLoading(false);
  
  if (newImage) {
    setLightboxImageCache(prev => new Map(prev).set(imageId, newImage));
    return newImage;
  }
  
  return null;
}, [imageIds, lightboxImageCache, loadedImages, loadSingleImage]);

This function creates a smart caching system. It first checks if the image is already cached, then looks in the carousel's loaded images, and finally fetches it on demand if needed.

Step 6: Payload CMS Integration

The beauty of this approach is that it works with any backend, but here's how we integrated it with Payload CMS:

export const getMediaImages = async (images: number[]): Promise<PaginatedDocs<Media>> => {
  const payload = await getPayloadClient()
  const result = await payload.find({
    collection: "media",
    where: { id: { in: images } },
  })
  return result
}

This function fetches multiple images by their IDs. You could easily adapt this to work with any REST API, GraphQL endpoint, or database query.

Step 7: Handling Navigation and User Experience

The lightbox navigation now works with the total image count, not just loaded images:

const goToPrevious = () => {
  setSelectedIndex((prevIndex) => (prevIndex === 0 ? imageIds.length - 1 : prevIndex - 1));
};

const goToNext = () => {
  setSelectedIndex((prevIndex) => (prevIndex === imageIds.length - 1 ? 0 : prevIndex + 1));
};

Users can navigate through all images seamlessly. The counter shows the real total (like "5 / 247"), and images load on demand with a smooth loading spinner.

Step 8: Performance Optimizations

We implemented several performance optimizations:

  1. Memoized ID extraction to prevent infinite re-renders
  2. Image caching in the lightbox to avoid refetching
  3. Smart fallback chain for image sizes
  4. Lazy loading with intersection observer
  5. Responsive image sizing with proper sizes attributes
const getSizes = () => {
  return "(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw";
};

This tells the browser exactly what size image to fetch based on the viewport, saving bandwidth on mobile devices.

The Complete Component Structure

Here's how all the pieces fit together:

  1. Data Layer: Extract image IDs and manage state
  2. Carousel: Load images in batches with intersection observer
  3. Lightbox: Independent image loading with caching
  4. UI Components: SHADCN carousel with custom lightbox
  5. Performance: Smart image sizing and lazy loading

We built a gallery component that solves the real-world problem of displaying large image collections without sacrificing performance. Users get a smooth browsing experience, your server doesn't get hammered with massive requests, and mobile users don't burn through their data.

The key insights:

  • Batch loading keeps initial loads fast
  • Intersection Observer provides smooth auto-loading
  • Independent lightbox gives full navigation without waiting
  • Smart caching prevents unnecessary API calls
  • Native APIs ensure compatibility with the latest React versions

This approach works whether you have 50 images or 500. The performance stays consistent because you're only loading what users actually want to see.

Want more performance tips and React deep dives? Subscribe to get notified when I publish new guides like this. I share practical solutions to real development challenges every week.

Having trouble implementing this in your project? Drop me a line – I love helping developers solve tricky performance problems

10
Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in