Contentlayer with next/image

sdorra

Sebastian Sdorra

Posted on January 4, 2023

Contentlayer with next/image

Getting images right and fast on the web is hard.
If we are doing it wrong,
we create a poor user experience with slow loading times and
CLS (Cumulative Layout Shift).

next/image

In Next.js we can use the next/image component to avoid this bad user experience.
next/image automatically optimizes our images in three ways to reduce the slow loading times:

  • Converts the image to the smallest supported format
  • Resizes the image based on the used device viewport
  • Uses image compression

It also creates a placeholder to avoid the CLS.
The following example shows the usage of next/image with a static image:

import picture from "./pictures/mountain.jpg";

<Image src={picture} alt="Picture of a mountain" placeholder="blur" />;
Enter fullscreen mode Exit fullscreen mode

Markdown

This looks great, but how can we use it in our markdown files?

MDX

My first reaction was to use MDX and use next/image just as in the example.
But that means that we can't use normal markdown images and it turns out that this won't work with contentlayer.
It won't work, because Next.js does some magic on the import of the static image.
The object which gets returned by the import contains not only a path to the image, it contains also the width and height plus a very small version of the image for the blurred placeholder.
This magic does not work if the MDX file is loaded with contentlayer,
because contentlayer uses its own bundler, which knows nothing about the import magic for images.

The trick with the public folder

The next idea was to use normal markdown images and to place the images in the public folder.
This eliminates the need for static imports and treats our image like a remote image.
But in order to make this work, we have to tell next/image the dimensions of the image.
If we would use a static import for the image, the import magic would provide the dimensions for us.
To pass the width and height to the image component we use a rehype plugin called rehype-img-size.

# pnpm
pnpm add -D rehype-img-size

# yarn
yarn add -D rehype-img-size

# npm
npm install --save-dev rehype-img-size
Enter fullscreen mode Exit fullscreen mode

After installation we can configure contentlayer to use the plugin within MDX, but it should work with markdown too.

export default makeSource({
  contentDirPath: "content/posts",
  documentTypes: [Post],
  mdx: {
    rehypePlugins: [[rehypeImgSize, { dir: "public" }]],
    remarkPlugins: [],
  },
});
Enter fullscreen mode Exit fullscreen mode

The plugin will now find each image in the markdown content and pass its dimensions to the image component.
If we have the following markdown content...

![Picture of a mountain](/pictures/mountain.jpg)
Enter fullscreen mode Exit fullscreen mode

...rehype-img-size will look up pictures/mountain.jpg in the public directory (as configured in the contentlayer config) and
calculate its size.
Now we can replace the image component with next/image:

import { useMDXComponent } from "next-contentlayer/hooks";
import Image from "next/image";

type Props = {
  code: string;
};

const Markdown = ({ code }: Props) => {
  const MDXComponent = useMDXComponent(code);
  return (
    <section className="prose prose-zinc">
      <MDXComponent
        components={{
          img: Image,
        }}
      />
    </section>
  );
};

export default Markdown;
Enter fullscreen mode Exit fullscreen mode

And that's it, we are now using next/image to render our markdown images.
This approach works, but it is not ideal, because the images can't be located next to the content.
They have to be placed in the public directory.
It would be better if we could locate the images right next to the content.

We also don't have the small image for the blurred placeholder,
we can only use the empty placeholder.

If these two things are not so important to you, this is the right approach.

Custom rehype plugin

To fix the two weaknesses from the last approach, we can write a custom rehype plugin.
Our plugin should do the following things:

  • Copy the image from its location to the public folder
  • Update the src attribute to the new location
  • Calculate the width and height
  • Create small image for blurred placeholder

Before we write this plugin,
we should understand how the markdown processing works in contentlayer.

contentlayer uses remark to parse the markdown into a mdast.
We can now use remark plugins to modify the mdast.
Then rehype comes into play and converts the mdast into a hast.
rehype plugins can now modify the hast.
Finally the hast is converted into react components.

markdown processing

For our plugin we have to modify the hast,
because images in the mdast don't have width or height and they don't support custom properties.
The hast on the other hand supports all properties of the HTML img component and we can pass custom properties.

Implementation

