DEV Community

Cover image for Next.js Image Loading with Blur Effect: A Step-by-Step Guide
Daniel Pôrto Nuñez
Daniel Pôrto Nuñez

Posted on

Next.js Image Loading with Blur Effect: A Step-by-Step Guide

During the development portfolio's front end, I came across a performance problem that involved image loading.

If you're already using Next.js, you've likely discovered that the optimal method for rendering images in the app is by utilizing the Image component provided by Next.js. The advantages of this component are significant, including preventing layout blinking during or after loading, intelligent resizing to decrease image loading times, and more.

In this article, I'll delve into implementing the loader effect using blurred data, a feature native to the Image component. However, it can be a bit tricky to utilize.


Problem Statement

I encounter a challenge when dealing with heavy images on a webpage, especially in cases of low internet speed. I aim to avoid rendering a blank space while the client is downloading the image file. Instead, I prefer to display a loader, particularly a blurred one, during this process.

problem gif


How to test

If you're using Chrome or any Chromium-based browser, you can simulate slow internet connections by accessing the "Network" tab in the developer tools. This feature is likely available in most mainstream browsers and can help replicate conditions of slow internet speeds for testing purposes.

How to simulate network issues


Splitting the solution

The best way to think about how to solve this problem is to split the solution into 2 sides

  • static image render
  • dynamic image render

The way that Next.js handles these cases is significantly different, let's start with the simplest, static image render.


Static image

See the commit changes

In this case, Next.js takes care of all the hard work for us. During build time, Next.js will recognize that we're importing the image in a component/page and generate the blurred version automatically. Our only task here is to provide the placeholder prop with the value blur.

import Image from 'next/image'
import image1 from '../../../../public/images/photo-1.jpeg'
import image2 from '../../../../public/images/photo-2.jpeg'
import image3 from '../../../../public/images/photo-3.jpeg'

export default function Page() {
  return (
    <>
      <Image className="image-style" src={image1} placeholder="blur" alt="" />
      <Image className="image-style" src={image2} placeholder="blur" alt="" />
      <Image className="image-style" src={image3} placeholder="blur" alt="" />
    </>
  )
}

Enter fullscreen mode Exit fullscreen mode

Dynamic images

Now we face a challenging scenario because Next.js, during build time, cannot anticipate which images we will render and generate a blurred version accordingly. Therefore, the responsibility of generating a blurred version is addressed to us.

Setup

Most guides and tutorials about blurred loading, including Next.js documentation recommendations, suggest using a library called plaiceholder. However, I do not recommend using that library, considering it is no longer maintained and has caused build problems in the Vercel environment.

To generate the blurred version, we will utilize the sharp library. This library functions by taking a Buffer as input and returning a new buffer containing a resized version of the image. From the Buffer of the resized version, we'll generate a base64 format, which is supported by next/image. We will delve deeper into the functionality of this library later during the implementation.

install dependencies

npm install sharp
Enter fullscreen mode Exit fullscreen mode

Approaches

With sharp installed, the next step is to obtain a Buffer containing image data, pass it to sharp, and handle the result with the new Buffer. However, obtaining a Buffer from a local image differs from obtaining one from a remote image. Therefore, we will split our solution into two parts: handling local images and handling remote images.

Local image

See the commit changes

Our goal is to create a function that receives an image path. In our implementation, this path exactly matches how you would pass it directly to the Image component (you should not include public or any relative path). The function should return a base64 data obtained by converting the Buffer of the resized version generated by sharp. Below is the implementation of this function:

For more information about how to properly read files on server side, access Vercel tutorial How to Read files in Vercel Function

'use server'
import sharp from 'sharp'
import { promises as fs } from 'fs'
import path from 'path'

function bufferToBase64(buffer: Buffer): string {
  return `data:image/png;base64,${buffer.toString('base64')}`
}

async function getFileBufferLocal(filepath: string) {
  // filepath is file addess exactly how is used in Image component (/ = public/)
  const realFilepath = path.join(process.cwd(), 'public', filepath)
  return fs.readFile(realFilepath)
}

