Skip to content

Step-by-Step Tutorial: Building a Blog with Next.js and MDX

May 25, 2024

Introduction

In this guide, we'll learn how to build a straightforward Next.js blog leveraging the MDX next-mdx-remote library, along with the robust capabilities of the app router and React server components.

MDX is a format that lets you write JSX directly in your Markdown files. This powerful combination allows for the seamless integration of dynamic React components within Markdown content, enabling rich and interactive experiences in your blog posts.

Throughout this tutorial, we'll start by setting up a basic Next.js application with TypeScript. Then, we'll dive into creating a statically generated page to display all our blog posts, sorted from newest to oldest. Additionally, we'll tackle building a page that renders the MDX files, utilizing dynamic routes based on the name of each MDX file.

Furthermore, we'll cover the generation of a sitemap and dynamically generating metadata for each blog post, ensuring optimal SEO performance and discoverability.


Getting Started

To kick things off, let's set up a Next.js application. Open your terminal and run the following command:

npx create-next-app@latest

This command will guide you through creating a new project with an interactive menu of options. For this guide, we'll be utilizing the app router, so make sure to select it when prompted.

While we'll be using TypeScript in this tutorial, the code provided should be easily adaptable to plain JavaScript if that's your preference.

Next up, let's install next-mdx-remote by running the following command:

npm install next-mdx-remote

By utilizing next-mdx-remote, we can compile MDX files into React elements, enabling seamless rendering within our application.

Important: If you've opted for TypeScript and are encountering errors in VSCode, don't worry. You can resolve this issue by ensuring you're using the TypeScript version of the workspace instead of the default VSCode version.

To do so, start by opening a TypeScript file. Then, head to the bottom right corner of the status bar where you'll find two brackets followed by "TypeScript React". Clicking on these brackets will display the currently used TypeScript version, alongside a "Select Version" button.

TypeScript version in status bar

Upon clicking, a message box will appear, prompting you to choose which version of TypeScript VS Code should be used. Simply select the "Use Workspace Version" option from the menu.

TypeScript versions options list

Creating the Blog Post Page

We're going to set up the blog post page next. But first, let's configure the routing. Each markdown file will correspond to a dynamic URL segment. Follow these steps:

  1. Create a directory named blog under the app directory.
  2. Inside the blog directory, create a folder named [slug].
  3. Within the [slug] folder, create a file named page.tsx.

Your directory structure should look like app/blog/[slug]/page.tsx, where [slug] is the dynamic segment that will be used for our blog posts.

To have something to work with, let's create our first simple markdown file. You can place this file wherever you like, but for organization, let's create a markdown folder to store all our markdown blog post files.

Here's an example markdown file:

---
title: "Building a Blog with Next.js and MDX"
description: "A guide to using Next.js app router and MDX for creating a dynamic blog"
date: "2024-05-25"
image: { url: "/path/to/image", width: 1200, height: 600 }
---
## Welcome to the Next.js and MDX Guide
Next.js and MDX are powerful tools for building modern blogs. In this post,
we'll explore how to use the Next.js app router and MDX to create a dynamic blog.
## Benefits of Using Next.js and MDX
- **Next.js**: Build fast, scalable web applications with ease.
- **MDX**: Combine Markdown with JSX for interactive content.
- **Dynamic Routing**: Simplify route creation with the app router.

The first part of the markdown file, enclosed in ---, is called frontmatter. It contains metadata about the page, such as the title, description, and date, which is not rendered in the content. You can add any property in the frontmatter, and we'll see how to access this information later on!

Setting Up Static Generation for Dynamic Routes

In page.tsx, we'll export a function named generateStaticParams. This function, in combination with dynamic route segments, enables us to statically generate routes during build time instead of on-demand at request time.

Within this function, we'll construct an array of parameters containing the slug of each post based on the filename in the markdown folder. Later, the server component can utilize these slugs to fetch the content of the respective MDX file.

To organize this a bit better, and maintain clean code, let's create a file called mdxUtils, which will contain various helper functions.

The first function we're going to add is getPostFilePaths, which will return an array of all the paths of the markdown files inside the markdown folder.

// mdxUtils.ts
import fsp from "fs/promises"
import path from "path";
import { cache } from "react";
const POSTS_PATH = path.join(process.cwd(), "markdown");
export const getPostFilePaths = cache(async () => {
const dirFiles = await fsp.readdir(POSTS_PATH);
// Only include md(x) files
return dirFiles.filter((filepath) => /.mdx?$/.test(filepath));
});

