Optimizing Images in Next.js with BlurHash and Skeleton Loading

Optimizing Images in Next.js with BlurHash

Optimizing images is crucial for improving the user experience on web applications. On my personal blog, vmnog.com, I implemented image optimization techniques to ensure a seamless and visually appealing experience for users. In this article, I’ll guide you through the process of optimizing images in Next.js using BlurHash and skeleton loaders.

The Problem

When loading images on a web page, users often experience a flash of blank space before the image loads. This can be jarring and negatively impact the user experience, especially on slower connections. We aim to solve this by providing placeholders that improve the perceived performance of the site.

Initial Setup

First, set up a new Next.js project and install the necessary dependencies.

npx create-next-app@latest my-next-app
cd my-next-app
npm install blurhash

Step 1: Using the Next.js Image Component

Next.js provides an optimized Image component out of the box, which offers better performance compared to a standard <img> tag.

Example:

import Image from 'next/image';
 
const BasicImage = () => (
  <Image
    src="/images/example.jpg"
    alt="Example Image"
    width={800}
    height={600}
  />
);
 
export default BasicImage;

Using the Next.js Image component provides benefits like automatic resizing, lazy loading, and better performance. However, we can enhance this further by providing visual placeholders.

Step 2: Generating BlurHash Strings

To provide a visual placeholder while the image loads, we use BlurHash. BlurHash generates a blurred, low-resolution placeholder for your images.

You can generate BlurHash strings using the BlurHash website by uploading your images and saving the generated hashes to your database.

Alternatively, you can generate BlurHash strings locally using Node.js. Here’s a script to do that:

blurhash.js:

const { encode } = require('blurhash');
const { createCanvas, loadImage } = require('canvas');
 
async function getBlurHashFromImage(imagePath) {
  const image = await loadImage(imagePath);
  const canvas = createCanvas(image.width, image.height);
  const context = canvas.getContext('2d');
  context.drawImage(image, 0, 0);
  const imageData = context.getImageData(0, 0, image.width, image.height);
  return encode(imageData.data, imageData.width, imageData.height, 4, 4);
}
 
getBlurHashFromImage('path/to/your/image.jpg').then(console.log);

You can run this script using the following command to get the BlurHash in the terminal:

node ./blurhash.js

Save the generated BlurHash string for each image in your database or alongside your image metadata.

Step 3: Creating an Optimized Image Component with BlurHash

Now, let's create a component that uses the BlurHash string as a placeholder.

Add the BlurHash Placeholder:

'use client';
 
import { Blurhash } from "react-blurhash";
import Image from 'next/image';
 
const OptimizedImage = ({ src, alt, width, height, blurHash }) => {
  const [isLoaded, setIsLoaded] = useState(false);
 
  return (
    <div>
        {!isLoaded && (
          <Blurhash
            hash={blurHash}
            width={width}
            height={height}
            resolutionX={32}
            resolutionY={32}
            punch={1}
          />
        )}
 
        <Image
          src={src}
          alt={alt}
          width={width}
          height={height}
          onLoadingComplete={() => setIsLoaded(true)}
        />
    </div>
  );
};
 
export default OptimizedImage;

In this step, we use the BlurHash string as a placeholder while the image is loading. Once the image is loaded, we update the state to show the image and hide the BlurHash.

Step 4: Adding Skeleton Loading

To enhance the user experience further, we can add a skeleton loader that displays until the BlurHash placeholder is ready.

First make sure you have shadcn-ui dependencie installed on your Next.js project, if you don't have it please follow documentation instructions for a detailed guide: shadcn/ui installation guide

Install the shadcn/ui Skeleton Component:

npx shadcn-ui@latest add skeleton

Using the Skeleton Component:

Update the OptimizedImage component to include the skeleton loader:

'use client';
 
import { Blurhash } from "react-blurhash";
import Image from 'next/image';
 
