code

Use Next/Image with React Markdown

Extend react-markdown with metastrings.

ByAmir Ardalan
0 views
~ 7 min read

In a previous post, I discussed how to get code syntax highlighting and individual line highlighting working in react-markdown using react-syntax-highlighter and parse-numeric-range. Custom components are a powerful way to extend react-markdown.

In this guide, we will create a custom component to that converts Markdown images into Next.js Image components. We'll get the best of both worlds with the terse syntax of Markdown and the powerful optimization and configuration of the next/image component. As a bonus, we will add the ability to add an image caption directly in the markdown as well.

Create Markdown.tsx

tsx
1// Markdown.tsx 2import Image from 'next/image' 3import ReactMarkdown from 'react-markdown' 4 5 6const MarkdownComponents: object = { 7 // Code will go here 8} 9 10return ( 11 <ReactMarkdown 12 children={your.content.here} 13 components={MarkdownComponents} 14 /> 15) 16

Add Custom Metastring Logic

If you want to get right down to business, here is the final code. Read on to see how it all works.

tsx
1p: (paragraph: { children?: boolean; node?: any}) => { 2 const { node } = paragraph 3 4 if (node.children[0].tagName === "img") { 5 const image = node.children[0] 6 const metastring = image.properties.alt 7 const alt = metastring?.replace(/ *\{[^)]*\} */g, "") 8 const metaWidth = metastring.match(/{([^}]+)x/) 9 const metaHeight = metastring.match(/x([^}]+)}/) 10 const width = metaWidth ? metaWidth[1] : "768" 11 const height = metaHeight ? metaHeight[1] : "432" 12 const isPriority = metastring?.toLowerCase().match('{priority}') 13 const hasCaption = metastring?.toLowerCase().includes('{caption:') 14 const caption = metastring?.match(/{caption: (.*?)}/)?.pop() 15 16 return ( 17 <div className="postImgWrapper"> 18 <Image 19 src={image.properties.src} 20 width={width} 21 height={height} 22 className="postImg" 23 alt={alt} 24 priority={isPriority} 25 /> 26 {hasCaption ? <div className="caption" aria-label={caption}>{caption}</div> : null} 27 </div> 28 ) 29 } 30 return <p>{paragraph.children}</p> 31}, 32

With the above code we can use the standard Markdown style image syntax: ![AltTextHere](/image.jpg) and we will get a Next Image component rendering the image.

We can also use JSON metastrings inside the Alt text to define a width and height.

As of Next.js 11, next/image no longer requires height and width to be explicitly defined for local images, but for this to work, the image's src must be declared using the import keyword, which wont work for us since we aren't hardcoding a specific image.

Finally, we can optionally preload images which will render above the fold by applying the Next/Image prop priority. All meta info contained within curly braces will be stripped before rendering.

Here's how we'd define a width of 786, height of 432 and flag as a priority image:

markdown
1![AltText {priority}{768x432}](/image.jpg) 2

To add a caption to our image, we can do something like:

markdown
1![AltText {768x432} {priority} {caption: Photo by Someone}](/image.jpg) 2

So How Does it Work?

It's mostly react-markdown's built-in component overrides with some Regex magic sprinkled in. Let's break it down:

The Logical Starting Point

tsx
1const MarkdownComponents: object = { 2 img: image => { 3 4 return ( 5 <Image 6 src={image.properties.src} 7 alt={image.properties.alt} 8 height="768" 9 width="432" 10 /> 11 ) 12 }, 13} 14

The above code should work, and it sort of does. It replaces a standard Markdown image with a fancy Next Image component. But if you pop open the console, there is an error. The image is wrapped in a paragraph tag. Not a major issue, but we can make it cleaner.

Fixing the Wrapping Paragraph Issue

By default in Markdown, images are wrapped with a paragraph tag. While this isn't the worst thing in the world, it's technically not valid HTML. Let's fix that:

tsx
1p: paragraph => { 2 const { node } = paragraph 3 4 if (node.children[0].tagName === "img") { 5 const image = node.children[0] 6 7 return ( 8 <Image 9 src={image.properties.src} 10 width="768" 11 height="432" 12 alt={image.properties.alt} 13 /> 14 ) 15 } 16 return <p>{paragraph.children}</p> 17}, 18

Much better, but, the image dimensions are hardcoded and it would be nice to utilize Next's Priority prop to preload the top-most image in our blog which renders above the fold.

Setting the Next/Image Dimensions

