Progressive Image Loading with NextJS

How to implement progressive image loading with NextJS Image component (Includes App Router example)

Progressive Image Loading with NextJS

Many front-end frameworks such as Gatsby come with progressive image loading out of the box. In this article, we look at how to achieve this with NextJS, using next/image and plaiceholder libraries.

Full code for examples can be found here on Github.

Examples in this article are based mostly on Pages Router, but if you are interested in the App Router version, see this repo instead.

💡
Note: The following article has been updated to reflect changes made to next/image component in Next 13. Read more at https://nextjs.org/docs/messages/next-image-upgrade-to-13.

What is progressive image loading?

Progressive image loading is a strategy where the website displays a low resolution or placeholder image until the actual image is loaded and displayed. Compared to staring at a blank space, this improves user experience by providing awareness on incoming images.

0:00
/
How it looks like (Photos by shun idota on Unsplash) - (simulated on a throttled network)

Install dependencies

sharp

Using Next.js' built-in Image Optimization requires sharp as a dependency.

plaiceholder

When loading remote images, next/image requires blurDataURL if using placeholder="blur".

The following examples use Typescript & TailwindCSS. If you would like to follow along, grab this starter template which comes with everything you'll need.

Install the following dependencies:

yarn install sharp plaiceholder

Statically imported image files

import type { NextPage } from 'next'
import Image from 'next/image'
import imageOne from 'public/image-one.jpeg'

const Home: NextPage = () => {
  return (
    <div className="relative w-[500px] h-[300px]">
      <Image
        src={imageOne}
        placeholder="blur"
        fill
        sizes="100vw"
        style={{
          objectFit: 'cover',
        }}
        alt="Static import image"
      />
    </div>
  )
}

export default Home
index.tsx

For statically imported images, next/image will automatically generate the blurDataURL. Since we are using the fill prop, wrap the component in a relative positioned <div> and assign it width and height properties.

Remote images

Remote images can be either an absolute external URL, or an internal path. When using an external URL, you must add it to domains in next.config.js.

module.exports = {
  images: {
    domains: ['assets.example.com'],
  },
}
next.config.js

Generate the image placeholder data at build time with getStaticProps:

import type { NextPage, InferGetStaticPropsType } from 'next'
import Image from 'next/image'
import { getPlaiceholder } from 'plaiceholder'

const Home: NextPage<InferGetStaticPropsType<typeof getStaticProps>> = ({
  remoteImageProps,
}) => {
  return (
    <div className="relative w-[500px] h-[300px]">
      <Image
        src={remoteImageProps.img.src}
        placeholder="blur"
        blurDataURL={remoteImageProps.base64}
        fill
        sizes="100vw"
        style={{
          objectFit: 'cover',
        }}
        alt="Remote Image"
      />
    </div>
  )
}

export const getStaticProps = async () => {
  // Remote Image (from external url or relative url like `/my-image.jpg`)
  const remoteImageProps = await getPlaiceholder(
    'https://source.unsplash.com/78gDPe4WWGE'
  )

  return {
    props: {
      remoteImageProps,
    },
  }
}

export default Home
index.tsx
💡
Tip: You can throttle your network speed in your browser to view the progressive effect better. Check out Chrome Devtools - Throttling.

Real world examples

How about data fetched from an API? The approach is the similar; process the fetched data and generate placeholder data for the images at build time.

import Image from 'next/image'
import { getPlaiceholder } from 'plaiceholder'
import type { InferGetStaticPropsType, NextPage } from 'next'

const GalleryPage: NextPage<InferGetStaticPropsType<typeof getStaticProps>> = ({
  imagesProps,
}) => {
  return (
    <div className="my-3 px-3">
      <main className="grid grid-cols-3 gap-3">
        {imagesProps.map((imagesProp) => (
          <div className="relative h-[300px]" key={imagesProp.img.src}>
            <Image
              src={imagesProp.img.src}
              placeholder="blur"
              blurDataURL={imagesProp.base64}
              layout="fill"
              sizes="100vw"
              style={{
                objectFit: 'cover',
              }}
              alt={imagesProp.img.src}
            />
          </div>
        ))}
      </main>
    </div>
  )
}

export const getStaticProps = async () => {
  // Fetch images
  const raw = await fetch('https://shibe.online/api/shibes?count=10')
  const data: string[] = await raw.json()

  // Process images
  const imagesProps = await Promise.all(
    data.map(async (src) => await getPlaiceholder(src))
  )

  return {
    props: {
      imagesProps,
    },
  }
}

export default GalleryPage
gallery/index.tsx
0:00
/
Loading an image gallery (Photos by shibe.online API) - (simulated on a throttled network)

Blog posts

import ghostClient from 'utils/ghost-client'
import Image from 'next/image'
import { getPlaiceholder } from 'plaiceholder'
import type { GetStaticProps, NextPage } from 'next'
import type { IGetPlaiceholderReturn } from 'plaiceholder'
import type { PostOrPage } from '@tryghost/content-api'

