How to build a Masonry Image Gallery + Lightbox with Next.js

Build a masonry layout of images that are clickable and open into a beautiful lightbox with the Next.js Image component

Written by Zach

Last Updated: Mar 29, 2024

Have you been searching NPM and Google for a simple, lightweight method for building a masonry layout of photos that can be dynamically opened into an image lightbox?

This post is my solution to this problem after way too many hours of searching.

I was completely surprised at the lack of solutions (and un-maintained packages) here and feel like this blog post is much-needed in the Next.js / React community (at least I needed it!).

Packages we'll be using

This was shocking to me given how common image galleries and lightboxes are on the web... There are all sorts of packages out there for displaying images with React, but most of them are no longer maintained (and break with React 18) or they're just plain ugly (IMO). Luckily, there are a couple of great vanilla JS/CSS libraries that we can use.

For this one, we will be using:

  • react-masonry-css - a simple React component with CSS that will arrange the images in a masonry layout, which is far more complicated than one might think. I sure didn't appreciate the complexity of it until I started reading through all the packages available that attempt to achieve this in a performant way. I chose this package because it seemed to be the simplest, most performant option (with a few tradeoffs mentioned here).
  • lightgallery - a vanilla JS package wrapped in a React component via lightgallery/react

Why two libraries?

When I first started searching for a gallery solution, I wanted one library that could do it all. I quickly found out that all the packages that attempted to do this had not been updated in years or looked antiquated from a UI/UX perspective.

