Using MDX with NextJS (and when to go Gatsby)

05 May, 2020

Recently I've been exploring the potential integrating MDX into NextJS and seeing how it handles common use cases for websites (like blogs, docs, etc). I've become a big fan of MDX, it's authoring experience is the equivalent of writing with Wordpress using shortcodes — on steroids (Guten-who?).

MDX is an extension of the Markdown language and adds the ability to write Javascript and JSX (like React components) inside your files alongside Markdown syntax context. It allows you to do things like import React components to use inside of posts, or even write them inline:

export const Highlight = ({children, color}) => (
<span
style={{
backgroundColor: color === 'green' ? "#25c2a0" : "#1877F2",
borderRadius: '2px',
color: '#fff',
padding: '0.2rem',
}}>
{children}
</span>
);
<Highlight color="green">Green</Highlight> and <Highlight>blue</Highlight> are my favorite colors.
I can write **Markdown** alongside my _JSX_!

NextJS is a framework for developing React applications. In this case, we're leveraging their static build capabilities and creating a prerendered version of the website.

As of writing, Next has a plugin for MDX that allow for authoring pages as MDX files. So rather than creating a page as /pages/your-page.jsx — you create a /pages/your-page.mdx file. Combined with Next's static building, it becomes an excellent option for creating blogs, docs, or even landing pages.

Let's see how it works in practice and how well it scales. But first, here's a list of starter projects to get you going 👇🏼

📦 Example projects

I setup a new NextJS project (v9.3.4) with MDX and created branches for each stage of the development. That way if you want the most base install possible, you can use that branch. Or if you're interested in using Styled Components, there's a branch progressed to that stage as well. You can also go through each step and it's git commits to see what's integral for each integration.

Basic install

🛠 Test it out on CodeSandbox

./Screen_Shot_2020-04-28_at_2.38.35_PM.png

  • Write content as MDX. Either directly in /pages/ or import MDX into React pages.

Custom Components

🛠 Test it out on CodeSandbox

  • Pass custom components into the MDX Provider (e.g. swap <h1> with a React component)

Basic example

🛠 Test it out on CodeSandbox

  • Merges "Basic Install" and "Custom Components"

Basic layouts

🛠 Test it out on CodeSandbox

  • Example of using different post layouts

Styled Components

🛠 Test it out on CodeSandbox

  • Integrated with Styled Components.
  • App is wrapped with a custom theme
  • Dynamic stylesheets are written on build and updated client-side

Prettier + ESLint

🛠 Test it out on CodeSandbox

  • Prettier integrated
  • Prettier configured for NextJS
  • ESLint integrated.
  • ESLint configured with Airbnb as base, and NextJS custom rules on top.
    • Accessibility rules (jsx-a11y) included
  • Prettier and ESLint integrated together.

Styled + Prettier

🛠 Test it out on CodeSandbox

  • Merges "Styled Components" and "Prettier + ESLint" branches

Typescript

🛠 Test it out on CodeSandbox

  • Same features as basic install
  • Integrates Typescript
  • Adds package.json script for linting TS/TSX files

Typescript + Linting

🛠 Test it out on CodeSandbox

  • Same features as Typescript
  • Kinda merges Prettier branch, but swaps babel-eslint for Typescript specific parsers
  • Includes tsconfig.json
  • Properly typed (complete with MDX types)

Chakra UI

🛠 Test it out on CodeSandbox

./Screen_Shot_2020-04-28_at_2.36.08_PM.png

  • Merges "Styled Components" and "Prettier + ESLint" branches
  • Integrated Chakra UI
  • Use any Chakra UI components inside MDX without imports
  • Replaced Styled Components with Emotion (with proper NextJS integration)
  • Added a sample <Header /> component (from Chakra UI recipes) to the sample layout

Chakra UI + Framer Motion

  • Same features as Chakra UI
  • Integrates framer-motion library
  • Page transition animations (exiting and entering)
  • Component-level transition animations (entering and hovering)

How does it work?

NextJS has a plugin for MDX called @next/mdx. The plugin detects most MDX files in the project and parses them with Webpack (using @mdx-js/loader). You install both these dependencies and add the plugin to your Next config:

npm i @next/mdx @mdx-js/loader

next.config.js:

