Blending Markdown and React components in NextJS

Authoring long-form content like blog posts is a pleasant experience with Markdown as it lets you focus on the content without worrying about the presentation or making the browser happy. Spamming <p> and <div> tags all over the place is a PITA and serves as a distraction from the content you're working on.

However, in a blog like this one, which deals with a lot of React/node/nextjs content, static text and images are limiting. We really want our React components to be live on the page with all of the richness and composability that React and JSX bring - so how do we blend the best of both of these worlds?

MDX: Markdown plus React

MDX is an extension to Markdown that also allows you to import and use React components. It lets you write content like this:

mycontent.mdx
MDX is a blend of:

- normal markdown
- React components

<Aside type="info">
This blue box is an custom React component called `<Aside>`, and it can be rendered by MDX along
with the other Markdown content.
</Aside>
mycontent.mdx
MDX is a blend of:

- normal markdown
- React components

<Aside type="info">
This blue box is an custom React component called `<Aside>`, and it can be rendered by MDX along
with the other Markdown content.
</Aside>

That's rendering an <Aside> component, which is a simple React component I use in some of my posts and looks like this:

That's really cool, and we can basically use any React component(s) we like here. But first let's talk a little about metadata.

Metadata matters

A document is not just a document - it has a bunch of associated metadata like a publication status, a title, maybe some tags, timestamps, a summary, a canonical url, potentially author information and any number of other pieces of data that are not the document itself, but data about the document.

When it comes to things like text documents, metadata should be co-located with the content - ideally in the same file. The nature of metadata is usually quite different from text content, though - metadata typically has some structure to it and is well suited to a format like JSON or YAML.

Thankfully, markdown has a concept known as "Frontmatter", which is really just a yaml block shoved into the top of a markdown file. The frontmatter metadata for this very post looks like this:

---
slug: using-markdown-with-nextjs
status: publish
title: Blending Markdown and React components in NextJS
tags:
- nextjs
- react
- vercel
- rsc
- ui
- mdx
date: '2024-08-28 06:31:02'
images:
- /images/posts/mdx-content.png
description: >-
Markdown is a really nice way to write content like blog posts,
---
---
slug: using-markdown-with-nextjs
status: publish
title: Blending Markdown and React components in NextJS
tags:
- nextjs
- react
- vercel
- rsc
- ui
- mdx
date: '2024-08-28 06:31:02'
images:
- /images/posts/mdx-content.png
description: >-
Markdown is a really nice way to write content like blog posts,
---

Unfortunately, while NextJS does have native support for markdown content, it does not support frontmatter out of the box. There may be instances where you don't really need any metadata, or can come up with some other way to handle it (I used to use a JSON file), but life is so much easier when you can use frontmatter.

Thankfully, it's pretty easy to do this using MDXRemote.

Rendering MDX content

MDXRemote is a library that lets you render a string containing MDX content. That's useful if you are loading content from a database or something, but there's nothing stopping you from just reading file data and passing that in as a prop. Well, almost nothing - we've got to do something about that frontmatter first.

There are a few ways to do that - here's an approach I like using a library called gray-matter:

import matter from 'gray-matter'

const source = fs.readFileSync(file)
const { data, content } = matter(source)

//this is now a JavaScript object of all the frontmatter yaml
console.log(data)

//this is the markdown content, not yet processed, but with the frontmatter stripped out
console.log(content)
import matter from 'gray-matter'

const source = fs.readFileSync(file)
const { data, content } = matter(source)

//this is now a JavaScript object of all the frontmatter yaml
console.log(data)

//this is the markdown content, not yet processed, but with the frontmatter stripped out
console.log(content)

Ok so we used gray-matter to process our MDX file into a JS object for the metadata, and a string for everything else, but that everything else - the content - is still Markdown. Let's turn it into HTML now using <MDXRemote>.

Here's the actual MarkdownContent.tsx that is used to power the RSC Examples:

MarkdownContent.tsx
import remarkGfm from 'remark-gfm'
import { Code } from 'bright'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { Callout } from './Callout'
import CaptionedContent from './CaptionedContent'
import Figure from './Figure'

Code.theme = {
dark: 'github-dark',
light: 'github-light',
}

Code.defaultProps = {
lang: 'shell',
theme: 'github-light',
}

const mdxOptions = {
remarkPlugins: [remarkGfm], //adds support for tables
rehypePlugins: [],
}

const components = {
pre: Code,
Callout,
Figure,
CaptionedContent,

//just colors any `inline code stuff` blue
code: (props: object) => (
<code style={{ color: 'rgb(0, 92, 197)' }} {...props} />
),
}

export default function MarkdownContent({ content }: { content: string }) {
return (
<MDXRemote
options={{ mdxOptions }}
source={content}
components={components}
/>
)
}
MarkdownContent.tsx
import remarkGfm from 'remark-gfm'
import { Code } from 'bright'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { Callout } from './Callout'
import CaptionedContent from './CaptionedContent'
import Figure from './Figure'

Code.theme = {
dark: 'github-dark',
light: 'github-light',
}

