skies.dev

Blur Placeholder Images with Next.js and mdx-bundler

3 min read

Let's build a blur-up image flow with Next.js and mdx-bundler.

The implementation has two parts:

  1. Extract the image metadata that next/image needs.
  2. Add a small visual transition so the blurred placeholder fades into the final image instead of snapping immediately to sharpness.

Generate the Image Metadata

mdx-bundler already gives us access to the markdown image syntax, so the source content can stay simple.

![Blue Ridge Mountains](/images/mountains.jpg)

To make that image work with next/image, we need width, height, placeholder="blur", and blurDataURL. Lazar Nikolov’s rehypeImage approach is a good fit here because it can inspect the image and attach the props during MDX compilation.

Install the helper dependencies first:

yarn add -D plaiceholder util unist-util-visit image-size

Then register the rehype plugin when bundling MDX.

import {bundleMDX} from 'mdx-bundler';
import rehypeImage from '@lib/imageMetadata';

export async function prepareMDX(source: string) {
  return bundleMDX({
    source,
    cwd: process.cwd(),
    esbuildOptions(options) {
      options.target = 'esnext';
      return options;
    },
    xdmOptions(options) {
      options.remarkPlugins = [...(options.remarkPlugins ?? [])];
      options.rehypePlugins = [...(options.rehypePlugins ?? []), rehypeImage];
      return options;
    },
  });
}

At this point the MDX compiler can attach the properties that next/image needs, including the low-resolution placeholder data.

Render the Image with Next.js

The next step is to forward those generated props into a custom MDX image component.

import React, {HTMLProps} from 'react';
import {getMDXComponent} from 'mdx-bundler/client';
import Image, {ImageProps} from 'next/image';

function Img(props: HTMLProps<HTMLImageElement>) {
  return <Image {...(props as ImageProps)} layout={'responsive'} />;
}

export default function BlogPost({code}: {code: string}) {
  const Component = React.useMemo(() => getMDXComponent(code), [code]);

  return (
    <article>
      <Component components={{img: Img}} />
    </article>
  );
}

That is enough to get the blur placeholder behavior working. The markdown image becomes a responsive Next.js image with metadata and a blurred preview.

Make the Blur Transition Feel Smooth

next/image gives us the placeholder, but the default handoff from blurred to sharp is abrupt. To smooth it out, I use a tiny CSS transition and let the image component toggle the loaded state.

.img-blur {
  filter: blur(20px);
  transform: scale(1.02);
}

.img-loaded {
  filter: blur(0);
  transform: scale(1);
  transition: filter 300ms ease, transform 300ms ease;
}

Then update the image component to switch classes when the underlying image finishes loading.

import {HTMLProps, useState} from 'react';
import Image, {ImageProps} from 'next/image';

function Img(props: HTMLProps<HTMLImageElement>) {
  const [loaded, setLoaded] = useState(false);

  return (
    <Image
      {...(props as ImageProps)}
      layout={'responsive'}
      className={loaded ? 'img-loaded' : 'img-blur'}
      onLoadingComplete={() => setLoaded(true)}
    />
  );
}

The important detail is that the blur is not only a placeholder. It is also part of the transition, so the image feels like it resolves naturally instead of popping in.

Result

With the metadata plugin and the transition styles in place, the final image behaves the way a polished blog post should:

  • the layout stays stable while the image loads
  • the user sees a low-quality preview immediately
  • the final image fades in without a harsh visual jump

That is the whole blur-up effect: compile the metadata once, then let the presentation layer do the rest.

Hey, you! 🫵

Did you know I created a YouTube channel? I'll be putting out a lot of new content on web development and software engineering so make sure to subscribe.

(clap if you liked the article)

You might also like