const withMDX = require('@next/mdx')({
extension: /\.mdx?$/,
// Or use this to support MD files too
// extension: /\.(md|mdx)$/,
})

In order to detect MDX files in pages directory (where NextJS picks up React page component automatically normally), you have to add MDX to the 'page file extensions' parameter in the Next config file:

next.config.js:

module.exports = withMDX({
pageExtensions: ['js', 'jsx', 'mdx'],
})

MDX page content is loaded automatically into the NextJS app wrapper. You can customize this by adding a _app.js file to your project. In this file, you can change the page layout of all MDX files, usually by wrapping the content in a layout component. In this case, we provide MDX with more components using it's context provider:

_app.js:

import React from 'react'
import {MDXProvider} from '@mdx-js/react'
const mdComponents = {
h1: props => <h1 style={{color: 'tomato'}} {...props} />
}
export default ({Component, pageProps}) => (
<MDXProvider components={mdComponents}>
<Component {...pageProps} />
</MDXProvider>
)

And that's all it takes to make a NextJS site where you can write MDX files inside the pages directory and display them on the frontend! And the last step isn't necessary, but makes it easier to bring new, custom components - or overwrite HTML elements with React components.

Creating MDX pages

There are a few ways to create pages in NextJS that use MDX content. Whether it's directly, where the MDX file is the page, or a React component that includes MDX content inside of it — there are a few avenues to approach when architecting your frontend.

/pages/ directory

