How I made my Blog with Next.js and MDX
I've been thinking about creating a blog with Next.js and MDX for a while. I've read a lot of articles about it, but honestly I haven't found any good ones. So I decided to create my own blog and write an article about it.
- next |
v12.1.5
- react |
v18.0
- @mdx-js/loader |
v2.1.1
- gray-matter |
v4.0.3
- next-mdx-remote |
v4.0.1*
- next-seo |
v5.2.0
- reading-time |
v1.5.0
- rehype-prism-plus |
v1.3.2
- rehype-remark |
v0.3.0
- tailwindcss |
v3.0.23
(optional) - @tailwindcss/typography |
v0.5.2
I'm using next-mdx-remote v4.0.1 because the last version v4.0.2 is not working with React v18.0. More information about this issue can be found here on Github.
At first you should know that it's not a simple task to create a blog with Next.js and MDX. (I struggle with it, and I'm sure you will too 😘).
I won't explain to you how to create a new app using Next.js since I assume you already know how to do that.
MDX allows you to use JSX in your markdown content. You can import components, such as interactive charts or alerts, and embed them within your content. This makes writing long-form content with components a blast. 🚀
What about next-mdx-remote
?
This package allows you to load MDX files from anywhere, in this case I'll retrieve the data using Node.js
.
Used to fetch, resolve, sort and compile MDX files.
lib/articles/parser.js
import fs from 'fs';
import path from 'path';
export const getArticleSlugs = () =>
fs
.readdirSync(path.join(process.cwd(), './articles'))
.filter(file => /\.mdx?$/.test(file))
.map(file => file.replace(/\.mdx?$/, ''));
The getArticleSlugs
function returns an array with all the slugs of the articles, it reads from the filesystem and looks for MDX files inside the ./articles
path.
lib/articles/parser.js
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import readingTime from 'reading-time';
export const getArticleData = ({ slug }) => {
const fullPath = path.join(process.cwd(), './articles', `${slug}.mdx`);
const raw = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(raw);
return {
frontMatter: {
...data,
slug,
title: removeMarkdown(data.title),
permalink: `${config.baseUrl}/articles/${slug}`,
date: new Date(data.date).toISOString(),
readingTime: Math.ceil(readingTime(content).minutes)
},
content
};
};
The getArticleData
looks for the article with the slug and it's read from the filesystem.
The matter
method will parse the front-matter from the article and its content, it will return the data (that contains the "header" part) and the content (that will contain the MDX content).
Finally, I prepare the object, composing other elements and properties useful for the frontend such as the reading-time, the permalink, the date, etc.
lib/articles/parser.js
import remarkGfm from 'remark-gfm';
import rehypePrism from 'rehype-prism-plus';
import { serialize } from 'next-mdx-remote/serialize';
export const getArticle = async ({ slug }) => {
const { frontMatter, content } = await getArticleData({ slug });
const source = await serialize(content, {
parseFrontmatter: false,
mdxOptions: {
remarkPlugins: [[remarkGfm]],
rehypePlugins: [[rehypePrism, { ignoreMissing: true }]]
}
});
const { compiledSource } = source;
return {
frontMatter,
source: {
compiledSource
}
};
};
I used two plugins: remark-gfm
and rehype-prism-plus
.
- remarkGfm supports the GMF specs.
- rehypePrism is a rehype plugin to highlight code blocks in HTML. This plugin is a fork of mapbox/rehype-prism and allows to show the number of lines and the line highlighting.
The serialize
method consumes the MDX string. It will return an object that will contains the compiled source that will be passed to the <MDXRemote>
component.
pages/blog/index.js
import { getAllArticles } from 'lib/articles/parser';
export async function getServerSideProps() {
const articles = getAllArticles();
return {
props: {
articles
}
};
}
Just asking to the server for looking the articles.
pages/blog/index.js
import s from 'styles/pages/blog.module.css'; // This file contains the CSS for the blog page.
import { NextSeo } from 'next-seo';
export default function Blog({ articles }) {
return (
<>
<NextSeo
title="Blog"
description="Articles written with ❤️ by Mateo Nunez."
openGraph={{
title: "Mateo's Blog"
}}
/>
<div className={s.root}>
{articles.map(article => (
<ArticlePreview
key={article.slug}
author={article.author}
date={article.date}
title={article.title}
description={article.description}
image={article.image}
slug={article.slug}
tags={article.tags}
readingTime={article.readingTime}
/>
))}
</div>
</>
);
}
As you can see having all the data inside the props it's very simply to map the articles data into a Preview
component.
components/articles/preview/index.js
import s from './preview.module.css';
import Image from 'next/image';
import Link from 'next/link';
import { dateForHumans } from 'lib/helpers/date';
export default function ArticlePreview({ author, date, title, description, image, slug }) {
return (
<>
<div className={s.root}>
{/* Heading */}
<div className={s.heading}>
{/* Author image */}
<Image
src={author.image}
alt={author.name}
width={32}
height={32}
className={s.authorImage}
/>
{/* Author Name */}
<span className={s.simpleText}>Written by: </span>
<span className={s.authorName}>{author.name}</span>
{/* Separator */}
<span className={s.simpleText}>at</span>
{/* Date */}
<span className={s.date}>{dateForHumans(date)}</span>
</div>
{/* Body */}
<Link href="/blog/[slug]" as={`/blog/${slug}`}>
<a rel="canonical" href={`/blog/${slug}`} title={title}>
<div className={s.body}>
{/* Image */}
<div className={s.imagePreview}>
<Image
src={image}
alt={title}
width={1280}
height={720}
className={s.image}
/>
</div>
{/* Title and Description */}
<div className={s.textPreview}>
<h2 className={s.title}>{title}</h2>
<p className={s.description}>{description}</p>
</div>
</div>
</a>
</Link>
</div>
</>
);
}
And this is how my component looks like:
Keep calm bro, have you copied and pasted the code? Is it working? Well, it's not the end of the world. I spent 3 days to get it working.
components/articles/index.js
import s from './article.module.css';
import ArticleHeader from './header';
import ArticleTitle from './title';
import ArticleContent from './content';
export default function Article({ frontMatter, source }) {
const { title, date, author, tags, readingTime } = frontMatter;
return (
<>
<div className={s.root}>
<ArticleHeader date={date} author={author} tags={tags} readingTime={readingTime} />
<ArticleTitle title={title} />
<ArticleContent {...source} />
</div>
</>
);
}
I ❤️ clean (and clear) components or at least I think so.
The Header
and the Title
components are pretty simple. So I show you just the <ArticleContent />
component.
components/articles/content/index.js
import s from './content.module.css';
import { MDXRemote } from 'next-mdx-remote';
import * as components from 'components/articles/mdx';
export default function ArticleContent({ compiledSource }) {
return (
<>
<div className={s.root}>
<MDXRemote compiledSource={compiledSource} components={components} />
</div>
</>
);
}
Ok ok ok... What the hell is MDXRemote? It's a component that consumes the serialized compiled source and uses the components you passed to it. Each component will render only in your MDX source. In my case, I prefer to create specific components for each case.
How my mdx-components
looks like?
So each time the MDXRemote will render a native HTML element like <h1>
, <h2>
, <h3>
, <h4>
, <h5>
, <a>
or <code>
(in this case) they will be replaced with my own components.
Oh yes, Tailwind.
I advise you to use the typography plugin. It helps you to create good typography and layout for your posts.
components/articles/article.module.css
.root {
@apply prose prose-invert prose-code:before:hidden prose-code:after:hidden;
margin: auto;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 800px;
}
The reason I hide the before and after mutators is because the typography plugin appends the ` char into the <code>
element.