Optimizing hundreds of images without sacrificing UX

When we built a photo gallery for Next.js Conf 2022, we had one major constraint: more than 350 high-resolution images in a web app that had to feel fast. Rather than linking externally or settling for a heavy grid, we built an open-source gallery that takes the Next.js Image component to its limits.

The app uses Cloudinary as the CDN and fetches all images at build time via getStaticProps. The display logic is split across three areas: a full-page grid, a modal with both a large image and a thumbnail carousel, and dynamic routes that display one image at near-4K resolution. Each area requests the same image at a different size so the network cost matches the context.

Right-size every image request

The homepage grid fetches images at 720x480px by adding a simple w=720 transformation directive to the Cloudinary URL. The modal's main image jumps to 1280x853px and has the priority prop enabled on next/image so it's preloaded. Thumbnails in the carousel are only 180x120px each.

What's less obvious is how the URL stays the same while the content changes. The grid, the modal, and each individual photo share one index route. Photo navigation is orchestrated through a photoId query parameter, and the shallow prop prevents any data refetch when that param changes. The Visual component's as prop keeps the URL masked, so queries don't leak into the address bar. A page refresh, however, drops the query param and rewrites the URL to a /p/[id] dynamic route, which was pre-generated with an even larger image (2560x1706px) for high-resolution viewing and proper OG cards for social sharing.

Most source photos are 4K. To still let visitors access the original file, the modal contains a download button in the top right that links straight to the unpixelated Cloudinary asset.

Blur placeholders that never make a network request

The loading skeleton was never meant to be a spinning wheel. Each image has an identical, pre-blurred placeholder that appears instantly while the real photo loads. To create it, we fetch the image at a tiny resolution, blur it further, and represent it as base64. Because that base64 string is embedded directly in the HTML markup, there's zero network hopping just to show an image-shaped promise to the user.

Even this placeholder file was minified ahead of time using imagemin to keep overall page weight low.

import imagemin from "imagemin";

import imageminJpegtran from "imagemin-jpegtran";

export async function getBase64ImageUrl(imageUrl: string) {

// fetch image and convert it to base64

const response = await fetch(imageUrl);

const buffer = await response.arrayBuffer();

const minified = await imagemin.buffer(Buffer.from(buffer), {

plugins: [imageminJpegtran()],

});

const base64 = Buffer.from(minified).toString("base64")

return `data:image/jpeg;base64,${base64}`;

}

That same placeholder serves double duty with the Next.js Image component, which uses it via the blurDataURL configuration.

Getting more out of the Image component settings

next/image ships with lazy loading enabled for all images, which was the right baseline for scrolling through hundreds of thumbnails. We only overrode that for the first four images—setting loading to eager so they appear with zero delay. Everything below the fold stays inert until it scrolls into view, protecting both page load time and First Input Delay (FID).

Beyond defaults, the sizes attribute told Next.js how much viewport width each image occupies ahead of time. This prevents over-generating more responsive formats than needed.

We also passed explicit width and height values to prevent layout shift and used the translate3d(0, 0, 0) CSS trick to kick element rendering onto the GPU—particularly useful on Safari for keeping smooth 60fps scrolls in a photo-dense layout.

Animation without breaking state

Framer Motion handled all of the gallery's animations, from the modal entrance to the crossfade between different photos. The onLoadingComplete signal from next/image controls when further modal controls mount, preventing the UI from showing before the main image is visually stable.

On mobile, the gallery is swipeable via the react-swipeable library, giving the native-photo-app feel instead of a barely-tappable modal. Returning to the grid is also state-aware: whenever a user escapes the modal, the browser runs scrollIntoView on the previously selected photo. If someone opened a photo after deep scrolling, they land exactly back on their spot in the grid, not the top of the document.

Accessibility aided by AI — then checked by humans

Every photo in the set uses an AI-generated alt description pulled from an automatic vision model, not an empty string. A script first reads the images from Cloudinary, sends each one to the model API, then writes the returned caption back as metadata. All entries were manually audited afterwards, since auto-generated alt text has known failure modes and should never be trusted blindly for all users.

We chose Headless UI for all non-image components to inherit fully accessible dialog, transition, and keyboard interaction patterns, saving us from writing focus trapping logic from scratch.

The end result holds near-perfect Lighthouse scores across the board, and the codebase is available via the Next.js gallery starter template to clone and use directly.