How to Build Your Own Blog with Next.js and MDX
By Caleb Olojo
When I decided to build my blog, I found many tools out there that were readily available. I looked at Gastby along with content management systems like Ghost, Contentful, Sanity dot io, and HUGO.
But I needed something that I could have total control over. I've also always been someone who loves the flexibility of writing own my custom code. When I do this, I can conveniently go back to where any issues might be when a problem arises.
Gatsby provides this flexibility, and it is something I could get familiar with pretty easily since it's built on a library I use every day (React.js). But, I found out that I can do the exact same thing with Next.js by integrating MDX.
"What is MDX?" You might ask me.
Well... MDX is more or less like the markdown files we always see in GitHub repositories. MDX brings this flexibility into a markdown file by allowing you to literally write or import JavaScript (React) components into your articles. This in turn saves you from writing repetitive code.
In this article, I am going to show you how I built my blog with these tools, so you can also try building something similar. You'll like this simple stack if you are a person who loves the flexibility that this approach brings.
So, sit tight, and let's get started.
How to Start Building – My Trial and Error
To build a blog with Next.js and MDX, there are four popular options that you can choose from.
They are:
- @next/mdx, which is the official tool built by the Next.js team
- Kent C. Dodds' mdx-bundler
- next-mdx-remote, which is a tool built by the Hashicorp team
- next-mdx-enhanced, which is a tool also built by Hashicorp (I honestly don't know why they decided to build two)
At first, I started by using Kent's mdx-bundler, but then I ran into a lot of problems with the tool. It is a library that is based on the new ECMAScript standards that allow us to create ESModules in the browser, and I was using a very old version of Next.js (V10.1.3, my bad honestly, I didn't know any better).
I did a lot of downgrading and upgrading of Next.js to fix this problem to no avail. There was a certain error that stuck with me, and refused to go away for days. Yes, for days! I felt like crying during that period. Take a look at the error below:
module not found: can't resolve 'builtin-modules'
Apparently, for mdx-bundler to work, it needs another npm package called esbuild to do the necessary compiling processes that work under the hood.
npm i mdx-bundler esbuild
Luckily for me — at least I thought I was lucky — Cody Brunner submitted an issue about this particular error. Going through the discussions on the issue, a lot of possible fixes were suggested, some of them were related to Webpack, modifying your next.config.js
file, and whatnot.
module.exports = {
future: {
// Opt-in to webpack@5
webpack5: true,
},
reactStrictMode: true,
webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
if (!isServer) {
// https://github.com/vercel/next.js/issues/7755
config.resolve = {
...config.resolve,
fallback: {
...config.resolve.fallback,
child_process: false,
fs: false,
'builtin-modules': false,
worker_threads: false,
},
}
}
return config
},
}
In the snippet above, it shows that Webpack5 was still a feature that was in progress for Next.js – hence the snippet below in the config:
future: {
webpack5: true
}
But, now the latest version of Next.js supports Webpack5 by default, so there's no need to add that object — if it works for you — in the config.
After going through the discussions, I found a comment (by Kent) that says running npm update
would fix the issue, and it did work for Cody Brunner. But not for me apparently.
When I couldn't find a possible fix to this error, I decided to use next-mdx-remote, and the only issue I faced was the breaking change that was added to the tool. Before version 3 of next-mdx-remote you would normally render parsed markdown content by doing the following:
import renderToString from 'next-mdx-remote/render-to-string'
import hydrate from 'next-mdx-remote/hydrate'
import Test from '../components/test'
export default function TestPage({ source }) {
const content = hydrate(source, { components })
return <div className="content">{content}</div>
}
export async function getStaticProps() {
// MDX text - can be from a local file, database, anywhere
const source = 'Some **mdx** text, with a component <Test />'
const mdxSource = await renderToString(source, { components })
return {
props: {
source: mdxSource,
},
}
}
The breaking change that was added in version 3 of the package stripped off a lot of internal code that was perceived to cause poor experiences for people who were using it at that time.
The team went on to announce the reason behind this change and the major changes. Take a look at them below.
This release includes a full rewrite of the internals of next-mdx-remote to make it faster, lighter-weight, and behave more predictably! The migration should be fairly quick for most use-cases, but it will require some manual changes. Thanks to our community for testing out this release and providing early feedback. heart.
Major changes to next-mdx-remote:
renderToString
has been replaced withserialize
hydrate
has been replaced with<MDXRemote />
- Removed provider configuration. React context usage should now work
without additional effort. - Content will now hydrate immediately by default
- Dropped support for IE11 by default
With this new change, the previous implementation will now become:
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote } from 'next-mdx-remote'
import { Test, Image, CodeBlock } from '../components/'
const components = { Test }
export default function TestPage({ source }) {
return (
<div className="content">
<MDXRemote {...source} components={{ Test, Image, CodeBlock }} />
</div>
)
}
export async function getStaticProps() {
// MDX text - can be from a local file, database, anywhere
const source = 'Some **mdx** text, with a component <Test />'
const mdxSource = await serialize(source)
return {
props: {
source: mdxSource,
},
}
}
How to Build the Blog
In the previous section, I walked you through some of the issues I encountered while I was choosing a suitable tool to use.
In this section, we're going to cover how you can build a similar blog like mine.
We'll start by creating a Next.js app with the command below:
npx create-next-app blog
The command above will give you a boilerplate of a typical Next.js app. For the sake of brevity, I'll be focusing more on the pages
and src/utils
folders of this app.
|--pages
| |-- blog
| | |-- index.js
| | |-- [slug].js
| |-- _app.js
| |-- index.js
|--src
| |-- utils
| |-- mdx.js
|--data
| |-- articles
| |-- example-post.mdx
| |-- example-post2.mdx
In a typical blog, we'd need to write blog posts or articles. In this blog, we're using markdown (MDX) to write our articles, which is why you can see that we have two .mdx
files inside the data/articles
directory. You can have more than that, as far as the number of articles you want to write goes.
How to Read the Markdown (MDX) Files
In this section, we're going to start by writing some reusable functions inside src/utils/mdx.js
.
The functions we're writing here will be using Node.js' FileSystem API. We'll be calling the functions at the server-side in the pages folder because Next.js has some data-fetching methods that runs on the server.
Let's start by installing the dependencies that we need for now. As we progress, we'll be adding other dependencies:
npm install gray-matter reading-time next-mdx-remote glob dayjs
The command above will get all packages listed above as dependencies in our blog project.
gray-matter
will parse the content in the .mdx
files to readable HTML content.
reading-time
assigns an approximate time to read a blog post or article based on the word count.
next-mdx-remote
does the background compilation of the MDX files by allowing them to be loaded within Next.js' getStaticProps
or getServerSideProps
data-fetching method, and hydrated properly on the client.
glob
gives us access to match the file patterns in data/articles
, which we'll be using as the slug of the article.
dayjs
is a JavaScript library that helps to parse, manipulate, validate, and display dates that we would be adding to the metadata of each article.
We've seen the basic functions of the packages we installed. Now let's start writing the functions that'll read the files in the articles directory.
import path from 'path'
import fs from 'fs'
import matter from 'gray-matter'
import readingTime from 'reading-time'
import { sync } from 'glob'
const articlesPath = path.join(process.cwd(), 'data/articles')
export async function getSlug() {
const paths = sync(`${articlesPath}/*.mdx`)
return paths.map((path) => {
// holds the paths to the directory of the article
const pathContent = path.split('/')
const fileName = pathContent[pathContent.length - 1]
const [slug, _extension] = fileName.split('.')
return slug
})
}
In the snippet above, we've imported the Node.js FileSystem from its module and the other packages. The first variable declaration, articlesPath
, holds the path to where all the articles can be found.
const articlesPath = path.join(process.cwd(), 'data/articles')
We're using the path
module to get access to where the articles are by tapping into the process
API of Node.js which gives us direct access to the cwd()
(Current Working Directory) object.
The getSlug
function will get a unique article when it's clicked by the user
on the blog page. You'll see that we're referencing the articlesPath
variable that was declared before, and we're passing it to the sync
function of the glob
package. This will in turn match any file that has the .mdx
extension, and give us an array with a list of these files.
const paths = sync(`${articlesPath}/*.mdx`)
With that being said, we'll return an array of modified file names. The pathContent
variable holds the path to all the articles in the articles directory, so we're using JavaScript to remove all the "forward-slashes" with the split()
method of JavaScript.
const fileName = pathContent[pathContent.length - 1]
const [slug, _extension] = fileName.split('.')
The fileName
variable declaration gets the last part of path, say for example "/data/articles/example-post.mdx"
, since it is an array, and returns the last part which is /example-post.mdx
. The next variable goes on to remove the period (.)
from the filename itself, so we'll be left with example-post
as the slug.
How to Parse Article Content from the Slug
The next function gets and parses the content in our MDX files from the slugs. It returns an object of metadata that we'll be using as we progress.
export async function getArticleFromSlug(slug) {
const articleDir = path.join(articlesPath, `${slug}.mdx`)
const source = fs.readFileSync(articleDir)
const { content, data } = matter(source)
return {
content,
frontmatter: {
slug,
excerpt: data.excerpt,
title: data.title,
publishedAt: data.publishedAt,
readingTime: readingTime(source).text,
...data,
},
}
}
In the snippet above, we're using Node.js' readFileSync
function from the FileSystem API to read the files in the articleDir
in a synchronous manner.
What we're doing with this function — readFileSync
— is telling Node to stop other processes that are currently going on, and perform this operation for us.
You can learn more about it here if you want to.
If you go ahead and console.log(source)
in your terminal, you'll get a <Buffer>
— which isn't readable — data type in your console.
This is where the gray-matter
package comes to save the day. It helps in parsing the markdown content in the source to something — readable HTML — that you and I can understand.
Here, we're destructuring content
and data
variables, assigning it to the matter
package (which parses the source) and returns an object that holds our content
and frontmatter: data
variables:
const { content, data } = matter(source)
return {
content,
frontmatter: {
slug,
excerpt: data.excerpt,
title: data.title,
publishedAt: data.publishedAt,
readingTime: readingTime(source).text,
...data,
},
}
We need a way to display all the articles on the blog page. The function below does that for us, by utilizing the reduce()
method of JavaScript to return an array of all the articles in the articles directory.
export async function getAllArticles() {
const articles = fs.readdirSync(path.join(process.cwd(), 'data/articles'))
return articles.reduce((allArticles, articleSlug) => {
// get parsed data from mdx files in the "articles" dir
const source = fs.readFileSync(
path.join(process.cwd(), 'data/articles', articleSlug),
'utf-8'
)
const { data } = matter(source)
return [
{
...data,
slug: articleSlug.replace('.mdx', ''),
readingTime: readingTime(source).text,
},
...allArticles,
]
}, [])
}
You can see how we're using readdirSync()
to synchronously read all the files inside data/articles
. The source
variable can be accessed by reading all the files with their respective slugs and getting their content parsed with the gray-matter
package.
const source = fs.readFileSync(
path.join(process.cwd(), 'data/articles', articleSlug),
'utf-8'
)
const { data } = matter(source)
If you take a look at the snippet below, you'll see how we're using the reading-time
package to get the approximate time it will take to read this article. We get the slug that will be attached to this article by stripping the last part of the article — blog/example-post.mdx
— and replacing it with an empty string. This makes it accessible via "blog/example-post".
{
slug: articleSlug.replace('.mdx', ''),
readingTime: readingTime(source).text,
}
The readingTime
has some methods that you can assign to it, one of them is the text
method. You can try removing this value, saving your code, and allowing Next.js to throw an error, so you can get a glimpse of the values that you can use.
How to Display a List of Articles
In the previous sections, we have seen how we can use the Node.js FileSystem API and a couple of other tools to get access to where all our articles are.
In this section, we'll be displaying the articles on a webpage.
We'll start with the index
file in the blog folder. In this file, we'll be using the data-fetching method — getStaticProps
— to render the articles on the page.
import { getAllArticles } from '../../src/utils/mdx'
export async function getStaticProps() {
const articles = await getAllArticles()
articles
.map((article) => article.data)
.sort((a, b) => {
if (a.data.publishedAt > b.data.publishedAt) return 1
if (a.data.publishedAt < b.data.publishedAt) return -1
return 0
})
return {
props: {
posts: articles.reverse(),
},
}
}
In the snippet above, we imported the getAllArticles
function and used it in the data-fetching method of Next.js.
You'll notice how we're sorting the articles based on the date they were published. We'll eventually map the list of articles that will be returned as props to the index (blog) page.
articles
.map((article) => article.data)
.sort((a, b) => {
if (a.data.publishedAt > b.data.publishedAt) return 1
if (a.data.publishedAt < b.data.publishedAt) return -1
return 0
})
Lest I forget, this is how the content of your typical article file will look in markdown syntax below:
---
title: 'Next.js Image optimization error on Netlify'
publishedAt: '2022-04-16'
excerpt: 'Next.js has a built-in Image component that comes with a lot of performance optimization features when you are using it.'
cover_image: 'path/to/where/image/is/stored'
---
rest of the content falls here
You may ask me, "why do we need to sort the articles by date if we can just use the reverse()
method to re-order the array of articles?".
I think it is appropriate for us to sort the list of articles by comparing them with the date they were published and still apply the reverse
method to the array.
Say, for example, we forget to add the published dates to the articles. Then the reverse()
method will just perform the operation on the array without comparing the dates in a LIFO — Last-In-First-Out — pattern, if the sort function is missing. So it is better to sort the articles and still reverse the content of the array.
Now that we've returned the list of articles as props we can go ahead to map them onto the page.
import React from 'react'
import Head from 'next/head'
import Link from "next/link"
import { getAllArticles } from '../../src/utils/mdx'
export default function BlogPage({ posts }) {
return (
<React.Fragment>
<Head>
<title>My Blog</title>
</Head>
<div>
{posts.map((frontMatter) => {
return (
<Link href={`/blog/${frontMatter.slug}`} passHref>
<div>
<h1 className="title">{frontMatter.title}</h1>
<p className="summary">{frontMatter.excerpt}</p>
<p className="date">
{dayjs(frontMatter.publishedAt).format('MMMM D, YYYY')} —{' '}
{frontMatter.readingTime}
</p>
</div>
</Link>
)
})}
</div>
</React.Fragment>
)
}
export async function getStaticProps() {
...
}
In the snippet above, we're using the Link
component to route the user to a dynamic page with the unique article's slug. This is the reason we created a file called [slug].js
, if you can recall. It is a dynamic route, and you can read more about it here.
How to Display a Unique Article
In the last section, we were able to render the list of articles onto the webpage. In this section, we'll be rendering a unique article that gets clicked on by the user in a new route.
We're also going to be using a tool called rehype to customize what our blog post will look like. Rehype is an HTML pre-processor that is powered by plugins. We'll be using some of these plugins in this section, so let's install them now.
npm i rehype-highlight rehype-autolink-headings rehype-code-titles rehype-slug
rehype-highlight
allows us to add syntax highlighting to our code blocks.
rehype-autolink-headings
is a plugin that adds links to headings from h1 to h6.
rehype-code-titles
adds language/file titles to your code.
rehype-slug
is a plugin that adds an id
attributes to headings.
Now that we've seen the roles that each plugin carries out, let's start working on the [slug].js
file. In this file, we'll be using two data-fetching methods of Next.js — getStaticProps
and getStaticPaths
.
We're using these two methods because we'll be fetching data (articles) that are unique to the path (slugs) that the user is redirected to.
// dynamically generate the slugs for each article(s)
export async function getStaticPaths() {
// getting all paths of each article as an array of
// objects with their unique slugs
const paths = (await getSlug()).map((slug) => ({ params: { slug } }))
return {
paths,
// in situations where you try to access a path
// that does not exist. it'll return a 404 page
fallback: false,
}
}
When you take a look at the snippet above, you'll see that we're obtaining the list of paths
from the articles, and mapping that list of items (paths) to an array. This can be accessed with the params
variable in the getStaticProps
data-fetching method.
import { getArticleFromSlug } from "../../src/utils/mdx"
export async function getStaticProps({ params }) {
//fetch the particular file based on the slug
const { slug } = params
const { content, frontmatter } = await getArticleFromSlug(slug)
const mdxSource = await serialize(content, {
mdxOptions: {
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
properties: { className: ['anchor'] },
},
{ behaviour: 'wrap' },
],
rehypeHighlight,
rehypeCodeTitles,
],
},
})
return {
props: {
post: {
source: mdxSource,
frontmatter,
},
},
}
}`
In the snippet above, we're destructuring content
and frontmatter
— which is the metadata of the article — and assigning it to the getArticleFromSlug
function which receives the slug of the article as an argument.
We continued by serializing the content of the article with next-mdx-remote's serialize()
function, and pass the necessary rehype plugins in the mdxOptions
object:
const mdxSource = await serialize(content, {
mdxOptions: {
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
properties: { className: ['anchor'] },
},
{ behaviour: 'wrap' },
],
rehypeHighlight,
rehypeCodeTitles,
],
},
})
To wrap it up, we return the content
of the article and the frontmatter
as props that'll be accessed by the slug component.
return {
props: {
post: {
source: mdxSource,
frontmatter,
},
},
}
The props that we returned in the previous snippets can be accessed via the component below.
You'll notice that the <MDXRemote />
component receives the {...source}
and custom React component props that we can use in our MDX files. This eradicates the process of having to write repetitive code over and over.
import dayjs from 'dayjs'
import React from 'react'
import Head from 'next/head'
import Image from 'next/image'
import rehypeSlug from 'rehype-slug'
import { MDXRemote } from 'next-mdx-remote'
import rehypeHighlight from 'rehype-highlight'
import rehypeCodeTitles from 'rehype-code-titles'
import { serialize } from 'next-mdx-remote/serialize'
import 'highlight.js/styles/atom-one-dark-reasonable.css'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import { getSlug, getArticleFromSlug } from '../../src/utils/mdx'
import { SectionTitle, Text } from '../../data/components/mdx-components'
export default function Blog({ post: { source, frontmatter } }) {
return (
<React.Fragment>
<Head>
<title>{frontmatter.title} | My blog</title>
</Head>
<div className="article-container">
<h1 className="article-title">{frontmatter.title}</h1>
<p className="publish-date">
{dayjs(frontmatter.publishedAt).format('MMMM D, YYYY')} —{' '}
{frontmatter.readingTime}
</p>
<div className="content">
<MDXRemote {...source} components={{ Image, SectionTitle, Text }} />
</div>
</div>
</React.Fragment>
)
}
In the snippet above, you'll notice how we destructured the post props into { source, frontmatter }
. So instead of doing this, in the <MDXRemote>
component below we can just spread the source variable directly as a prop.
<MDXRemote {...post.source} />
Notice how we're also dynamically rendering the title of the page with the title of the article instead of the normal title? This is gotten from the frontmatter.
<Head>
<title>{frontmatter.title} | My blog</title>
</Head>
Final Thoughts
Every developer loves having their fancy themes applied to their editors. So we're not going to leave this out in this blog.
I'm currently using the "atom-one-dark-reasonable"
theme for my syntax highlighting. You can import it from the "highlight.js"
library — since the rehype-highlight
plugin uses it under the hood — like this:
import 'highlight.js/styles/atom-one-dark-reasonable.css'
There are a lot of other themes here, so you can choose any that you're comfortable with.
You might have noticed while reading this article that there are some components like the one in the image below – and you may have been wondering how it was created.
You can decide to have a lot of custom MDX components that you can use in your articles. But, I decided to target any element that I want to style in this article by assigning a generic className to it. So whenever I want to use it, I just reference that style in the element.
SEO is one of the important things when it comes to building a blog, and luckily for us, Next.js already has that covered for us. You can take a look at this article that walks you through How to add SEO Meta tags in your Next.js apps and How I fixed a meta tag pre-rendering error in Next.js
There is an important thing that you must not forget, and that is the next.config.js
file. You need to make sure that it is properly set up so you can avoid one of the version compatibility errors of the latest version of React — v18.0.0 — with next-mdx-remote.
Although the Hashicorp team said they've fixed this in their latest release, it didn't work for me. A way to bypass this error is to install next-mdx-remote as a legacy peer dependency, like so:
npm i next-mdx-remote --legacy-peer-deps
And make sure to have a next.config
file that looks like what you're seeing below.
module.exports = {
reactStrictMode: true,
images: {
loader: 'akamai',
path: '',
},
webpack: (config) => {
config.resolve.alias = {
...config.resolve.alias,
'react/jsx-runtime.js': require.resolve('react/jsx-runtime'),
}
config.resolve = {
...config.resolve,
fallback: {
...config.resolve.fallback,
child_process: false,
fs: false,
// 'builtin-modules': false,
// worker_threads: false,
},
}
return config
},
}
The resolve.alias
object in the config above helps as a workaround in fixing the error below
What to do if you get a server error
Error: Package subpath "./jsx-runtime.js" is not defined by "exports" in "path-to-node_modules/react/package.json"
Sometimes you may also encounter an error that has to do with the "builtin" modules of Node.js while you're deploying your project. The config.resolve
object with the fallback
key helps in removing that error.
You'll notice that there's an image
object in the config.
images: {
loader: 'akamai',
path: '',
},
Its role is to ensure that the proper image optimization process is used during the build process. You can take a look at an article I wrote about how you can fix the Next.js image optimization error on Netlify
Thank you so much for reading this article. I hope you found it helpful.
Subscribe to my newsletter
Read articles from freeCodeCamp directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
freeCodeCamp
freeCodeCamp
Learn to code. Build projects. Earn certifications—All for free.