export async function getPlaceholderImage(filepath: string) {
  try {
    const originalBuffer = await getFileBufferLocal(filepath)
    const resizedBuffer = await sharp(originalBuffer).resize(20).toBuffer()
    return {
      src: filepath,
      placeholder: bufferToBase64(resizedBuffer),
    }
  } catch {
    return {
      src: filepath,
      placeholder:
        'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOsa2yqBwAFCAICLICSyQAAAABJRU5ErkJggg==',
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

explanations:

  • bufferToBase64 function: This function takes a Buffer object as input and returns a base64-encoded string with a data URI prefix (data:image/png;base64,). It achieves this by converting the Buffer to a base64-encoded string using buffer.toString('base64').

  • getFileBufferLocal function: This async function takes a file path (filepath) as input. It constructs the real file path by joining the current working directory (process.cwd()) with the public directory and the provided file path. Then, it reads the file asynchronously using fs.readFile() and returns a promise that resolves to the file buffer.

  • getPlaceholderImage function: This async function takes a file path (filepath) as input. It attempts to retrieve the file buffer using getFileBufferLocal(). Then, it uses sharp to resize the image to a width of 20 pixels. The resized image buffer is then converted to base64 format using bufferToBase64(). The function returns an object containing the original file path (src) and the base64-encoded placeholder image (placeholder). In case of an error (e.g., if the file cannot be read or resized), it returns a default placeholder image encoded in base64.

On Next Page

In the image component, beyond the placeholder prop, now we need to pass blurDataURL, which receives a base64.

import { getPlaceholderImage } from '@/utils/images'
import Image from 'next/image'

const images = [
  '/images/photo-4.jpeg',
  '/images/photo-5.jpeg',
  '/images/photo-6.webp',
]

export default async function Page() {
  const imageWithPlaceholder = await Promise.all(
    images.map(async (src) => {
      const imageWithPlaceholder = await getPlaceholderImage(src)
      return imageWithPlaceholder
    }),
  )
  return imageWithPlaceholder.map((image) => (
    <Image
      className="image-grid"
      key={image.src}
      src={image.src}
      width={600}
      height={600}
      placeholder="blur"
      blurDataURL={image.placeholder}
      alt="Image"
    />{% embed 
 %}  ))
}
Enter fullscreen mode Exit fullscreen mode

Remote image

See the commit changes

Given that you've already configured your next.config to support image rendering from remote hosts, the only thing left to implement is retrieving a buffer from a remote URL. For this purpose, we have the following implementation.

'use server'
import sharp from 'sharp'
import { promises as fs } from 'fs'
import path from 'path'

function bufferToBase64(buffer: Buffer): string {
  return `data:image/png;base64,${buffer.toString('base64')}`
}

async function getFileBufferLocal(filepath: string) {
  // filepath is file addess exactly how is used in Image component (/ = public/)
  const realFilepath = path.join(process.cwd(), 'public', filepath)
  return fs.readFile(realFilepath)
}

async function getFileBufferRemote(url: string) {
  const response = await fetch(url)
  return Buffer.from(await response.arrayBuffer())
}

function getFileBuffer(src: string) {
  const isRemote = src.startsWith('http')
  return isRemote ? getFileBufferRemote(src) : getFileBufferLocal(src)
}

export async function getPlaceholderImage(filepath: string) {
  try {
    const originalBuffer = await getFileBuffer(filepath)
    const resizedBuffer = await sharp(originalBuffer).resize(20).toBuffer()
    return {
      src: filepath,
      placeholder: bufferToBase64(resizedBuffer),
    }
  } catch {
    return {
      src: filepath,
      placeholder:
        'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOsa2yqBwAFCAICLICSyQAAAABJRU5ErkJggg==',
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Differences

  • Functionality for Remote Files: In the above code, a new function getFileBufferRemote is added to retrieve a buffer from a remote URL using the fetch API. This function fetches the remote file and converts the response to a buffer using arrayBuffer().

  • Unified Buffer Retrieval: The getFileBuffer function is modified to determine whether the file is local or remote based on the URL. If the URL starts with http, it is considered a remote file, and getFileBufferRemote is called. Otherwise, getFileBufferLocal is called to handle local files.

These changes enable the getPlaceholderImage function to handle both local and remote file paths seamlessly, providing a consistent interface for generating placeholder images.

On Next Page

import { getPlaceholderImage } from '@/utils/images'
import Image from 'next/image'

const images = [
  'https://images.unsplash.com/photo-1705615791178-d32cc2cdcd9c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTcxMjM2NzUwNA&ixlib=rb-4.0.3&q=80&w=1080',
  'https://images.unsplash.com/photo-1498751041763-40284fe1eb66?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTcxMjM2NzUxNg&ixlib=rb-4.0.3&q=80&w=1080',
  'https://images.unsplash.com/photo-1709589865176-7c6ede164354?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTcxMjM2NzUyNg&ixlib=rb-4.0.3&q=80&w=1080',
]

export default async function Page() {
  const imageWithPlaceholder = await Promise.all(
    images.map(async (src) => {
      const imageWithPlaceholder = await getPlaceholderImage(src)
      return imageWithPlaceholder
    }),
  )

  return imageWithPlaceholder.map((image) => (
    <Image
      key={image.src}
      className="image-grid"
      src={image.src}
      width={600}
      height={600}
      placeholder="blur"
      blurDataURL={image.placeholder}
      alt="Image"
    />
  ))
}
Enter fullscreen mode Exit fullscreen mode

Result

solution gif

Resources

source code: https://github.com/dpnunez/nextjs-image-loading

live example: https://nextjs-image-loading.vercel.app/

Top comments (1)

Collapse
 
fazliddin04 profile image
Fazliddin

Hey, that's a good article! Why are the source code and demo not available?