tsx
1p: paragraph => { 2 const { node } = paragraph 3 4 if (node.children[0].tagName === "img") { 5 const image = node.children[0] 6 const metastring = image.properties.alt 7 const alt = metastring?.replace(/ *\{[^)]*\} */g, "") 8 const metaWidth = metastring.match(/{([^}]+)x/) 9 const metaHeight = metastring.match(/x([^}]+)}/) 10 const width = metaWidth ? metaWidth[1] : "768" 11 const height = metaHeight ? metaHeight[1] : "432" 12 13 return ( 14 <Image 15 src={image.properties.src} 16 width={width} 17 height={height} 18 alt={image.properties.alt} 19 /> 20 ) 21 } 22 return <p>{paragraph.children}</p> 23}, 24

We are declaring a metaHeight and metaWidth which will look for curly braces in the alt tag and return an array with a string for our height and width based on the numbers we entered between the curly braces and the letter x: {768x432}. These variables will also be used for error handling.

Next, we declare width and height which give us the value for our Image component height and width attributes and utilizing ternaries, we set fallbacks if there are no dimensions declared in a metastring.

And finally, we declare alt and strip out the curly braces from the alt text.

Taking Advantage of the priority Prop

It'd be nice to have some way to apply the Next/Image prop priority to preload above-the-fold images.

tsx
1const MarkdownComponents: object = { 2 p: paragraph => { 3 const { node } = paragraph 4 5 if (node.children[0].tagName === "img") { 6 const image = node.children[0] 7 const metastring = image.properties.alt 8 const alt = metastring?.replace(/ *\{[^)]*\} */g, "") 9 const metaWidth = metastring.match(/{([^}]+)x/) 10 const metaHeight = metastring.match(/x([^}]+)}/) 11 const width = metaWidth ? metaWidth[1] : "768" 12 const height = metaHeight ? metaHeight[1] : "432" 13 const isPriority = metastring?.toLowerCase().match('{priority}') 14 15 return ( 16 <Image 17 src={image.properties.src} 18 width={width} 19 height={height} 20 className="postImg" 21 alt={alt} 22 priority={isPriority} 23 /> 24 ) 25 } 26 return <p>{paragraph.children}</p> 27 }, 28} 29

We simply look for the metastring {priority} and then conditionally render the priority prop. Nice!

Adding an Image Caption

As a bonus, it's nice to add the ability to easily add an image caption from inside the markdown. To do this, we just add a bit more regex to look for a {caption:} metastring. Anything after the colon will be displayed as your image caption.

Notice we are also wrapping the image component with a div. This will allow us to contain the caption together with the image.

Even if you decide not to utilize the caption functionality, I recommend using the wrapping div in order to target with your CSS to add bottom margin or padding. Without this, the Next/Image component sometimes does wonky things.
markdown
1![AltText {768x432}{priority}{caption: Photo by Someone}](/image.jpg) 2

Our code would look like this:

tsx
1p: (paragraph: { children?: boolean; node?: any}) => { 2 const { node } = paragraph 3 4 if (node.children[0].tagName === "img") { 5 const image = node.children[0] 6 const metastring = image.properties.alt 7 const alt = metastring?.replace(/ *\{[^)]*\} */g, "") 8 const metaWidth = metastring.match(/{([^}]+)x/) 9 const metaHeight = metastring.match(/x([^}]+)}/) 10 const width = metaWidth ? metaWidth[1] : "768" 11 const height = metaHeight ? metaHeight[1] : "432" 12 const isPriority = metastring?.toLowerCase().match('{priority}') 13 const hasCaption = metastring?.toLowerCase().includes('{caption:') 14 const caption = metastring?.match(/{caption: (.*?)}/)?.pop() 15 16 return ( 17 <div className="postImgWrapper"> 18 <Image 19 src={image.properties.src} 20 width={width} 21 height={height} 22 className="postImg" 23 alt={alt} 24 priority={isPriority} 25 /> 26 {hasCaption ? <div className="caption" aria-label={caption}>{caption}</div> : null} 27 </div> 28 ) 29 } 30 return <p>{paragraph.children}</p> 31}, 32

I would prefer to have used a regex lookbehind for a cleaner and more modern approach to stripping the caption from our metastring. Unfortunately Safari does not support this, so we do something less than ideal here as a workaround:

javascript
1 const caption = metastring?.match(/{caption: (.*?)}/)?.pop() 2

Working Example

And here is the code working in this very blog post. This is fully created with our extended markdown, and it's rendered using Next/Image!

Sample Image By Keith Tanner
Photo by Keith Tanner

Final Thoughts

react-markdown's custom component capability is quite powerful. I have chosen to implement the Gatsby style JSON metastring to extend my markdown but you could take the concepts I've presented and run with them in any direction you see fit.

Did you enjoy this post?

amirardalan.eth QR Code

Copied to clipboard ✅