Skip to content

Using Relative Paths in Markdown Images With Next.JS

Next.js/

In Next.js you must store images inside public folder and use absolute paths when referencing them in Markdown.

That can be inconvenient if you want to reference image paths relatively to use your code editor’s autocomplete feature.

Relative paths also feel much better when you store images in the same folder as Markdown files. Give that post a read before continuing here.

In this post you will learn how to go from this:

![Dark forest](/path/inside/public/image.jpg)

…to this:

![Dark forest](./image.jpg)

Assuming that you keep images alongside the Markdown file.

You will learn to use either unified (including Contentlayer) or react-markdown to achieve this goal.

Creating Your Own remark Plugin

When you use unified with its remark ecosystem, you can create your own plugin that converts relative paths to absolute ones.

Creating your own plugin might sound scary, but it really isn’t. You can take a quick look at the official guide first.

But if you feel ready, let’s jump straight in.

Start by creating a file src/plugins/transform-img-src.mjs (or .ts).

Since creating plugins depends on a package that uses ESM, you must use an .mjs file. Here’s a guide on how to use ESM in Next.js.

If you are using TypeScript (.ts file) you can forget about the last paragraph. ESM will work without any extra effort.

In unified you are working with syntax trees that contain nodes.

Unified does the heavy lifting for you to represent your Markdown content as an object that your code can work with.

Coding the Plugin

To walk the tree and visit its nodes, you must use a visit utility function. It’s the base of any plugin.

import { visit } from 'unist-util-visit';

const imgDirInsidePublic = 'images';

export default function transformImgSrc() {
  return (tree, file) => {
    visit(tree, 'paragraph', node => {
      const image = node.children.find(child => child.type === 'image');

      if (image) {
        const fileName = image.url.replace('./', '');
        image.url = `/${imgDirInsidePublic}/${fileName}`;
      }
    });
  };
}

Since every image inside Markdown is wrapped in a paragraph element, you need to visit 'paragraph' nodes.

You can then check if the visited node has a child that is an image. This way you will find any image in your Markdown.

When that’s done, you overwrite the image.url value from a relative to an absolute path.

You can omit imgDirInsidePublic if you keep your images right inside the public folder. Otherwise, adjust the variable to your image folder, for example public/blog/images.

The final step is to attach your plugin to your unified pipeline.

import { readFileSync } from 'fs';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';

import transformImgSrc from '../plugins/transform-img-src';

const buffer = readFileSync(postMarkdownFile);

const content = await unified()
  .use(remarkParse)
  .use(transformImgSrc)
  .use(remarkRehype)
  .use(rehypeStringify)
  .process(buffer);

You can pass arguments to your transformImgSrc plugin, such as one for slugs. It is needed if you keep each post’s images in its own folder inside public.

const buffer = readFileSync(postMarkdownFile);
const slug = getPostSlug(postMarkdownFile);

const content = await unified()
  .use(remarkParse)
  .use(transformImgSrc, { slug })
  .use(remarkRehype)
  .use(rehypeStringify)
  .process(buffer);

Adjust your plugin to extract the slug from the arguments.

export default function transformImgSrc({ slug }) {
  return (tree, file) => {
    visit(tree, 'paragraph', node => {
      const image = node.children.find(child => child.type === 'image');

      if (image) {
        const fileName = image.url.replace('./', '');
        image.url = `/${imgDirInsidePublic}/${slug}/${fileName}`;
      }
    });
  };
}

Using remark Plugin With MDX

If you are using a component for displaying images in Markdown with MDX, you will need to slightly adjust your remark plugin.

In unified, components inside MDX are represented as mdxJsxFlowElement nodes.

For example, when you use a CustomImage component for showing images:

## Heading

Content of the blog post

<CustomImage src="./image.jpg" width="600" height="400" alt="Dark forest" />

You need to compare node.name to 'CustomImage' to match it.

visit(tree, 'mdxJsxFlowElement', node => {
  if (node.name === 'CustomImage') {
    const src = node.attributes.find(attribute => attribute.name === 'src');
    const fileName = src.value.replace('./', '');
    src.value = `/images-path-in-public/${fileName}`;
  }
});

You can find the prop you use for the source inside node.attributes, in this case src. If you use a different prop, swap src to yours.

You can always use console.log(node) inside visit to see all node properties.

Adding Your Plugin To Contentlayer

If you are using Contentlayer, adding your own remark plugin can be done inside contentlayer.config.js:

import transformImgSrc from './src/plugins/transform-img-src';

export default makeSource({
  markdown: { remarkPlugins: [transformImgSrc] },
  // for MDX, change to:
  // mdx: { remarkPlugins: [transformImgSrc] },
});

You can also get the post slug from Markdown file parent folder. Contentlayer exposes data about the file through rawDocumentData property.

export default function transformImgSrc() {
  return (tree, file) => {
    const slug = file.data.rawDocumentData.sourceFileDir;

    visit(tree, 'paragraph', node => {
      const image = node.children.find(child => child.type === 'image');

      if (image) {
        const fileName = image.url.replace('./', '');
        image.url = `/${imgDirInsidePublic}/${slug}/${fileName}`;
      }
    });
  };
}

This is one of many reasons why I strongly recommend Contentlayer. If you are not already using it in your project, check out my tutorial on how to create a blog with Contentlayer as a quickstart.

Using Relative Paths With react-markdown

An alternative to using unified directly, is to use react-markdown, which relies on it internally, but is more beginner-friendly. It doesn’t support MDX though, so use it for Markdown only.

With react-markdown you can control how the Markdown is transformed into HTML via components prop.

Use the components prop on ReactMarkdown component to transform img elements. You will have access to the src attribute, in which you can change a relative path to an absolute one.

export default function Post({ markdown }) {
  return (
    <ReactMarkdown
      components={{
        img: function ({ node, ...props }) {
          const fileName = node.properties.src.replace('./', '');
          props.src = `/images/${fileName}`;

          return <img {...props} />;
        },
      }}
    >
      {markdown}
    </ReactMarkdown>
  );
}

That’s it, with these tools you can transform image paths however you want.

Next thing you should do is store images in the same folder as Markdown files if you didn’t read my post about it already.