interface PostOrPageImgPlaiceholder extends PostOrPage {
  featureImageProps: IGetPlaiceholderReturn | null
}
interface BlogPageProps {
  posts: PostOrPageImgPlaiceholder[]
}

const BlogPage: NextPage<BlogPageProps> = ({ posts }) => {
  return (
    <div className="my-3 px-3">
      <main className="grid grid-cols-3 gap-x-3 gap-y-6">
        {posts.map((post) => (
          <article key={post.id}>
            {post.feature_image && post.featureImageProps && (
              <div className="relative h-[300px]">
                <Image
                  src={post.featureImageProps.img.src}
                  placeholder="blur"
                  blurDataURL={post.featureImageProps.base64}
                  fill
                  sizes="100vw"
                  style={{
                    objectFit: 'cover',
                  }}
                  alt={post.title}
                />
              </div>
            )}
            <h1 className="text-lg font-medium mb-1 mt-3">{post.title}</h1>
          </article>
        ))}
      </main>
    </div>
  )
}

export const getStaticProps: GetStaticProps = async () => {
  // Fetch posts
  const rawPosts = await ghostClient.posts.browse({
    limit: 'all',
  })

  if (!rawPosts) {
    return {
      notFound: true,
    }
  }

  // Process images
  const posts = await Promise.all(
    rawPosts.map(async (post) => {
      return {
        featureImageProps: post.feature_image
          ? await getPlaiceholder(post.feature_image)
          : null,
        ...post,
      } as PostOrPageImgPlaiceholder
    })
  )

  return {
    props: {
      posts,
    },
  }
}

export default BlogPage
blog/index.tsx
0:00
/
Loading images from a Ghost blog (simulated on a throttled network)

Contentful API - Images in Rich Text Field

Here's how to render images from rich text fields in Contentful using next/image.

After fetching the data, loop through your rich text field to generate the placeholder data, and render the field with documentToReactComponents(). Create a custom renderer for BLOCKS.EMBEDDED_ASSET node type using renderOptions and use the placeholder data.

import contentfulClient from 'utils/contentful-client'
import { documentToReactComponents } from '@contentful/rich-text-react-renderer'
import { BLOCKS } from '@contentful/rich-text-types'
import Image from 'next/image'
import type { InferGetStaticPropsType, NextPage } from 'next'
import type { Options } from '@contentful/rich-text-react-renderer'
import type { Document } from '@contentful/rich-text-types'
import { getPlaiceholder } from 'plaiceholder'

const renderOptions: Options = {
  renderNode: {
    [BLOCKS.EMBEDDED_ASSET]: (node) => {
      const imgBlurData = node.data.target.fields.file.imgBlurData

      return (
        <div className="relative">
          <Image
            {...imgBlurData.img}
            alt={node.data.target.fields.description}
            placeholder="blur"
            blurDataURL={imgBlurData.base64}
          />
        </div>
      )
    },
  },
}

interface PageFields {
  title: string
  slug: string
  richTextBody: Document
}

const ContentfulPage: NextPage<
  InferGetStaticPropsType<typeof getStaticProps>
> = ({ page }) => {
  return (
    <main className="container mx-auto">
      <article>
        <h1 className="text-2xl my-10">{page.fields.title}</h1>
        <div>
          {documentToReactComponents(page.fields.richTextBody, renderOptions)}
        </div>
      </article>
    </main>
  )
}

export const getStaticProps = async () => {
  const page = await contentfulClient.getEntry<PageFields>(
    '53yJ05JcurUI5p9NA1FCrh'
  )

  // Add blur placeholder data to rich text body for images
  for (const node of page.fields.richTextBody.content) {
    if (node.nodeType === BLOCKS.EMBEDDED_ASSET) {
      node.data.target.fields.file.imgBlurData = await getPlaiceholder(
        `https:${node.data.target.fields.file.url}`
      )
    }
  }

  return {
    props: {
      page,
    },
  }
}

export default ContentfulPage
components/RichTextContent.tsx

Using App Router

import Image from 'next/image'
import { getPlaiceholder } from 'plaiceholder'

const fetchImages = async () => {
  // Fetch images
  const raw = await fetch('https://shibe.online/api/shibes?count=10')
  const data: string[] = await raw.json()

  // Process images
  return await Promise.all(data.map(async (src) => await getPlaiceholder(src)))
}

export default async function Gallery() {
  const plaiceholders = await fetchImages()

  return (
    <div className="my-3 px-3">
      <h1 className="text-2xl mb-6">Loading from a gallery</h1>
      <main className="grid grid-cols-3 gap-3">
        {plaiceholders.map((plaiceholder) => (
          <div className="relative h-[300px]" key={plaiceholder.img.src}>
            <Image
              src={plaiceholder.img.src}
              placeholder="blur"
              blurDataURL={plaiceholder.base64}
              alt={plaiceholder.img.src}
              fill
              sizes="100vw"
              style={{
                objectFit: 'cover',
              }}
            />
          </div>
        ))}
      </main>
    </div>
  )
}
app/gallery/page.tsx