const OptimizedImage = ({ src, alt, width, height, blurHash }) => {
  const [isLoaded, setIsLoaded] = useState(false);
 
  return (
    <div>
        {!isHashLoaded && (
          <Skeleton className="absolute inset-0 w-full h-full" />
        )}
 
        {!isLoaded && !isHashLoaded && (
        <Blurhash
          {/* More code... */}
          onLoad={() => setIsHashLoaded(true)}
        />
 
        {/* More code... */}
    </div>
  );
};
 
export default OptimizedImage;

Step 5: Adding a Delay for Smooth Transition (Optional)

To make the transition from the BlurHash to the actual image smoother, you can add a delay. This ensures that the BlurHash is visible for a short duration, providing a smoother user experience.

Adding a Delay:

import { useEffect } from 'react';
 
const OptimizedImage = ({ src, alt, width, height, blurHash }) => {
  const [isLoaded, setIsLoaded] = useState(false);
  const [isHashLoaded, setIsHashLoaded] = useState(false);
  const [showImage, setShowImage] = useState(false);
 
  // Add a delay to smooth the transition from the BlurHash to the actual image
  useEffect(() => {
    if (isHashLoaded) {
      const timer = setTimeout(() => {
        setShowImage(true);
      }, 200);
      return () => clearTimeout(timer);
    }
  }, [isHashLoaded]);
 
  // Add a delay to smooth the transition from the Skeleton to the Blurhash
  useEffect(() => {
    if (isLoaded) {
      const timer = setTimeout(() => {
        setIsHashLoaded(true);
      }, 200);
      return () => clearTimeout(timer);
    }
  }, [isLoaded]);
 
  // More code below...

Final Result: Full Component Code

Add some inline style to make the Image, Skeleton and Blurhash component be located at the same place on the screen. Start copying the styling used below and change it as you need.

Combining all the steps, here is the full OptimizedImage component:

'use client';
 
import { Blurhash } from "react-blurhash";
import { useState, useEffect } from 'react';
import Image from 'next/image';
import { Skeleton } from "./ui/skeleton";
 
const OptimizedImage = ({ src, alt, width, height, blurHash }) => {
  const [isLoaded, setIsLoaded] = useState(false);
  const [isHashLoaded, setIsHashLoaded] = useState(false);
  const [showImage, setShowImage] = useState(false);
 
  useEffect(() => {
    if (isHashLoaded) {
      const timer = setTimeout(() => {
        setShowImage(true);
      }, 200);
      return () => clearTimeout(timer);
    }
  }, [isHashLoaded]);
 
  useEffect(() => {
    if (isLoaded) {
      const timer = setTimeout(() => {
        setIsHashLoaded(true);
      }, 200);
      return () => clearTimeout(timer);
    }
  }, [isLoaded]);
 
  return (
    <div className="flex justify-center w-full mt-5">
      <div style={{ position: 'relative', width: '100%', maxWidth: width, aspectRatio: `${width} / ${height}` }}>
        {!isHashLoaded && (
          <Skeleton className="absolute inset-0 w-full h-full" />
        )}
 
        {!showImage && blurHash && (
          <Blurhash
            hash={blurHash}
            width="100%"
            height="100%"
            resolutionX={32}
            resolutionY={32}
            punch={1}
            style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
            onLoad={() => setIsHashLoaded(true)}
          />
        )}
 
        <Image
          src={src}
          alt={alt}
          layout="fill"
          objectFit="cover"
          onLoadingComplete={() => setIsLoaded(true)}
          style={{
            visibility: showImage ? 'visible' : 'hidden',
            opacity: showImage ? 1 : 0,
            transition: 'opacity 0.5s ease-in-out',
          }}
        />
      </div>
    </div>
  );
};
 
export default OptimizedImage;

Utilizing the OptimizedImage Component in Other Components/Pages

The OptimizedImage component can be easily integrated into any of your Next.js components or pages to enhance the user experience with optimized image loading. Here are a few examples of how to use the OptimizedImage component in different contexts within your application.

import OptimizedImage from '@/components/OptimizedImage';
 
// More code ...
 
<OptimizedImage
  src={post.coverImage}
  alt={post.title}
  width={800}
  height={600}
  blurHash={post.coverImageBlurHash}
/>
 
// More code ...

Conclusion

By following these steps, you have successfully created an optimized image component in Next.js using BlurHash and skeleton loaders. This approach improves the perceived performance and visual appeal of your website. On my blog, vmnog.com, I’ve implemented these techniques to ensure a smooth and engaging user experience.

Implementing these optimizations ensures that your Next.js application handles images efficiently, providing a seamless and visually appealing experience for users.