Using the Next.js Image Component with MDX
December 31, 2020

Photo by Samuel Ferrara.

Happy new years! 2020 was a rough year and here's hoping 2021 will be a better year!

Recently I've been experimenting with Next.js, and I have found it amazing. I've been trying to recreate my portfolio website using Next.js, which is currently written in Gatsby. I've noticed from my experimentation that Next.js does not handle a lot of the details, unlike Gatsby, where plugins do much of the heavy lifting. With Next.js, images and sourcing MDX is your responsibility (a pro and a con, depending on what you need).

Next.js Image Component and Layout Shift

Recently Vercel released Next.js 10, which includes an image component that handles image optimization similar to the one in Gatsby. Unlike the Gatsby component, Next.js's component requires that you provide the image's width and height to prevent layout shift (Gatsby handles this internally), which will be important for SEO. You can read more here.

Images are always rendered in such a way as to avoid Cumulative Layout Shift, a Core Web Vital that Google is going to use in search ranking.

Gist of Layout Shift

You know how you click on a website, and you see the initial page load without seeing the images, and then the images start loading in, causing things to move around? I and many users find this annoying. Well, by providing the height and width, Next.js can prevent this "layout shift" to improve user experience (it's crucial since Google is going to use it in search ranking).

The Problem

Well, Next.js's unopinionated nature has made it difficult to find a straightforward way to use optimized images in an MDX blog. Additionally, I have been using next-mdx-remote (link) for sourcing and parsing MDX data, but the documentation does not provide any information about using the Next.js image component. I have read a few articles (see footnotes) about the subject but still nothing. So I created a custom solution!

The Solution

I've created a rehype plugin that attaches the image's height and width to its properties. This allows you to use them in your custom MDX components. You can create a custom img MDX component that uses the Next.js image component with the provided height and width.

The Code

Full code is on my GitHub repo.

First, you will need to install unist-util-visit, image-size.

1
yarn add unist-util-visit image-size

Here's the example blog post.

/posts/example.mdx
1
# Example MDX
2
3
![image](/snippets/other/workshops1.jpg)
4
5
![other](/snippets/other/default-seo-image.png)

Here's how you source and create the MDX data. Do note that I am using example.tsx for a single blog post, but in reality, you would want to source multiple MDX files and thus might want to use a dynamic route like /pages/blog/[blogid].tsx

/pages/blog/example.tsx
1
import type { MDXProviderComponentsProp } from '@mdx-js/react';
2
import { promisify } from 'util';
3
import type { GetStaticProps } from 'next';
4
import hydrate from 'next-mdx-remote/hydrate';
5
import renderToString from 'next-mdx-remote/render-to-string';
6
import NextImage from 'next/image';
7
import path from 'path';
8
import imageMetadata from 'plugins/image-metadata';
9
10
export interface ExampleBlogPostProps {
11
body: string;
12
}
13
14
const mdxComponents: MDXProviderComponentsProp = {
15
// Custom image - here you can customize the image layout: https://nextjs.org/docs/api-reference/next/image#layout
16
img: ({ src, height, width, ...rest }) => (
17
// layout="responsive" makes the image fill the container width wise - I find it looks nicer for blog posts
18
<NextImage layout="responsive" src={src} height={height} width={width} {...rest} />
19
),
20
};
21
22
export const getStaticProps: GetStaticProps = async () => {
23
// Source in the file
24
const content = await (
25
await promisify(fs.readFile)(path.join(process.cwd(), 'content/snippets', 'other.mdx'))
26
)
27
// await fsPromise.readFile(path.join(process.cwd(), 'posts', 'example.mdx'))
28
.toString();
29
30
// If you have metadata, parse it here with `grey-matter` or something else!
31
32
const body = await renderToString(content, {
33
components: mdxComponents,
34
scope: {},
35
mdxOptions: {
36
rehypePlugins: [imageMetadata],
37
},
38
});
39
40
return { props: { body } };
41
};
42
43
const ExampleBlogPost: React.FC<ExampleBlogPostProps> = ({ body }) => {
44
const content = hydrate(body, { components: mdxComponents });
45
46
return <div>{content}</div>;
47
};
48
49
export default ExampleBlogPost;

Here is the rehype plugin that attaches the width and height of the image to the image component.

/plugins/image-metadata.ts
1
// Similiar structure to:
2
// https://github.com/JS-DevTools/rehype-inline-svg/blob/master/src/inline-svg.ts
3
import imageSize from 'image-size';
4
import path from 'path';
5
import { Processor } from 'unified';
6
import { Node } from 'unist';
7
import visit from 'unist-util-visit';
8
import { promisify } from 'util';
9
import { VFile } from 'vfile';
10
11
const sizeOf = promisify(imageSize);
12
13
/**
14
* An `<img>` HAST node
15
*/
16
interface ImageNode extends Node {
17
type: 'element';
18
tagName: 'img';
19
properties: {
20
src: string;
21
height?: number;
22
width?: number;
23
};
24
}
25
26
/**
27
* Determines whether the given HAST node is an `<img>` element.
28
*/
29
function isImageNode(node: Node): node is ImageNode {
30
const img = node as ImageNode;
31
return (
32
img.type === 'element' &&
33
img.tagName === 'img' &&
34
img.properties &&
35
typeof img.properties.src === 'string'
36
);
37
}
38
39
/**
40
* Filters out non absolute paths from the public folder.
41
*/
42
function filterImageNode(node: ImageNode): boolean {
43
return node.properties.src.startsWith('/');
44
}
45
46
/**
47
* Adds the image's `height` and `width` to it's properties.
48
*/
49
async function addMetadata(node: ImageNode): Promise<void> {
50
const res = await sizeOf(path.join(process.cwd(), 'public', node.properties.src));
51
52
if (!res) throw Error(`Invalid image with src "${node.properties.src}"`);
53
54
node.properties.width = res.width;
55
node.properties.height = res.height;
56
}
57
58
/**
59
* This is a Rehype plugin that finds image `<img>` elements and adds the height and width to the properties.
60
* Read more about Next.js image: https://nextjs.org/docs/api-reference/next/image#layout
61
*/
62
export default function imageMetadata(this: Processor) {
63
return async function transformer(tree: Node, file: VFile): Promise<Node> {
64
const imgNodes: ImageNode[] = [];
65
66
visit(tree, 'element', (node) => {
67
if (isImageNode(node) && filterImageNode(node)) {
68
imgNodes.push(node);
69
}
70
});
71
72
for (const node of imgNodes) {
73
await addMetadata(node);
74
}
75
76
return tree;
77
};
78
}

Caveats

This only works with the markdown image form, ie: ![image](/path/to/image) and does not work with JSX components. If you use a JSX image component you will need to manually figure out the height and width.

Conclusion

With this setup, I can now easily add more optimized images to my MDX blog post without finding the height and width. I have recently been learning Next.js, so there may be a better way of doing this. I hope that next-mdx-remote or the Next.js team posts more information about using images inside an MDX blog. I hope you enjoyed this post. Have a great day!

Footnotes