Similar to how Next.js caches the result of a fetch, React's cache function allows us to memoize the result of a function when called from a server component. This enables the reuse of the result whenever the function is called with the same arguments.

Next, let's use getPostFilePaths inside generateStaticParams:

// page.tsx
import { getPostFilePaths } from "@/utils/mdxUtils";
export const generateStaticParams = async () => {
const postFilePaths = await getPostFilePaths();
return postFilePaths.map((path) => ({
slug: path.replace(/.mdx?$/, ""),
}));
};

We're using the name of each markdown file, without its extension, as the slug. A different page will be generated at build time for each slug, with the slug passed as a parameter to each of these pages.

Fetching the Markdown Content

Now, let's add a second function to the mdxUtils file. This function will read the markdown file and compile the MDX using the compileMDX function from next-mdx-remote.

// mdxUtils.ts
import { compileMDX } from "next-mdx-remote/rsc";
export type PostsFrontmatter = {
title: string;
description: string;
date: string;
image: { url: string; width: number; height: number };
};
export const getCompiledMDX = cache(async (postSlug: string) => {
const postFilePath = path.join(POSTS_PATH, `${postSlug}.mdx`);
const source = await fsp.readFile(postFilePath);
return compileMDX<PostsFrontmatter>({
source,
options: { parseFrontmatter: true },
});
});

We introduced a PostsFrontmatter type and passed it as a generic to compileMDX. This ensures that the frontmatter data returned by compileMDX is typed.

Note: We've hardcoded the file extension to .mdx. This assumes that all files used will be in the .mdx format and not .md. If you wish to use both .md and .mdx files, you'd need to search for the file in the array returned by getPostFilePaths to determine its extension. However, I'd recommend sticking to .mdx files since they are a superset of .md, making sure you don't miss out on any features while also keeping the code cleaner.

Let's utilize the getCompiledMDX function in our page.tsx server component by passing the slug from the params:

// page.tsx
import { getCompiledMDX, getPostFilePaths } from "@/utils/mdxUtils";
import Image from "next/image";
import { notFound } from "next/navigation";
export const generateStaticParams = async () => {
const postFilePaths = await getPostFilePaths();
return postFilePaths.map((path) => ({
slug: path.replace(/.mdx?$/, ""),
}));
};
const getPostData = async (postSlug: string) => {
try {
return await getCompiledMDX(postSlug);
} catch (e) {
notFound();
}
};
type PageProps = {
params: {
slug: string;
};
};
const BlogPostPage = async ({ params }: PageProps) => {
const { content, frontmatter } = await getPostData(params.slug);
const { title, date, image } = frontmatter;
return (
<>
<h1>{title}</h1>
<p>{new Date(date).toDateString()}</p>
<Image src={image.url} width={image.width} height={image.height} alt="" />
{content}
</>
);
};
export default BlogPostPage;

The getPostData function wraps getCompiledMDX in a try-catch block and calls Next's notFound function, which redirects the user to the 404 page in case of an error. It's important to use await here; otherwise, the catch block will never execute in case of an error, potentially leading to application crashes.

We used the frontmatter data to render the title, date, and image, and then rendered the content of the MDX file. You can now view the result at /blog/{mdxFileName}.

Note: If you want to serve the image locally, you need to place it in the public folder. For example, if you have an image named my-image.png placed directly inside the public folder (not nested), you would pass the path /my-image.png to the Image component's src property.


Adding Custom Components to MDX

MDX allows us to integrate custom React components, and use them just as we would in JSX. We can even pass props to them! To achieve this, we simply pass these components within the components property of the compileMDX function.

Additionally, MDX offers the flexibility to override default rendered elements with our own custom implementations. For instance, if you wish to replace the standard <h2> element with a customized heading component featuring your own styles, MDX allows you to do so effortlessly.

// mdxUtils.ts
import CustomHeading from "@/components/CustomHeading";
import MyButton from "@/components/MyButton";
export const getCompiledMDX = cache(async (postSlug: string) => {
const postFilePath = path.join(POSTS_PATH, `${postSlug}.mdx`);
const source = await fsp.readFile(postFilePath);
return compileMDX<PostsFrontmatter>({
source,
options: { parseFrontmatter: true },
components: { Button: MyButton, h2: CustomHeading },
});
});

Now, with our custom components defined, we can use them in our MDX files like so:

