Using NextJS and Next/Image with MDX Markdown Processing

NextJS has recently announced greater support for markdown-powered blogs. This allows a developer to create a statically-generated website whose content originates from markdown pages.

After playing around with the blog starter, I noticed that the generated markup wasn't sufficient for my requirements. I wanted to make use of image optimisation provided by next/image but this wasn't evidently supported out-of-the-box with the blog starter. The starter repo makes use of remark to process the markdown - which is fine if basic HTML markup is enough - but its extensibility wasn't immediately evident or clear for my use case, so I needed to look for alternatives.

Enter react-markdown - a wrapper round remark that gives us more flexibility with the processed markup (in the form of AST nodes), and allows us to intercept/serve different components to suit our needs. So in this case, I want to serve next/image for any image tag that exists within the markdown.

Let's go ahead and bring down blog-starter-app and make our change (with a very contrived example!).

Setup

Download the blog starter app as instructed here, then navigate to the dynamic routing post.

You should now see the following page:

Hello world page with no changes
Hello World Page

We now need to add an image into the markdown content to test this (because the image visible in the page is currently served from the frontmatter and handled by components/cover-image.js).

Add the following line directly below the frontmatter of _posts/hello-world.md so that it renders at the top of the markdown's content:

![NextJS logo](https://upload.wikimedia.org/wikipedia/commons/thumb/8/8e/Nextjs-logo.svg/440px-Nextjs-logo.svg.png "NextJS Logo")

Refresh the page and you should be able to see the logo in the page:

Hello world post with NextJS image
Hello World Post with NextJS Image

Now that we're all set up, let's make a change to the code to serve next/image for images.

The Approach

Let's inspect the image to see what the markup looks like in Developer Tools:

<p>
  <img
    src="https://upload.wikimedia.org/wikipedia/commons/thumb/8/8e/Nextjs-logo.svg/440px-Nextjs-logo.svg.png"
    alt="NextJS logo"
    title="NextJS Logo"
  />
</p>

That's just a plain old image tag, but we want to make use of next/image.

Let's take a look at the code that transforms the markup:

// File: lib/markdownToHtml.js

import remark from "remark"
import html from "remark-html"

export default async function markdownToHtml(markdown) {
  const result = await remark()
    .use(html)
    .process(markdown)
  return result.toString()
}

This function takes a raw string-representation of a markdown file's content (as produced by the gray-matter npm module within getPostBySlug in lib/api.js:L15) as an input and then transforms it into a string-representation of HTML markup. Because we would rather deal with processed JSX - and not a string of HTML - we're not going to call this function anymore.

So go to the getStaticProps method of the dynamic page pages/posts/[slug].js:L59 and replace it with the following code:

export async function getStaticProps({ params }) {
  const post = getPostBySlug(params.slug, [
    "title",
    "date",
    "slug",
    "author",
    "content",
    "ogImage",
    "coverImage",
  ])

  return {
    props: {
      post,
    },
  }
}

Refresh the page in your browser. Now you should see the following:

Raw page content with no HTML formatting
Raw page content with no HTML formatting

We now need to process this raw markdown - this is where react-markdown comes into play. Install the module by running the following command in the terminal:

npm install react-markdown

Then open components/post-body.js in your editor. Looking at the original code, we can see that the processed markdown (provided by remark) is simply passed to dangerouslySetInnerHTML of a <div /> element - which is very insecure.

Now replace the contents of that file with the following code:

import ReactMarkdown from "react-markdown"
import Image from "next/image"
import markdownStyles from "./markdown-styles.module.css"

const renderers = {
  image: image => {
    return <Image src={image.src} alt={image.alt} height="200" width="355" />
  },
}

export default function PostBody({ content }) {
  return (
    <div className="max-w-2xl mx-auto">
      <ReactMarkdown
        className={markdownStyles["markdown"]}
        children={content}
        renderers={renderers}
      />
    </div>
  )
}

The renderer object is the section of interest here. This object allows us to 'intercept' and manipulate the node types listed here. In this case, we're returning the next/image component for all image types.

We also need to update next.config.js in the root of the project to tell NextJS about the domain of which to optimise images for. Add the following to youe next.config.js file:

module.exports = {
  images: {
    domains: ["upload.wikimedia.org"],
  },
}

Restart your development server and refresh the page. You'll now be able to see the markup correctly formatted. Also note how next/image has done its stuff on the image by looking at its now-extensive markup in Developer Tools:

Markdown in post showing with NextJS Image
Markdown in post showing with NextJS Image

This now raises an issue - if you look at the error in the console and then back to the markup for the image:

Warning: validateDOMNesting(...): <div> cannot appear as a descendant of <p>.
    in div (created by Image)
    in div (created by Image)

..this is because next/image wraps the image in a div that then gets placed inside a p tag - which is invalid. So let's add an additional renderer to fix this. Add the following renderer to your const renderers = ... variable:

  paragraph: (paragraph) => {
    const { node } = paragraph;
    if (node.children[0].type === "image") {
      const image = node.children[0];
      return <Image src={image.url} alt={image.alt} height="200" width="355" />;
    }

    return <p>{paragraph.children}</p>;
  },

All this does is return just the image (and not its enclosing paragraph tag) if an <img /> tag is a descendant of a <p /> tag. Else it returns a <p /> tag as normal.

Conclusion

That's it! With such a fairly small change but big impact, we now have so much more control over our markup.

Granted, this is a contrived example and it opens up more questions with regards to how far you could actually go with this method and how messy it would inevitably get. But, it opens up the door to the possibilities of what we can do with plain old markdown within a react app.

Comments