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:
- Store your assets in the blog post directory. Instead of moving them to the public directory, you can create a remark/rehype plugin. This plugin would handle source redirection and also copy the assets into the public directory during the build process whenever there are changes. To optimize this process, you can generate and compare checksums of the source and destination assets. If the checksums are the same, you can skip copying the assets. In production, you can even directly move the assets instead of copying them to save storage space and speed up the process.
- Another option is to keep your assets in the blog post directory and generate symlinks to the public directory. However, be aware that Webpack and Next.js don't handle symlinks well 🥲 You might need to patch Next.js to check for symlinks using
fs.symlink
in its Webpack image loader before reading them.