Place MDX files directly in /pages/ for them to show. /pages/your-page.mdx becomes [http://localhost:3000/your-page/](http://localhost:3000/your-page/) on the frontend.

Import MDX files

Import the MDX files like you would any other React component or NPM dependency. This gives you a parsed version of the MDX courtesy of Webpack on build. Great for authoring content as MDX and importing into React pages.

import About from "./about.mdx"
const TestPage = () => {
return (
<div>
<div>Test</div>
<About />
</div>
)
}
export default TestPage

Dynamic import

When you need to load the MDX content on demand, like for a post archive, you can use Next's dynamic utility. It's normally used for importing React component dynamically (like React.lazy), but instead we leverage it for MDX (since it requires Webpack parsing, just like React components).

import dynamic from 'next/dynamic'
const BlogPostPage = ({ filename }) => {
console.log('the filename', filename)
const MDXContent = dynamic(() => import(`../../../${filename}`))
// Not convinced this works, but this is theoretically how it happens
const MDXMetadata = dynamic(() => import(`../../../${filename}`).then(mod => mod.metadata))
console.log('the content', MDXMetadata)
return (
<div>
<h1>Blog</h1>
<MDXContent />
<h2>Blog Title: {MDXMetadata.title}</h2>
<h2>Date: {MDXMetadata.date}</h2>
</div>
)
}

The path provided to the dynamic import is relative to the page component (<BlogPostPage>), and you can't rely on Node's path. Also note here, the metadata is an object that is exported from the MDX file (export const metadata), and it's pulled out from dynamic using a Promise. You can read more about @next/dynamic here or exporting variables from MDX here.

Common Issues

Despite the setup being immensely easy to accomplish, and incredibly powerful, it suffers from it's simplicity. Here are the common issues I discovered while adding features, as well as any workarounds I found.

Page layouts

In the past, when integrating Markdown with NextJS, you'd parse the Markdown content, check the frontmatter, see if there was a "section" or "layout" parameter, and swap the layout based off that. It's a common practice in most blogs with different "post types". With Next and Markdown, this all would happen inside a dynamic page component (like /pages/blog/[slug].js) where you'd use the the NodeJS filesystem (fs) to query for the content (and inevitably parse it).

With MDX, it's a much simpler process. You import the layout component and wrap you MDX content in it:

import AboutPage from "../layouts/AboutPage"
<AboutPage>
# About
A test MDX page.
</AboutPage>

This is much simpler than the alternative, where you're forced to parse the MDX (see below).

You can also wrap your entire website and all MDX content in a single layout by using the _app.js wrapper (see above for an example).

Frontmatter is a problem

By default, the MDX plugin for Next doesn't support frontmatter. If you include frontmatter inside your MDX file, it'll print out inside the content area of the post 🤮 If you're migrating an existing content library to Next, and you have to use MDX, make sure you're not using frontmatter in any posts (or have an efficient way to convert it to the method below).

The way you handle frontmatter in MDX is a little different. Since MDX allows you to write Javascript inside your files, like importing and using React components, you can also export data. According to the MDX docs, this is how it looks like:

import { sue, fred } from '../data/authors'
export const metadata = {
authors: [sue, fred]
}
# Post about MDX
MDX is a JSX in Markdown loader, parser, and renderer for ambitious projects.

And when you want to use the metadata (i.e. frontmatter), you import it as a module alongside the post content:

import React from 'react'
import MDXContent, {metadata} from 'posts/post.mdx'
export default () => (
<>
<MDXContent />
<footer>
<p>By: {metadata.authors.map(author => author.name).join(', ') + '.'}</p>
</footer>
</>
)

This works great if you're importing your content directly into your pages (like the second method above in the page creation section).

The only issue? You can't get this dynamically, since MDX files require Webpack parsing to access the metadata like this. Instead, it's recommend you load the data using Node's filesystem, then parse the MDX file manually using MDX AST. Then you can walk through the tree to find the metadata object.

{
"type": "root",
"children": [
{
"type": "import",
"value": "import { sue, fred } from '../data/authors'\n",
"position": {
"start": {
"line": 1,
"column": 1,
"offset": 0
},
"end": {
"line": 2,
"column": 1,
"offset": 44
},
"indent": [
1
]
}
},
{
"type": "export",
"value": "export const metadata = {\n authors: [sue, fred]\n}",
"position": {
"start": {
"line": 2,
"column": 1,
"offset": 44
},
"end": {
"line": 4,
"column": 2,
"offset": 94
},
"indent": [
1,
1
]
}
},

It's not clear how to get the AST however? The docs don't make it clear anywhere how to parse MDX manually without some sort of plugin for a framework (like CRA or Next). I'm assuming you use the MDX loader for Webpack, but it's not clear how to use it on demand and dynamically.

Post Archives / Post Summaries

You use the getStaticProps method in your page (like a blog archive pages/blog/archive.js). In the method, you use Node's fs method to access the MDX content directory. Then you can loop over the posts however you need.

Since you will likely need frontmatter, you'll need to parse the MDX as an AST and navigate it to find that. Here you can also try to pull a blog summary by avoiding any JS that may be at the top of the MDX.

The major issue with this is the amount of processing required. Each page that references the MDX content will be required to parse it separately, since each page executes it's own getStaticProps lifecycle. You could try to cache the results? Or pass a huge data object around the client-side context. But at that point, you might want to explore options like Gatsby that create a unified data layer with GraphQL. Getting frontmatter data is as easy as a GraphQL query.

Example of an archive page:

export async function getStaticProps({ params }) {
const postsDirectory = path.join(process.cwd(), 'posts')
const mdxFiles = fs.readdirSync(postsDirectory)
// const mdxFiles = fs.readdirSync("posts")
// Loop through all post files and create array of slugs (to create links)
const paths = files.map(filename => ({ slug: filename.replace(".mdx", "") }));
// Optionally loop through files, get content, and parse frontmatter
const postsWithFrontmatter = files.map(filename => {
const postContent = fs.readFileSync(path.join("posts", params.slug + ".mdx")).toString();
// Dont do this.
// const frontmatter = matter(postContent)
// Parse the MDX as an AST instead
// Use the MDX library to parse here "server-side"
// Pass the parsed data back to page component below
return ({
slug: filename.replace(".mdx", ""),
frontmatter
})
})
return {
props: {
posts: paths
// or posts: postsWithFrontmatter
}
}
}

Example of a single page:

Here we use the @next/dynamic library to dynamically load the MDX data using Webpack (since Webpack parses the content for us, and without it we'd have a giant unparsed string of MDX).

pages/blog/post/[slug].js:

import fs from 'fs'
import path from 'path'
import dynamic from 'next/dynamic'
const BlogPostPage = ({ filename }) => {
console.log('the filename', filename)
const MDXContent = dynamic(() => import(`../../../${filename}`))
// Official syntax - but doesn't work for me personally?
const MDXMetadata = dynamic(() => import(`../../../${filename}`).then(mod => mod.metadata))
console.log('the content', MDXMetadata)
return (
<div>
<h1>Blog</h1>
<MDXContent />
<h2>Blog Title: {MDXMetadata.title}</h2>
<h2>Date: {MDXMetadata.date}</h2>
</div>
)
}
export async function getStaticProps({ params }) {
const filename = path.join("content/blog", params.slug + ".mdx")
// const frontmatter = matter(mdxPost)
return {
props: {
filename
},
}
}
export async function getStaticPaths() {
const postsDirectory = path.join(process.cwd(), 'content/blog')
const mdxFiles = fs.readdirSync(postsDirectory)
console.log('the queried pages', mdxFiles)
// Loop through all post files and create array of slugs (to create links)
const paths = mdxFiles.map(filename => ({
params: {
slug: filename.replace(".mdx", "")
}
}));
return {
paths,
fallback: false
}
}
export default BlogPostPage

Linting MDX

Writing MDX is great, but with any declarative syntax it can be easy to make a small mistake that breaks the entire code. MDX supports linting using ESLint, which statically analyzes your MDX content and checks if it passes predetermined rules. This works great for detecting small issues while writing.

yarn add -D eslint-plugin-mdx

However, it breaks when you try to use MDX for what it's for. For example, when you want to wrap a page in a certain layout, you just wrap the entire MDX file's content in a React component that contains the layout (see above "Page Layouts").

<PageLayout>
# MDX Page
This content prints correctly. But linting will fail at the code block.
```js
const TestComponent = () => {
return(<div>Test</div>)
}
```
All this content will look improperly colored in the code editor now.
</PageLayout>

The issue with this style of writing is that we're mixing Markdown and JSX. There's usually not an issue with it, but it has some odd edge cases that you'll encounter quickly. Ideally if you write JSX, you shouldn't place Markdown inside it. It works, the MDX parser will convert Markdown inside React components into HTML. But the linter, on the other hand, has some issues parsing the data according to it's rules.

If you include a code example that uses Javascript inside your MDX using "code fences" (or the three tildes followed by the language) it will break the linter. The issue lies with the way our MDX file is setup. We wrap the Markdown/MDX content in a React component, which triggers the JSX linting rules. Because of this, ESLint now checks our Markdown content for JSX that may break it. It doesn't stop until the component is closed, which is an issue, since it has to wrap the whole page.

This doesn't seem like a big issue, but it defeats the purpose of using MDX. The point is to be able to mix Markdown and JSX. If you can't do simple things like page layouts and you're forced back into full JSX (when using JSX), it ruins the authoring experience. It discourages the use of JSX since the author will have to format all nested content in JSX or HTML, which is the tedium we sought to escape with MDX.

Thankfully this is simply the linting process, not the build, and can be improved. The code works, just fails testing, which isn't a deal breaker for smaller projects.

Displaying 404 for pages that don't exist

If you use the getStaticPaths method in any dynamic pages, NextJS makes a static map of all your dynamic routes (like blog posts). If a page isn't included in the static path method, a 404 will be displayed.

If for whatever reason the component doesn't receive the post data it needs to render, like a slug, you can return NextJS' error page:

import { useRouter } from 'next/router'
import ErrorPage from 'next/error'
// Place somewhere inside the page component's render cycle
const router = useRouter()
if (!router.isFallback && !post?.slug) {
return <ErrorPage statusCode={404} />
}

Handling emojis and image URLs

MDX supports remark plugins, so you can use plugins like remark-images and remark-emoji.

Images allows you to write image URLs directly (e.g. "http://my.domain.app/image.png") and it converts it to a Markdown friendly image syntax. The emoji plugin allows you to write emojis in the Markdown syntax (e.g. :dog: becomes 🐶).

next.config.js:

const images = require('remark-images')
const emoji = require('remark-emoji')
const withMDX = require('@next/mdx')({
extension: /\.mdx?$/,
options: {
mdPlugins: [images, emoji]
}
})

Take the Next step to MDX

I hope this article was able to enlighten you on the process for handling MDX with Next, as well as it how it compares to a similar and popular framework - Gatsby. And with plenty of starter templates in a variety of React flavors, you've got no excuse to try it out today!

If you have any questions or want to show off your MDX project in Next, comment here or hit me up on Twitter!

References

🔗 This post was filed under

Blue square avatar white centered hiragana text reading Ryosuke

@Ryosuke2 months ago