So let's be clear, we're achieving two very distinct things here:

  1. Masonry layout - how we show the initial images in a "grid" like fashion on the webpage (this involves adjusting the spacing between images that don't fit into a perfect grid by default)
  2. Lightbox - what opens those images up in a "lightbox" or "carousel" for viewing them in larger sizes, seeing attribution links, downloading, etc.

Step 1: Prepare the images

One thing that is not talked about enough is how you're prepping your images for rendering.

Fortunately and unfortunately, you have a TON of options when it comes to how you prepare your images. Questions like:

  • Where are the images stored?
  • What CDN to use?
  • Should I optimize the images with Next.js Image component?
  • Should I resize images dynamically with something like Cloudinary? Or store multiple sizes in my image storage?

After researching and thinking about these questions quite a bit, here is my (current - Oct 2022) "recipe":

  • Store all images in a flat directory structure on AWS S3
  • Optimize and resize images with Imgix, setting the "origin" as the S3 bucket where your images are stored.
  • Use a custom imgixLoader with the Next.js <Image /> component to automatically resize your images based on different screen sizes using Imgix (rather than Vercel's built-in optimizer). For more on how this works, please read my post on the Next.js Image component.
  • Use Vercel's CDN to deliver images
    • This is only a convenience thing. Imgix serves images over a CDN too, but since I deploy to Vercel, it just makes more sense to use their built-in CDN solution (not using it would require additional configuration)

If this all makes sense, you can skip down to step 2. If not, I'm going to walk through why I've chosen each of these options.

Image storage on S3

Here is my reasoning for storing images on S3, vs. a service-based image management solution such as Cloudinary.

  1. As of now, AWS S3 is one of the most widely used object storage options on the market.
  2. AWS S3 is the lowest level storage abstraction, which means you are not at the mercy of a company changing the way they handle image storage. It also has a great API and tons of integrations (as you'll see with Imgix)

While I've chosen AWS S3, you could also look at solutions like Google Cloud Object storage, Azure "blob storage", Digital Ocean "Spaces" (an abstraction layer on top of S3), or any other object storage. You can't really go wrong with these solutions, although some may not have as many integrations as S3.

How to store image paths in your database

Something that is also not talked about enough is how to store image paths. Should you store them in your database? If so, do you want the full https URL? Or the relative path?

I believe the best solution is to store relative paths in your database (i.e. /some-image.png). The reasoning here is to make your life easier if you ever choose to change the URL of those images. For example, let's say you start by hosting your images at https://cdn.yoursite.com, but later decide that you want to upgrade to an image optimization/transformation service like Imgix and you don't want to upgrade to get a custom domain. Now, your domain is https://yoursite.imgix.net.

If all your images are stored in the DB with absolute paths, you need to update every image. If you store as relative paths, you just need to change your implementation (usually an environment variable) that controls the path's URL prefix!

Flat or nested file structure?

The more image migrations I've done, the more I'm convinced that a flat directory structure is the best way to store a large group of images. It might have made sense to build out directory structures in the past when we needed to store multiple sizes of a single image, but with all the modern image transformation APIs available (like Cloudinary, Imgix, ImageKit, etc.), it is "best practice" to store the largest version you have of an image and resize it on-the-fly with one of these services. This way, you know that each image only exists with a single path on a single origin (S3). If you ever need to migrate your images, this makes things a lot simpler!

Image optimization and transformation

I've chosen Imgix + Next.js Image for a couple reasons. First, Imgix provides me with both image optimization (sizing, compression) and transformation (cropping, watermarks, etc.). Vercel offers optimization but not transformation (yet). Both are similarly priced (past their free tiers of 1,000 origin images), so this was a no-brainer for me.

I've written extensively about this combination in my post about the Next.js Image component, so I won't repeat myself too much.

When building an image gallery, we have a couple hard requirements and a couple of "nice to have" requirements.

  • Hard requirements
    • Image storage + CDN
  • "Nice to haves"
    • Different sized images for thumbnails vs. main images in the gallery
    • LQIP (low quality image placeholder) for images as they are loading in the masonry layout

These "nice to haves" are enabled through Imgix and a library called lqip-modern as you'll see later in this post.

Step 2: Create the Masonry Grid Layout

If you prefer, you can view the code below in a Github Gist here.

Creating a masonry layout is relatively simple using react-masonry-css. For now, I'm showing hardcoded image metadata, but you'll see later in this post how we can make this dynamic according to the setup we did in step 1.

For the sake of these examples, we will be showing everything on a Next.js Page, which has access to data fetching methods like getStaticProps (which we will later use for some image metadata).

import type { NextPage } from "next";

import Image from "next/image";
import Masonry from "react-masonry-css";

type ImageEnhanced = {
  src: string;
  alt: string;
  width: number;
  height: number;
};

type ImageGalleryPageProps = {
  images: ImageEnhanced[];
};

export const getStaticProps: GetStaticProps<
  ImageGalleryPageProps
> = async () => {
  return {
    props: {
      images: [
        {
          src: "https://some-cdn.someurl.com/image-1.png",
          alt: "test image 1",
          width: 1000,
          height: 800,
        },
        {
          src: "https://some-cdn.someurl.com/image-2.png",
          alt: "test image 2",
          width: 1000,
          height: 800,
        },
      ],
    },
  };
};

// NOTE: I'm using TailwindCSS here
const ImageGalleryPage: NextPage<ImageGalleryPageProps> = ({ images }) => {
  return (
    <Masonry>
      {images.map((image) => (
        <Image
          key={image.src}
          className="hover:opacity-80 cursor-pointer my-2"
          src={image.src}
          alt={image.alt}
          width={image.width}
          height={image.height}
        />
      ))}
    </Masonry>
  );
};

export default ImageGalleryPage;

That's it! Although we've got hardcoded image data right now, this is all that is required to create a Masonry layout.

If you prefer, you can view the code below in a Github Gist here.

Now, we're going to use the lightgallery library, which will allow us to handle clicks on each image and open up the full-size gallery that you saw in the example at the beginning of this post.

This one took me some digging to figure out since lightgallery is first and foremost a Vanilla JS library, but the solution is relatively clean:

import type { NextPage } from "next";
import type { LightGallery } from "lightgallery/lightgallery";

import Image from "next/image";
import Masonry from "react-masonry-css";

// Import lightgallery with a couple nice-to-have plugins
import LightGalleryComponent from "lightgallery/react";
import "lightgallery/css/lightgallery.css";

import lgThumbnail from "lightgallery/plugins/thumbnail";
import "lightgallery/css/lg-thumbnail.css";

import lgZoom from "lightgallery/plugins/zoom";
import "lightgallery/css/lg-zoom.css";

import { useRef } from "react";

type ImageEnhanced = {
  src: string;
  alt: string;
  width: number;
  height: number;
};

type ImageGalleryPageProps = {
  images: ImageEnhanced[];
};

export const getStaticProps: GetStaticProps<
  ImageGalleryPageProps
> = async () => {
  return {
    props: {
      images: [
        {
          src: "https://some-cdn.someurl.com/image-1.png",
          alt: "test image 1",
          width: 1000,
          height: 800,
        },
        {
          src: "https://some-cdn.someurl.com/image-2.png",
          alt: "test image 2",
          width: 1000,
          height: 800,
        },
      ],
    },
  };
};

// NOTE: I'm using TailwindCSS here
const ImageGalleryPage: NextPage<ImageGalleryPageProps> = ({ images }) => {
  // This will allow us to grab the lightgallery JS instance, which will let us programmatically open the lightbox on clicks
  const lightbox = useRef<LightGallery | null>(null);

  return (
    <>
      {/* Lightbox that opens on image clicks */}
      <LightGalleryComponent
        // Once the component initializes, we'll assign the instance to our React ref.  This is used in the onClick() handler of each image in the Masonry layout
        onInit={(ref) => {
          if (ref) {
            lightbox.current = ref.instance;
          }
        }}
        plugins={[lgThumbnail, lgZoom]}
        // These options turn the component into a "controlled" component that let's us determine when to open/close it
        dynamic
        dynamicEl={images.map((image) => ({
          src: image.src,
          thumb: image.src,
          width: image.width.toString(),
          alt: image.alt,
        }))}
      />

      <Masonry className="flex gap-2" columnClassName="bg-clip-padding">
        {images.map((img, idx) => (
          <Image
            key={img.src}
            className="hover:opacity-80 cursor-pointer my-2"
            // Here, we're using the ref to dynamically open the gallery to the exact image that was clicked by the user
            onClick={() => lightbox.current?.openGallery(idx)}
            src={img.src}
            alt={img.alt}
            width={img.width}
            height={img.height}
          />
        ))}
      </Masonry>
    </>
  );
};

export default ImageGalleryPage;

Not too bad! Our last step is to make all of this dynamic.

Step 4: Load your images dynamically

If you prefer, you can view the code below in a Github Gist here.

Up until this point, we've sent a static array of image paths and sizes via getStaticProps, but in a realistic app, this is never what we want! For this final step, I'll assume that you are using AWS S3 as your image origin and Imgix as your optimization/transformation API.

In the code below, I've left plenty of comments to explain what we are doing with each piece. You've already seen most of this, so pay attention to what we are doing in getStaticProps now. With Next.js, getStaticProps will run at build-time, so all your image data will be loaded and ready to go when the user loads the page!

import type { NextPage } from "next";
import type { LightGallery } from "lightgallery/lightgallery";

import Image, { ImageLoaderProps } from "next/image";
import Masonry from "react-masonry-css";

// Import lightgallery with a couple nice-to-have plugins
import LightGalleryComponent from "lightgallery/react";
import "lightgallery/css/lightgallery.css";

import lgThumbnail from "lightgallery/plugins/thumbnail";
import "lightgallery/css/lg-thumbnail.css";

import lgZoom from "lightgallery/plugins/zoom";
import "lightgallery/css/lg-zoom.css";

import { useRef } from "react";

import lqip from "lqip-modern"; // this helps us find the dimensions of a remote image and generates a LQIP (see below)
import axios from "axios"; // we will need to fetch each image by it's URL for the LQIP

// NOTE: we've added some additional properties here since the last code block!
type ImageEnhanced = {
  href: string; // used for the gallery main images
  thumbnailHref: string; // used for the gallery thumbnails

  // Used by the Next.js <Image /> component for our masonry layout (prior to opening the lightbox gallery)
  src: string;
  alt: string;
  width: number;
  height: number;
  blurDataUrl: string; // this is our LQIP (low quality image placeholder) in a data-url format (see - https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs)
};

type ImageGalleryPageProps = {
  images: ImageEnhanced[];
};

/**
 * A helper function that looks at an environment variable you've set to determine
 * what URL to look for the images at.  It also ensures the paths are of a standardized URL format.
 */
function normalizeUrl(src: string) {
  const MEDIA_URL = process.env["NEXT_PUBLIC_MEDIA_URL"]; // set to whatever env you want

  // Returns a URL object
  if (src.slice(0, 4) === "http") {
    return new URL(src);
  } else {
    return new URL(`${MEDIA_URL}/${src[0] === "/" ? src.slice(1) : src}`);
  }
}

/**
 * Our Next.js Image loader
 *
 * This plays a VERY SIGNIFICANT role.
 *
 * It will:
 *
 * * Instruct Vercel (where we deploy) to *skip* its own image optimization and *instead* use Imgix for these duties
 * * Grabs various image sizes and formats from Imgix for the given origin image `src`, which will then be used in the `srcset` of the <Image /> component
 */
function imgixLoader({ src, width, quality }: ImageLoaderProps): string {
  const url = normalizeUrl(src); // see fn above
  const params = url.searchParams;

  // Configure the Imgix params to get the appropriate image sizes and formats
  params.set("auto", params.getAll("auto").join(",") || "format");
  params.set("fit", params.get("fit") || "max");
  params.set("w", params.get("w") || width.toString());

  if (quality) {
    params.set("q", quality.toString());
  }

  return url.href; // The absolute image url (i.e. https://www.someurl.com/image-1.png)
}

export const getStaticProps: GetStaticProps<
  ImageGalleryPageProps
> = async () => {
  /**
   * In reality, you would have a real database that fetched a list of *relative* image paths
   *
   * Below is a mock implementation that returns a hardcoded list of image paths
   */
  const dummyDatabase = () => {
    const imagePaths = ["/image-1.png", "/image-2.png", "/image-3.png"];

    return {
      getImagePaths: () => Promise.resolve(imagePaths),
    };
  };

  // An array of relative image URLs
  const imagePaths = await dummyDatabase.getImagePaths();

  const imagesWithMetadata: ImageEnhanced[] = [];

  /**
   * Loop through each image path and...
   *
   * 1) Grab the width and height of the image (so Next.js <Image /> knows what size to render for the masonry layout)
   * 2) Grab a LQIP (low quality image placeholder) that will show as a placeholder while the full-size image is still loading over the network
   *
   */
  for (const path of imagePaths) {
    // Grab the URL object, which knows *where* to fetch our images (see top of file)
    const url = normalizeUrl(path);

    // To find the dimensions and prepare the LQIP, we need to make a GET request and grab the image in an "arraybuffer" encoding format
    const imageResponse = await axios(url.href, {
      responseType: "arraybuffer",
    });

    // Pass our Axios response data to lqip and let it do its magic!
    const lqipData = await lqip(Buffer.from(imageResponse.data));

    // Generate our thumbnails dynamically with the Imgix API
    const thumbnailUrl = normalizeUrl(path);
    thumbnailUrl.searchParams.set("w", "200"); // all thumbnails will have same width of 200px

    // Add the image with all its metadata to the array
    imagesWithMetadata.push({
      href: url.href,
      hrefThumbnail: thumbnailUrl.href,
      src: url.pathname + url.search, // path + search params
      alt: url.pathname, // I'm being lazy here, you should put something more descriptive in the alt attribute
      width: lqipData.metadata.originalWidth,
      height: lqipData.metadata.originalHeight,
      blurDataUrl: lqipData.metadata.dataURIBase64, // our LQIP
    });
  }

  // Pass our enhanced image objects to the page props!
  return {
    props: {
      images: imagesWithMetadata,
    },
  };
};

// NOTE: I'm using TailwindCSS here
const ImageGalleryPage: NextPage<ImageGalleryPageProps> = ({ images }) => {
  // This will allow us to grab the lightgallery JS instance, which will let us programmatically open the lightbox on clicks
  const lightbox = useRef<LightGallery | null>(null);

  return (
    <>
      {/* Lightbox that opens on image clicks */}
      <LightGalleryComponent
        // Once the component initializes, we'll assign the instance to our React ref.  This is used in the onClick() handler of each image in the Masonry layout
        onInit={(ref) => {
          if (ref) {
            lightbox.current = ref.instance;
          }
        }}
        plugins={[lgThumbnail, lgZoom]}
        // These options turn the component into a "controlled" component that let's us determine when to open/close it
        dynamic
        dynamicEl={images.map((image) => ({
          src: image.href,
          thumb: image.hrefThumbnail,
          width: image.width.toString(),
          alt: image.alt,
        }))}
      />

      <Masonry className="flex gap-2" columnClassName="bg-clip-padding">
        {images.map((image, idx) => (
          <Image
            key={image.src}
            className="hover:opacity-80 cursor-pointer my-2"
            // Here, we're using the ref to dynamically open the gallery to the exact image that was clicked by the user
            onClick={() => lightbox.current?.openGallery(idx)}
            src={image.src}
            alt={image.alt}
            width={image.width}
            height={image.height}
            // This is our LQIP (low quality image placeholder) implementation (Next.js Image makes it super easy!)
            placeholder="blur"
            blurDataURL={image.dataUrl}
          />
        ))}
      </Masonry>
    </>
  );
};

export default ImageGalleryPage;

And that's it! I hope this solution works for you and saves you some time digging through the trenches of the internet :)