Converting MDX files with frontmatter into an MDX Component in Next.js 13
MDX is a superset of markdown that lets you write JSX directly in markdown files. If you're using Next.js (this post was written for Next.js 13 specifically), you might know that it has a built-in support for MDX. You can read more about how to configure it in the docs.
The most basic way to use it looks like this:
import HelloWorld from './hello.mdx'
export default function Page() {
return <HelloWorld />
}
import HelloWorld from './hello.mdx'
export default function Page() {
return <HelloWorld />
}
If you get the files dynamically, though, and use a frontmatter, things might be a bit confusing.
The MDX documentation recommends using a remark plugin, remark-frontmatter
to ignore the frontmatter and get only the MDX content like this:
import fs from 'node:fs/promises'
import remarkFrontmatter from 'remark-frontmatter'
import {compile} from '@mdx-js/mdx'
console.log(
await compile(await fs.readFile(`./posts/${params.slug}.mdx`), {
remarkPlugins: [remarkFrontmatter]
})
)
import fs from 'node:fs/promises'
import remarkFrontmatter from 'remark-frontmatter'
import {compile} from '@mdx-js/mdx'
console.log(
await compile(await fs.readFile(`./posts/${params.slug}.mdx`), {
remarkPlugins: [remarkFrontmatter]
})
)
Say, if we have a hello.mdx
file that looks something like this:
---
title: Hello world
---
# Hi
This is an example MDX file.
---
title: Hello world
---
# Hi
This is an example MDX file.
What is logged for that hello.mdx
looks like this:
VFile {
data: {},
messages: [],
history: [],
cwd: '/Users/me/projects/demo-site',
value: '/*@jsxRuntime automatic @jsxImportSource react*/\n' +
'import {Fragment as _Fragment, jsxDEV as _jsxDEV} from "react/jsx-dev-runtime";\n' +
'function _createMdxContent(props) {\n' +
' const _components = Object.assign({\n' +
' h1: "h1",\n' +
' p: "p"\n' +
' }, props.components);\n' +
' return _jsxDEV(_Fragment, {\n' +
' children: [_jsxDEV(_components.h1, {\n' +
' children: "Hi"\n' +
' }, undefined, false, {\n' +
' fileName: "<source.js>",\n' +
' lineNumber: 5,\n' +
' columnNumber: 1\n' +
' }, this), "\\n", _jsxDEV(_components.p, {\n' +
' children: "This is an example MDX file."\n' +
' }, undefined, false, {\n' +
' fileName: "<source.js>",\n' +
' lineNumber: 6,\n' +
' columnNumber: 1\n' +
' }, this)]\n' +
' }, undefined, true, {\n' +
' fileName: "<source.js>",\n' +
' lineNumber: 1,\n' +
' columnNumber: 1\n' +
' }, this);\n' +
'}\n' +
'function MDXContent(props = {}) {\n' +
' const {wrapper: MDXLayout} = props.components || ({});\n' +
' return MDXLayout ? _jsxDEV(MDXLayout, Object.assign({}, props, {\n' +
' children: _jsxDEV(_createMdxContent, props, undefined, false, {\n' +
' fileName: "<source.js>"\n' +
' }, this)\n' +
' }), undefined, false, {\n' +
' fileName: "<source.js>"\n' +
' }, this) : _createMdxContent(props);\n' +
'}\n' +
'export default MDXContent;\n',
map: undefined
}
VFile {
data: {},
messages: [],
history: [],
cwd: '/Users/me/projects/demo-site',
value: '/*@jsxRuntime automatic @jsxImportSource react*/\n' +
'import {Fragment as _Fragment, jsxDEV as _jsxDEV} from "react/jsx-dev-runtime";\n' +
'function _createMdxContent(props) {\n' +
' const _components = Object.assign({\n' +
' h1: "h1",\n' +
' p: "p"\n' +
' }, props.components);\n' +
' return _jsxDEV(_Fragment, {\n' +
' children: [_jsxDEV(_components.h1, {\n' +
' children: "Hi"\n' +
' }, undefined, false, {\n' +
' fileName: "<source.js>",\n' +
' lineNumber: 5,\n' +
' columnNumber: 1\n' +
' }, this), "\\n", _jsxDEV(_components.p, {\n' +
' children: "This is an example MDX file."\n' +
' }, undefined, false, {\n' +
' fileName: "<source.js>",\n' +
' lineNumber: 6,\n' +
' columnNumber: 1\n' +
' }, this)]\n' +
' }, undefined, true, {\n' +
' fileName: "<source.js>",\n' +
' lineNumber: 1,\n' +
' columnNumber: 1\n' +
' }, this);\n' +
'}\n' +
'function MDXContent(props = {}) {\n' +
' const {wrapper: MDXLayout} = props.components || ({});\n' +
' return MDXLayout ? _jsxDEV(MDXLayout, Object.assign({}, props, {\n' +
' children: _jsxDEV(_createMdxContent, props, undefined, false, {\n' +
' fileName: "<source.js>"\n' +
' }, this)\n' +
' }), undefined, false, {\n' +
' fileName: "<source.js>"\n' +
' }, this) : _createMdxContent(props);\n' +
'}\n' +
'export default MDXContent;\n',
map: undefined
}
That is an object called VFile
.
What matters is its value
because that is the function body that we are going to run to compile MDX content into JavaScript.
In order to do that, we need to add the outputFormat
option as 'function-body'
to the compile()
function, and also change the development to false
. We also convert it into a String
:
import fs from 'node:fs/promises'
import remarkFrontmatter from 'remark-frontmatter'
import {compile} from '@mdx-js/mdx'
const code = String(
await compile(
await fs.readFile(`./posts/${params.slug}.mdx`, {
remarkPlugins: [remarkFrontmatter],
outputFormat: 'function-body',
development: false
/* …otherOptions */
})
)
)
import fs from 'node:fs/promises'
import remarkFrontmatter from 'remark-frontmatter'
import {compile} from '@mdx-js/mdx'
const code = String(
await compile(
await fs.readFile(`./posts/${params.slug}.mdx`, {
remarkPlugins: [remarkFrontmatter],
outputFormat: 'function-body',
development: false
/* …otherOptions */
})
)
)
The default outputFormat
of compile()
is 'program'
, it uses import
and export
statements, but we need to change it into function-body
, otherwise we'll get a dreaded error: Error: Cannot use import statement outside a module
.
These are not so important details, you can read more in the docs.
The only thing left is to run it, and we can finally return it as an MDX component with these two more lines:
const {default: Content} = await run(code, runtime)
return <Content />
const {default: Content} = await run(code, runtime)
return <Content />
Putting it together, what we have written looks like this:
export default async function Page({ params }: {
params: { slug: string }
}) {
const code = String(
await compile(await fs.readFile(`./posts/${params.slug}.mdx`), {
remarkPlugins: [remarkFrontmatter],
outputFormat: 'function-body',
development: false
})
)
const {default: Content} = await run(code, runtime)
return <Content />
}
export default async function Page({ params }: {
params: { slug: string }
}) {
const code = String(
await compile(await fs.readFile(`./posts/${params.slug}.mdx`), {
remarkPlugins: [remarkFrontmatter],
outputFormat: 'function-body',
development: false
})
)
const {default: Content} = await run(code, runtime)
return <Content />
}
We can do many different things such as overriding the components of Content
, using plugins to add KateX support, etc. MDX offers a lot of flexibility.
As always, the first place to check out more of what we can do is the official docs.