We start our plugin by defining the options:

  • publicDir: path to the public directory

Then we can define our plugin.
The plugin is a function which gets the options and returns another function which receives the following parameters:

  • tree: the whole hast
  • file: the parsed file with path and content
  • done: a function which should be called when our plugin has finished

Our plugin searches the hast tree and for each element with the tagName img we call the processImage function.
rehype plugins can't be async, but image processing is async.
That's the reason why we store each resulting Promise of the processImage function in an array and call the done function if all Promises are resolved and the work is done.

type Options = {
  publicDir: string;
};

const staticImages: Plugin<[Options], Root> = (options) => (tree, file, done) => {
  const tasks: Promise<void>[] = [];

  visit(tree, "element", (node) => {
    if (node.tagName === "img") {
      tasks.push(processImage(options, file, node));
    }
  });

  Promise.all(tasks).then(() => done());
};

const processImage = async (options: Options, file: VFileWithOutput<unknown>, node: Element): Promise<void> => {
  // implementation comes next
}

export default staticImages;
Enter fullscreen mode Exit fullscreen mode

Ok, now we can have a detailed look at the implementation of the processImage function.
At first we have to find the real path of the image.
Fortunately, contentlayer stores information about the location of our markdown file in the data field of the file.

// find the content directory
const root = file.dirname || process.cwd();

// find the directory of the markdown file
// RawDocumentData is an import from contentlayer/source-files
const data = file.data as { rawDocumentData: RawDocumentData };
const directory = data.rawDocumentData.sourceFileDir;

// get the path of the image from the src attributes
const filePath = image.properties?.src as string || "";

// create the path by joining root, directory and filePath
// path is imported from node:path
const imagePath = path.join(root, directory, filePath)
Enter fullscreen mode Exit fullscreen mode

Now that we have the path we can use sharp to read the width and height of the image.

// sharp is an import from sharp (@types/sharp)
const image = await sharp(imagePath);

// get the width and height of the image
const { width, height } = await image.metadata();
Enter fullscreen mode Exit fullscreen mode

After that we can create a small version of the image for the blurred placeholder.
We will create a png with a quality of 75, a width of 8px and we keep the aspect ratio.

const imgAspectRatio = width / height;
const blurDataURL = await image
  .resize(8, Math.round(8 / imgAspectRatio))
  .png({
    quality: 75,
  })
  .toBuffer()
  .then((buffer) => `data:image/png;base64,${buffer.toString("base64")}`);
Enter fullscreen mode Exit fullscreen mode

Then we can copy the image to the public folder and create a new path for the src attribute of the image.

const src = path.join(directory, filePath);
const target = path.join(options.publicDir, src);

await fs.mkdir(path.dirname(targetDir), { recursive: true });
await fs.copyFile(imagePath, target);
Enter fullscreen mode Exit fullscreen mode

With all the information in place we can update the properties of the img tag.

node.properties = {
  ...node.properties,
  width,
  height,
  src: path.join("/", src),
  blurDataURL,
};
Enter fullscreen mode Exit fullscreen mode

After we have finished writing our plugin, it is time to configure contentlayer.

import mdxImages from "./lib/static-images";

export default makeSource({
  contentDirPath: "content/posts",
  documentTypes: [Post],
  mdx: {
    rehypePlugins: [[mdxImages, { publicDir: path.join(process.cwd(), "public") }]],
    remarkPlugins: [],
  },
});
Enter fullscreen mode Exit fullscreen mode

Don't forget to replace the img tag with next/image.
As we have seen in the rehype-img-size method.

Conclusion

Ok, let's summarize again what we have achieved.

We have written a rehype plugin, which does the following steps fully automatic for us:

  • Copy the image from its location to the public folder
  • Update the src attribute to the new location
  • Calculate the width and height
  • Create a small image for the blurred placeholder

With this plugin we can use the normal markdown image syntax and gain all the benefits of next/image:

  • Smallest supported format
  • Resized version based on the used device viewport
  • image compression

For a more complete example have a look at the source of this blog.

💖 💪 🙅 🚩
sdorra
Sebastian Sdorra

Posted on January 4, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Contentlayer with next/image
markdown Contentlayer with next/image

January 4, 2023