Mazzarolo MatteoMazzarolo Matteo

Automatic width and height of local MDX images with Next.js

By Mazzarolo Matteo


I recently migrated my blog from Gatsby to Next.js (mostly for fun). Although the process was mostly straightforward, while using Next.js' image component I encountered bugs and missing features that made the transition more difficult than anticipated.
In this post, I'll explain how I solved the issue of having Next.js automatically determine the width and height of images in a MDX (or markdown) file to prevent Cumulative Layout Shift.

Next.js's built-in options

Regardless of the MDX loader or library you use, as of version 13, Next.js offers two main ways to load images from files.
The first is to import the image source directly into your MDX files using a static import and feeding it into the next/image component (or into your abstraction on top of it):

import dogPicture from "../public/dog.png";
 
# My dog
 
Hi! Here's a picture of my dog:
 
<Image alt="My dog" src={dogPicture} />
import dogPicture from "../public/dog.png";
 
# My dog
 
Hi! Here's a picture of my dog:
 
<Image alt="My dog" src={dogPicture} />

By going down this path, Next.js statically analyzes the image file at build time and automatically determines its width and height. This is great, as it eliminates the need to manually set a size for each image used in MDX files. However, I personally prefer to keep my MDX files as compatible as possible with standard markdown format (using ![]() for images) and only embed React components only when strictly necessary.

The other option is to pass the image path URL string directly to the next/image component or a standard markdown image:

# My dog
 
Hi! Here are two pictures of my dog:
 
<Image alt="My dog" src="../public/dog1.png" />
 
![My dog]("../public/dog2.png")
# My dog
 
Hi! Here are two pictures of my dog:
 
<Image alt="My dog" src="../public/dog1.png" />
 
![My dog]("../public/dog2.png")

I prefer this approach. But for Next.js, using an image path means you're loading a remote image, so it won't be able to statically analyze its width and height at build time.

Rehype to the rescue

I solved this by creating a small rehype plugin. It loads all image referenced in my MDX files during the build process and adds their width and height to the generated img elements.

Rehype is a tool that transforms HTML with plugins. If you're using MDX in Next.js, it's lickely your MDX pipeline (such as unified, Remote MDX, ContentLayer, etc.) already uses rehype.

Here's the plugin code:

/**
 * rehype-image-size.js
 *
 * Requires:
 * - npm i image-size unist-util-visit
 */
import getImageSize from "image-size";
import { visit } from "unist-util-visit";
 
/**
 * Analyze local MDX images and add `width` and `height` attributes to the
 * generated `img` elements.
 * Supports both markdown-style images and MDX <Image /> components.
 * @param {string} options.root - The root path when reading the image file.
 */
export const rehypeImageSize = (options) => {
  return (tree) => {
    // This matches all images that use the markdown standard format ![label](path).
    visit(tree, { type: "element", tagName: "img" }, (node) => {
      if (node.properties.width || node.properties.height) {
        return;
      }
      const imagePath = `${options?.root ?? ""}${node.properties.src}`;
      const imageSize = getImageSize(imagePath);
      node.properties.width = imageSize.width;
      node.properties.height = imageSize.height;
    });
    // This matches all MDX' <Image /> components.
    // Feel free to update it if you're using a different component name.
    visit(tree, { type: "mdxJsxFlowElement", name: "Image" }, (node) => {
      const srcAttr = node.attributes?.find((attr) => attr.name === "src");
      const imagePath = `${options?.root ?? ""}${srcAttr.value}`;
      const imageSize = getImageSize(imagePath);
      const widthAttr = node.attributes?.find((attr) => attr.name === "width");
      const heightAttr = node.attributes?.find((attr) => attr.name === "height");
      if (widthAttr || heightAttr) {
        // If `width` or `height` have already been set explicitly we
        // don't want to override them.
        return;
      }
      node.attributes.push({
        type: "mdxJsxAttribute",
        name: "width",
        value: imageSize.width,
      });
      node.attributes.push({
        type: "mdxJsxAttribute",
        name: "height",
        value: imageSize.height,
      });
    });
  };
};
 
export default rehypeImageSize;
/**
 * rehype-image-size.js
 *
 * Requires:
 * - npm i image-size unist-util-visit
 */
import getImageSize from "image-size";
import { visit } from "unist-util-visit";
 
/**
 * Analyze local MDX images and add `width` and `height` attributes to the
 * generated `img` elements.
 * Supports both markdown-style images and MDX <Image /> components.
 * @param {string} options.root - The root path when reading the image file.
 */
export const rehypeImageSize = (options) => {
  return (tree) => {
    // This matches all images that use the markdown standard format ![label](path).
    visit(tree, { type: "element", tagName: "img" }, (node) => {
      if (node.properties.width || node.properties.height) {
        return;
      }
      const imagePath = `${options?.root ?? ""}${node.properties.src}`;
      const imageSize = getImageSize(imagePath);
      node.properties.width = imageSize.width;
      node.properties.height = imageSize.height;
    });
    // This matches all MDX' <Image /> components.
    // Feel free to update it if you're using a different component name.
    visit(tree, { type: "mdxJsxFlowElement", name: "Image" }, (node) => {
      const srcAttr = node.attributes?.find((attr) => attr.name === "src");
      const imagePath = `${options?.root ?? ""}${srcAttr.value}`;
      const imageSize = getImageSize(imagePath);
      const widthAttr = node.attributes?.find((attr) => attr.name === "width");
      const heightAttr = node.attributes?.find((attr) => attr.name === "height");
      if (widthAttr || heightAttr) {
        // If `width` or `height` have already been set explicitly we
        // don't want to override them.
        return;
      }
      node.attributes.push({
        type: "mdxJsxAttribute",
        name: "width",
        value: imageSize.width,
      });
      node.attributes.push({
        type: "mdxJsxAttribute",
        name: "height",
        value: imageSize.height,
      });
    });
  };
};
 
export default rehypeImageSize;

To use this plugin, add it to the rehypePlugin list of your MDX build pipeline (e.g., in next.config.js if you're using MDX Remote, in contentlayer.config.js if you're using Contentlayer, etc.):

// Example: `next.config.js` if you're using MDX Remote
const withMDX = createMDX({
  options: {
    extension: /\.mdx?$/,
    rehypePlugins: [[rehypeImageSize, { root: process.cwd() }]],
  },
});
 
// Example: `contentlayer.config.js` if you're using Contentlayer
export default makeSource({
  mdx: {
    rehypePlugins: [[rehypeImageSize, { root: process.cwd() }]],
  },
});
// Example: `next.config.js` if you're using MDX Remote
const withMDX = createMDX({
  options: {
    extension: /\.mdx?$/,
    rehypePlugins: [[rehypeImageSize, { root: process.cwd() }]],
  },
});
 
// Example: `contentlayer.config.js` if you're using Contentlayer
export default makeSource({
  mdx: {
    rehypePlugins: [[rehypeImageSize, { root: process.cwd() }]],
  },
});