---
title: "A Blog Post with Custom Components"
description: "Let's see how to use custom components in our MDX files"
date: "2024-05-25"
image: { url: "/path/to/image", width: 1200, height: 600 }
---
## This heading will use the CustomHeading component!
We can use our custom components just as we would in JSX:
<Button myCustomProp={true}>Click Me</Button>

If your custom component relies on client-side functionality such as hooks like useState or browser APIs like localStorage, don't forget to include the "use client" directive at the top of its file, otherwise it won't work!


Generating Metadata

Next, we'll improve the SEO of our blog post page by adding metadata. To accomplish this, we'll use the generateMetadata function, which accepts the slug from the page's parameters. Using the slug, we'll retrieve the frontmatter data and utilize it to construct the metadata object.

// page.tsx
import { Metadata } from "next";
export const generateMetadata = async ({ params }: PageProps): Promise<Metadata> => {
try {
const { frontmatter } = await getCompiledMDX(params.slug);
return {
title: frontmatter.title,
description: frontmatter.description,
openGraph: {
title: frontmatter.title,
description: frontmatter.description,
type: "article",
url: `/blog/${params.slug}`,
images: frontmatter.image.url,
},
};
} catch (e) {
notFound();
}
};

We've added a meta title, description, and additional Open Graph properties. Open Graph is a protocol that allows websites to control how their content is displayed when shared on social media platforms.

The generateMetadata function can only be exported from a server component.


Setting Up the Blog Index Page

Now, let's create a page that displays all the blog posts with summaries, linking to their respective detailed pages. We'll add another helper function to mdxUtils that retrieves all the posts from newest to oldest.

// mdxUtils.ts
export const getLatestPostSummaries = cache(async () => {
const filePaths = await getPostFilePaths();
const posts = await Promise.all(
filePaths.map(async (filepath) => {
const slug = filepath.replace(/.mdx?$/, "");
return { slug, ...(await getCompiledMDX(slug)) };
})
);
posts.sort((a, b) => (a.frontmatter.date < b.frontmatter.date ? 1 : -1));
return posts.map((post) => ({ slug: post.slug, ...post.frontmatter }));
});

For each markdown file we return its slug, so we can navigate to its respective page, and the frontmatter data.

Note: The reason I'm able to sort the posts as described above is because date strings in the format YYYY-MM-DD are inherently sortable as strings without needing to convert them to Date objects.

The next step is to create a page.tsx file under the blog folder, which will fetch the blog posts and render a list of them:

// app/blog/page.tsx
import { getLatestPostSummaries } from "@/utils/mdxUtils";
import Link from "next/link";
const BlogIndexPage = async () => {
const posts = await getLatestPostSummaries();
return (
<div>
<h1>Blog Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
<p>{post.description}</p>
<p>{new Date(post.date).toDateString()}</p>
</Link>
</li>
))}
</ul>
</div>
);
};
export default BlogIndexPage;

You can further style the index page to match your website's design. Add CSS classes or use a styling solution of your choice to improve the appearance of the blog post summaries.

Visiting /blog, you will see a list of all available blog posts, each with a brief summary. When you click on any post, you can view its full content.


Creating a Sitemap

A sitemap.xml file is essential for improving the discoverability of your website by search engines. It provides a structured way to inform search engine crawlers about the pages on your site that are available for crawling.

Let's add a function into mdxUtils responsible for generating the post section of the sitemap:

// mdxUtils.ts
export const getPostsSitemap = cache(async () => {
const filePaths = await getPostFilePaths();
return await Promise.all(
filePaths.map(async (filepath) => {
const slug = filepath.replace(/.mdx?$/, "");
return { slug, ...(await fsp.stat(path.join(POSTS_PATH, filepath))) };
})
);
});

We utilize fs.stat to retrieve the modified date of each file, which is later used in our sitemap generation process.

To create our sitemap we have to add a sitemap.ts file in the root of the app directory:

// app/sitemap.ts
import { getPostsSitemap } from "@/utils/mdxUtils";
import { MetadataRoute } from "next";
const BASE_URL = "https://example.com";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getPostsSitemap();
return [
{
url: BASE_URL,
},
{
url: BASE_URL + "/blog"
},
...posts.map((post) => ({
url: `${BASE_URL}/blog/${post.slug}`,
lastModified: post.mtime,
})),
];
}

You can view the generated sitemap by visiting /sitemap.xml.

Don't forget to replace BASE_URL with the domain of your deployed website!


I hope this guide has helped you create an awesome Next.js blog, complete with dynamic routing, MDX integration, and server-side rendering utilizing the app router and server components!