Mazzarolo MatteoMazzarolo Matteo

Use relative paths in markdown and MDX images with Next.js

By Mazzarolo Matteo


As a follow-up to Automatic width and height of local MDX images with Next.js, here's another post about how I resolved an issue when transitioning from the Gatsby image component to the Next.js image component.
This time, I will discuss how Next.js requires you to place static assets in the public directory in order to use them in your MDX files.

Blog posting directory structure

Before migrating to Next.js, I stored blog posts by placing their assets in the same directory as their markdown file:

blog/
├── 2023-01-01-my-first-post/
│   ├── index.md
│   └── images/
│       └── hello-world.png
└── 2023-02-04-it-is-my-birthday/
    ├── index.md
    ├── images/
    │   └── happy-birthday-to-me.md
    └── videos/
        └── my-birthday-recording.mp4

This is my ideal blog project structure: each post has its own self-contained directory. Assets can be easily imported into the markdown using relative paths, such as ![](images/hello-world.md). Additionally, this structure is similar to GitHub repositories, allowing you to copy/paste a blog post into a repository and ensure that all assets work correctly.
However, this directory structure doesn't work well in Next.js blogs. By default, Next.js requires static assets to be placed under the /public directory. This separation of a markdown post and its assets into different directories results in longer and error-prone image paths, like /blog-assets/2023-01-01-my-first-post/images/hello-world.png. The officially supported way to overcome this limitation is to use MDX instead of markdown.
With MDX, you can statically import all your assets (e.g., import helloWorldSrc from './images/hello-world.png') and pass them into MDX components (e.g., <Image src={helloWorldSrc} />).
While this approach offers the benefits of next/image optimizations out-of-the-box, it deviates from the simplicity of markdown in my opinion.

My solution

So... I explored a few solutions to this problem. Here are my findings.

Use the public dir, but keep relative paths

I think this is the simplest approach, and the one I went with. The idea here is to embrace the Next.js suggestion of putting assets in the public dir...

blog/
├── 2023-01-01-my-first-post.md
└── 2023-02-04-it-is-my-birthday.md

public/
└── blog-assets/
    └── 2023-01-01-my-first-post/
        └── images/
            └── hello-world.png

...while still referring to them as you would if assets were located in the same directory of the markdown file:

Hello world!
 
![](images/hello-world.png)
Hello world!
 
![](images/hello-world.png)

Similarly to how I automatically generate my images width and height attributes at build time, I achieved this behvaiour by creating and adding a custom plugin in my MDX/Markdown pipeline, this time using a remark. This plugin rewrites the src attribute of all images (and videos) to point to the public directory.

Remark is a tool that transforms markdown 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 remark.

/**
 * remark-assets-src-redirect.js
 *
 * Requires:
 * - npm i image-size unist-util-visit
 */
import { visit } from "unist-util-visit";
 
/**
 * Analyzes local markdown/MDX images & videos and rewrites their `src`.
 * Supports both markdown-style images, MDX <Image /> components, and `source`
 * elements. Can be easily adapted to support other sources too.
 * @param {string} options.root - The root path when reading the image file.
 */
const remarkSourceRedirect = (options) => {
  return (tree, file) => {
    // You need to grab a reference of your post's slug.
    // I'm using Contentlayer (https://www.contentlayer.dev/), which makes it
    // available under `file.data`.But if you're using something different, you
    // should be able to access it under `file.path`, or pass it as a parameter
    // the the plugin `options`.
    const slug = file.data.rawDocumentData.flattenedPath.replace("blog/", "");
    // This matches all images that use the markdown standard format ![label](path).
    visit(tree, "paragraph", (node) => {
      const image = node.children.find((child) => child.type === "image");
      if (image) {
        image.url = `blog-assets/${slug}/${image.url}`);
      }
    });
    // This matches all MDX' <Image /> components & source elements that I'm
    // using within a custom <Video /> component.
    // Feel free to update it if you're using a different component name.
    visit(tree, "mdxJsxFlowElement", (node) => {
      if (node.name === "Image" || node.name === 'source') {
        const srcAttr = node.attributes.find((attribute) => attribute.name === "src");
        srcAttr.value = `blog-assets/${slug}/${srcAttr.value}`)
      }
    });
  };
};
 
export default remarkSourceRedirect;
/**
 * remark-assets-src-redirect.js
 *
 * Requires:
 * - npm i image-size unist-util-visit
 */
import { visit } from "unist-util-visit";
 
/**
 * Analyzes local markdown/MDX images & videos and rewrites their `src`.
 * Supports both markdown-style images, MDX <Image /> components, and `source`
 * elements. Can be easily adapted to support other sources too.
 * @param {string} options.root - The root path when reading the image file.
 */
const remarkSourceRedirect = (options) => {
  return (tree, file) => {
    // You need to grab a reference of your post's slug.
    // I'm using Contentlayer (https://www.contentlayer.dev/), which makes it
    // available under `file.data`.But if you're using something different, you
    // should be able to access it under `file.path`, or pass it as a parameter
    // the the plugin `options`.
    const slug = file.data.rawDocumentData.flattenedPath.replace("blog/", "");
    // This matches all images that use the markdown standard format ![label](path).
    visit(tree, "paragraph", (node) => {
      const image = node.children.find((child) => child.type === "image");
      if (image) {
        image.url = `blog-assets/${slug}/${image.url}`);
      }
    });
    // This matches all MDX' <Image /> components & source elements that I'm
    // using within a custom <Video /> component.
    // Feel free to update it if you're using a different component name.
    visit(tree, "mdxJsxFlowElement", (node) => {
      if (node.name === "Image" || node.name === 'source') {
        const srcAttr = node.attributes.find((attribute) => attribute.name === "src");
        srcAttr.value = `blog-assets/${slug}/${srcAttr.value}`)
      }
    });
  };
};
 
export default remarkSourceRedirect;

To use this plugin, add it to the remarkPlugins 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?$/,
    remarkPlugins: [remarkSourceRedirect]],
  },
});
 
// Example: `contentlayer.config.js` if you're using Contentlayer
export default makeSource({
  mdx: {
    remarkPlugins: [remarkSourceRedirect],
  },
});
// Example: `next.config.js` if you're using MDX Remote
const withMDX = createMDX({
  options: {
    extension: /\.mdx?$/,
    remarkPlugins: [remarkSourceRedirect]],
  },
});
 
// Example: `contentlayer.config.js` if you're using Contentlayer
export default makeSource({
  mdx: {
    remarkPlugins: [remarkSourceRedirect],
  },
});

Other approaches

I considered other approaches, but I don't think they're necessary for my simple use case. Here are a few ideas for inspiration: