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

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.
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.
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
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'],
},
}
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
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.
Image gallery
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
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
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
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>
)
}