Using Relative Paths in Markdown Images With 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.