Code.defaultProps = {
lang: 'shell',
theme: 'github-light',
}

const mdxOptions = {
remarkPlugins: [remarkGfm], //adds support for tables
rehypePlugins: [],
}

const components = {
pre: Code,
Callout,
Figure,
CaptionedContent,

//just colors any `inline code stuff` blue
code: (props: object) => (
<code style={{ color: 'rgb(0, 92, 197)' }} {...props} />
),
}

export default function MarkdownContent({ content }: { content: string }) {
return (
<MDXRemote
options={{ mdxOptions }}
source={content}
components={components}
/>
)
}

MarkdownContent.tsx shows off several of the capabilities of MDXRemote:

  • mdxOptions - allow us to pass in whatever remark/rehype plugins we like (I'm just using remark-gfm here to support Markdown tables)
  • source - is just the source string we saw in the previous code block, passed in as a React prop
  • components - allows us to render our custom React components like <Callout> and <Figure>

The components prop is where the interesting stuff is happening. By passing in Callout, Figure and CaptionedContent there - all of which are React components imported above - we can start putting content like <Callout type="warning">Be careful!</Callout> directly in our .mdx files (Callout is basically the same as the Aside component I use on the blog).

Here is also where we support syntax highlighting via the awesome Bright syntax highlighter. That's what turns our code snippets (delineated by ```, which markdown turns into a <pre>) into beautifully syntax highlighted blocks of HTML. It's the same library I use for the syntax highlighting on this blog.

Where to store the content

You could store your MDX content anywhere, including inside a database, but generally it's easier to save them as files in your git repo. Not only is this one less dependency, but you get all the things like file histories, branching and reversion for free.

For the RSC Examples app, I wanted people to be able to get value by browsing the repo as much as by browsing the app itself. Most of the examples are just an .mdx file and a .tsx file - one explaining the example, the other executing it. By structuring things this way, you can grok an example like this one directly in the repo almost as well as you can by playing with the live example itself.

A non-database database

Keeping the content in source control is great, but you probably want to have things like index pages that list out the content, some kind of search, filtering by tag, or other dynamic functionality that requires your app to have some kind of database of all of your .mdx content.

In previous iterations of my blog app I kept a JSON file that acted as a sort of manifest of all of the posts I had written. It was annoying to have to keep switching from the .md file to the .json file to add metadata, but it did provide a "database" of all the content on the site.

Once I migrated to .mdx I was able to write a really piece of code that would just find all the .mdx files nested in some directory, parse the frontmatter using gray-matter and expose a couple of utility functions like getting the content, ready to be passed into <MarkdownContent>.

I do basically the exact same thing with this simple Examples class inside the RSC Examples repo. If you are planning on having tens of thousands of .mdx files then you'd probably want to consider a different approach, but this ~100 lines of TypeScript makes it easy to do everything I need without adding the dependency of a database while keeping builds fast and tooling unnecessary.

The page.tsx inside RSC Examples that actually renders the content now becomes pretty simple:

page.tsx
export default async function Page({ params }: Props) {
const slug = params.slug.join('/')
const examples = new Examples()
const example = examples.publishedExamples.find(
(example: any) => example.slug === slug,
)

if (!example) {
return notFound()
}

const content = examples.getContent(example)

return (
<DocsLayout frontmatter={example}>
<MarkdownContent content={content} />
</DocsLayout>
)
}
page.tsx
export default async function Page({ params }: Props) {
const slug = params.slug.join('/')
const examples = new Examples()
const example = examples.publishedExamples.find(
(example: any) => example.slug === slug,
)

if (!example) {
return notFound()
}

const content = examples.getContent(example)

return (
<DocsLayout frontmatter={example}>
<MarkdownContent content={content} />
</DocsLayout>
)
}

Server-side rendering friendliness

Both my blog and the RSC Examples site are Next JS applications, hosted on the Vercel platform, and making heavy (almost exclusive) use of static server-side rendering. That's why both sites are generally pretty instantaneous to load - most of the work was done at deploy time, while still allowing for interactivity where it's needed.

Looking at our page.tsx again, the generateStaticParams function is super easy to implement, and allows Next JS to build all of our Examples as static content at deploy time:

page.tsx
export function generateStaticParams() {
const { publishedExamples } = new Examples()

const all = publishedExamples.map((example: any) => ({
slug: example.slug.split('/'),
}))

return all
}
page.tsx
export function generateStaticParams() {
const { publishedExamples } = new Examples()

const all = publishedExamples.map((example: any) => ({
slug: example.slug.split('/'),
}))

return all
}

Not only does this make our content cheap and easy to host, it makes it blazing fast too, while still supporting as much interactivity as we need. Even though the .mdx files are rendered server-side, we can still render rich, interactive React components that run on the client side, like this little InformAI-driven chatbot that's running live inside this page and lets you ask questions about this post:

That's a live client-side component rendered inside a .mdx file at build time inside a static, server-rendered page. It's awesome how well this all works together. To find out more, take a poke around the RSC Example GitHub repo or ping me on twitter.

Share Post:

What to Read Next