Skip to content

MDX, Next.js, and image handling

Posted on:

Note: This post requires some familiarity with Markdown and Next.js

MDX provides a familiar authoring experience for writers, allowing for custom components when needed without the cognitive load of fully-fledge markup. However, a key challenge arose when our team wanted to use Next.js’ image optimization capabilities with MDX. At the heart of this challenge is the requirement for width and height dimensions by Next.js’ Image component, while Markdown and MDX don’t inherently offer a straightforward way to specify an image’s size.

Background

Contentlayer provides a strongly-typed, opinionated way of handling MDX files within Next.js, along with the helpful useMDXComponent hook to pass in components that can be used in lieu of importing components within every MDX file.

This hook also allows you to replace HTML elements with JSX components of your choosing. Suppose we have the following MDX content that we want to render:

---
title: Test post
---
# This is an h1
![a test image](images/path-to-image.png)

The above content contains frontmatter metadata (just a title, for now), and an image and heading in typical markdown syntax. Unfortunately, since MDX & Contentlayer don’t have any awareness of the Next.js Image component, this image tag won’t benefit from the image optimizations that Next.js provides (namely auto-scaling & sizing). We can change that!

Below is a simple example of replacing the <h1> element with a custom JSX component.


import { useMDXComponent } from "next-contentlayer/hooks";
import SpecialFancyHeading from "@components/SpecialFancyHeading";

const components = {
  // All h1 tags are replaced with this component
  h1: (props) => <SpecialFancyHeading { ...props } />
}

// Use within page.tsx to render MDX content
export function MDXContent({ code }: any) {
  const MdxOutput = useMDXComponent(code);
  return <MdxOutput components = { components } />;
}

We can use this approach to replace all img tags with the Next.js Image component.

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

const components = {
  /* All h1 tags are replaced with this component */
  h1: (props) => <SpecialFancyHeading { ...props } />,
  
  /* All img tags are replaced with the `Image` component */
  img: (props) => {
    return <Image { ...newProps } />;
  }
}

// Use within page.tsx to render MDX content
export function MDXContent({ code }: any) {
  const MdxOutput = useMDXComponent(code);
  return <MdxOutput components = { components } />;
}

But wait! The Image component requires a width and height to prevent layout shifts (and of course, alt text for accessibility). One approach we can take is to read the image file and get its dimensions. We’ll use the image-size npm package for this, and handle some other cases to make sure that a default img element can still work as expected:

//...other imports
import getImageSize from "image-size";

const components = {

  /* All img tags are replaced with the `Image` component */
  img: (props) => {
    // Copy props into new object since it's locked
    const newProps = { ...props };

    const isLocalImage = !props.src.startsWith("http");

    // If no dimensions are defined, let's find it!
    if (!props.width && !props.height && isLocalImage) {
      // Extract the file name and path. You may need to adjust this for your app.
      const fileName = props.src.replace("/images", ""); // e.g. file.png, or /subdir/file.png
      const filePath = path.join(process.cwd(), "public", "images", fileName);

      const dimensions = getImageSize(filePath);

      newProps.width = dimensions.width;
      newProps.height = dimensions.height;
    }

    /** 
     * If you have a different basePath for your application (e.g. site.xyz/docs/),
     * you might want to hardcode that here so that you don't need to write it for
     * every image.
     */
    if (!props.src.startsWith(basePath) && isLocalImage) {
      newProps.src = `${basePath}${props.src}`;
    }
    return <Image {...newProps} />;
  }
}

Overall, the benefit of this approach is that one can leverage standard Markdown syntax in their content while maintaining the optimizations that Image provides.

If you need to specify your own width, height, or styles, you can leverage the Image component directly in MDX, or perhaps create/find a remark plugin which allows annotations to markdown-flavored image syntax.

I hope this helps someone out there working within the MDX/Next.js/Contentlayer stack.