A.

Blog

Building a blog with Next.js App Router and MDX

Jan 31, 2024

Next.js and MDX are no strangers. Next is a wonderful platform to build a site if you’re using React and writing in MDX is only natural if you’re looking for the simplicity of Markdown with the flexibility of breaking out into JSX if you have to.

There are quite a few resources on how to get running with the pair and even official docs and support for MDX. But there isn’t a whole lot about how to leverage the @next/mdx package to actually build a fully functioning blog with Next’s App Router (and React server components).

I recently built out this blog with that in mind so I figured I’d share how it went about it. If you’d like to skip over the explanation and see a demo, check out the final project’s repository on GitHub or play around with it on CodeSandbox.

Why not use next-mdx-remote?

I’ve used next-mdx-remote for other projects and it’s a great option. However, it does have some limitations that I wanted to overcome for building this blog. I’m also not going to host the MDX anywhere else so collecting it from a remote source isn’t a requirement for me.

I’m planning on showcasing small experiments that would be imported as a component so being able to use import statements is a must. I also found that not being able to specify export statements makes it difficult to define variables within my MDX content for easy reuse of strings, etc.

As of authoring this post, next-mdx-remote is currently marked as unstable when used with React server components. I’m sure the maintainers will stabilize things soon, but I really wanted to use server components as much as possible.

Initial setup

Let’s start by installing the necessary dependencies to get MDX up and running in our Next.js project. Assuming you use npm and Typescript, you’ll want to run the following commands:

npm install @next/mdx @mdx-js/loader @mdx-js/react
npm install -D @types/mdx

Then, you’ll need to update your next.config.js file to use MDX:

// Import the MDX plugin
const withMDX = require('@next/mdx')();

/** @type {import('next').NextConfig} */
const nextConfig = {
// Configure pageExtensions to support MDX
pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],
};

// Use the MDX plugin
module.exports = withMDX(nextConfig);

Finally, you’ll need to create src/mdx-components.tsx with the following:

import type { MDXComponents } from 'mdx/types';

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
};
}

This is where you’ll define your custom MDX components, but we’ll get into that later!

Adding posts

Now that we’ve added MDX support to our project, we can now create pages with the .mdx extension. A convention that I decided to use was to keep my posts in a (posts) directory. The parenthetical naming allows us to use Route Groups which helps us organize and standardize the layout of our posts without affecting the route structure.

Let’s create our first post by creating src/app/(posts)/hello-world/page.mdx. Your directory structure should look something like this:

src/
└── app/
└── (posts)/
└── hello-world/
└── page.mdx

Our post will now be accessible at the /hello-world route. But first, let’s add some content to our post. Add the following to page.mdx:

# Hello world

Welcome to my blog!

Great! Now we have our first post with some content in it. Next, let’s add some metadata to the post.

Adding post metadata

Metadata allows us to encode some information with our post such as the title and publish date. You’re free to add other metadata as well and we’ll explore adding categories in another section below. For now, add the following to src/app/(posts)/hello-world/page.mdx:

export const metadata = {
title: 'Hello world',
publishDate: '2024-01-01T00:00:00Z',
};

You’ll notice that the page title changed to “Hello world”. That’s because Next.js has metadata fields that are read automatically if you export a metadata object from your pages. In this case, it is reading the metadata.title as the page title.

Customizing MDX components

There might be some cases where you want to use a custom component in place of one of the default components that MDX automatically renders. As an example, let’s replace the h1 component with our own heading component.

First, we’ll create src/components/heading/index.tsx to house our component:

import { type ComponentPropsWithoutRef } from 'react';
import './styles.css';

export function Heading(props: ComponentPropsWithoutRef<'h1'>) {
return <h1 className="heading" {...props} />;
}

And then let’s create src/components/heading/styles.css for our component’s CSS:

.heading {
text-decoration: underline;
color: crimson;
}

Now let’s update src/mdx-components.tsx to add Heading in place of h1:

import type { MDXComponents } from 'mdx/types';
import { Heading } from '@/components/heading';

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
h1: Heading,
...components,
};
}

Our h1 should now render in a crimson color with an underline beneath it.

Listing posts

Listing posts is a little different than what you would do with next-mdx-remote. Since we’re using server components, we can directly access the MDX files in our (posts) directory. This is the beauty of server components and Next.js! ✨ For the sake of this example, let’s use src/app/page.tsx to list our posts.

We’ll create a posts helper file to house some utilities we’ll be using throughout this project. Create src/posts.ts and then let’s define an interface for our posts so we can get some type safety:

interface Post {
slug: string;
title: string;
publishDate: string;
}

Now we’ll create a getPosts() function grab all of our post data from src/app/(posts):

export async function getPosts(): Promise<Post[]> {
// Retrieve slugs from post routes
const slugs = (
await readdir('./src/app/(posts)', { withFileTypes: true })
).filter((dirent) => dirent.isDirectory());

// Retrieve metadata from MDX files
const posts = await Promise.all(
slugs.map(async ({ name }) => {
const { metadata } = await import(`./app/(posts)/${name}/page.mdx`);
return { slug: name, ...metadata };
})
);

// Sort posts from newest to oldest
posts.sort((a, b) => +new Date(b.publishDate) - +new Date(a.publishDate));

return posts;
}

Let’s break down what’s happening here. First, we’re using the readdir() function from Node.js to get a list of the directories in src/app/(posts) (i.e., the posts). Then we’re mapping over this list to get the metadata for each post. This is possible with the use of a dynamic import to read the exported items from each post. Hurray for async components! 🙌

Then we sort the posts by publishDate (newest to oldest) using the sort array method.

We’re going to be reusing this pattern so let’s create a component to list our posts in src/components/posts:

import { type Post } from '@/posts';
import Link from 'next/link';

export function Posts({ posts }: { posts: Post[] }) {
return (
<ol>
{posts.map(({ slug, title, publishDate, categories }) => (
<li key={slug}>
<h2>
<Link href={`/${slug}`}>{title}</Link>
</h2>
<p>
<strong>Published:</strong>{' '}
{new Date(publishDate).toLocaleDateString()}{' '}
<strong>Categories:</strong>{' '}
{categories.map((cat, i) => `${i ? ', ' : ''}${cat}`)}
</p>
</li>
))}
</ol>
);
}

Here we’re grabbing the slug (the name of the post route) and the post’s metadata (title and publishDate) and showing it in an ordered list. Since publishDate is an ISO-8601 string, we place it in a Date object and use the toLocaleDateString method to format it.

The final result should look something like this:

import { Posts } from '@/components/posts';
import { getPosts } from '@/posts';

export default async function Home() {
const posts = await getPosts();

return (
<main>
<h1>Next.js MDX Blog</h1>
<Posts posts={posts} />
</main>
);
}

Now we have a list of all of the posts on the homepage!

Adding categories

Let’s add some categories to our posts. In this example, posts can have multiple categories: dog, cat, and rock. First, we’ll add the categories as metadata to our posts’ MDX as an array:

export const metadata = {
title: 'Hello world',
publishDate: '2024-01-01T00:00:00Z',
categories: ['dog'],
};

Then to make things easy, we’ll define a static list of categories in a categories helper in src/categories.ts:

export const categories = ['dog', 'cat', 'rock'] as const;

We’ll also create a type for our categories:

export type Category = (typeof categories)[number];

Our Post type we defined in the last section in src/posts.ts should be updated to this:

export interface Post {
slug: string;
title: string;
publishDate: string;
categories: Category[];
}

And finally, we can update our Posts component to show each posts’ categories:

<ol>
{posts.map(({ slug, title, publishDate, categories }) => (
<li key={slug}>
<h2>
<Link href={`/${slug}`}>{title}</Link>
</h2>
<p>
<strong>Published:</strong> {new Date(publishDate).toLocaleDateString()}{' '}
<strong>Categories:</strong>{' '}
{categories.map((cat, i) => `${i ? ', ' : ''}${cat}`)}
</p>
</li>
))}
</ol>

But wait! We also want to have individual pages that list all of the posts for a given category. To do that, we’ll need to create a dynamic route. We’ll need to filter the posts we get back by the category specified by the route. For example, the /category/dog route should return only the posts categorized as “dog”.

Start by creating a new page at src/app/category/[category]/page.tsx:

import { categories, type Category } from '@/categories';
import { notFound } from 'next/navigation';

export default async function Category({
params,
}: {
params: { category: Category };
}) {
const { category } = params;

// 404 if the category does not exist
if (categories.indexOf(category) == -1) notFound();

return (
<main>
<h1>Category: {category}</h1>
</main>
);
}

Let’s walk through what’s happening here. First, we’re grabbing the category from our route parameters. We’ll send a 404 with the notFound() function provided by Next.js if the category doesn’t match one of our statically defined categories.

Now we need to get the filtered posts by category by creating a getPostsByCategory() function in src/posts.ts:

export async function getPostsByCategory({
category,
}: {
category: Category;
}): Promise<Post[]> {
const allPosts = await getPosts();

// Filter posts by specified category
const posts = allPosts.filter(
(post) => post.categories.indexOf(category) !== -1
);

return posts;
}

This is just like how we grabbed our post data on the homepage, except we filter all the posts we get back by category.

We can also take advantage of generateStaticParams() on our category routes to statically generate them at build time:

export function generateStaticParams() {
return categories.map((category) => ({
category,
}));
}

This will return a structure that looks like this:

[
{ cateogry: 'dog' },
{ category: 'cat' },
{ category: 'rock' },
]

The final result should look like this:

import { categories, type Category } from '@/categories';
import { Posts } from '@/components/posts';
import { getPostsByCategory } from '@/posts';
import { notFound } from 'next/navigation';

export default async function Category({
params,
}: {
params: { category: Category };
}) {
const { category } = params;

// 404 if the category does not exist
if (categories.indexOf(category) == -1) notFound();

const posts = await getPostsByCategory({ category });

return (
<main>
<h1>Category: {category}</h1>
<Posts posts={posts} />
</main>
);
}

export function generateStaticParams() {
return categories.map((category) => ({
category,
}));
}

Pagination

As you add more and more blog posts, it would probably be wise to add some pagination. We’ll need to add pagination to both the homepage and the category pages.

Let’s start with the homepage where we list all posts. We’ll create a new getPaginatedPosts() function in src/posts.ts:

export async function getPaginatedPosts({
page,
limit,
}: {
page: number;
limit: number;
}): Promise<{ posts: Post[]; total: number }> {
const allPosts = await getPosts();

// Get a subset of posts pased on page and limit
const paginatedPosts = allPosts.slice((page - 1) * limit, page * limit);

return {
posts: paginatedPosts,
total: allPosts.length,
};
}

This just takes our posts and returns a subset of them based on the page and limit (i.e., posts per page) defined. We also return the total number of posts to help us build our pagination controls.

We’ll also define a constant to store how many posts per page we want throughout our blog:

export const postsPerPage = 3 as const;

Now we’ll create a pagination component for our paging controls in src/components/pagination:

import Link from 'next/link';

export function Pagination({
baseUrl,
page,
perPage,
total,
}: {
baseUrl: string;
page: number;
perPage: number;
total: number;
}) {
return (
<div>
{page !== 1 && (
<>
<Link href={`${baseUrl}/${page - 1}`} rel="prev">
Previous
</Link>{' '}
</>
)}
{perPage * page < total && (
<Link href={`${baseUrl}/${page + 1}`} rel="next">
Next
</Link>
)}
</div>
);
}

We’re conditionally showing the “Previous” and “Next” controls if there are actually posts in those directions.

Let’s update our homepage to show the first page of paginated posts:

const { posts, total } = await getPaginatedPosts({
page: 1,
limit: postsPerPage,
});

And then we can also add the Pagination component to show the paging controls:

<Pagination baseUrl="/page" page={1} perPage={postsPerPage} total={total} />

Since we know that the homepage will be the first page, we can hardcode the page as 1.

Now let’s create the actual routes for the paginated pages. We want to have these live at a /page/{pageNumber} route so we’ll need to create a dynamic route at src/app/page/[page]/page.tsx:

import { notFound, redirect } from 'next/navigation';

export default async function Page({ params }: { params: { page: number } }) {
let { page } = params;
page = Number(page);

if (page < 1) notFound();

if (page == 1) redirect('/');

return (
<main>
<h1>Next.js MDX Blog (Page {page})</h1>
</main>
);
}

Now when we go to the /page/2 route, we’ll see our paginated page. We also 404 if the page specified isn’t a positive number and we redirect to the homepage if the page is 1 (because that’s what the homepage is for!).

Let’s add the paginated posts:

import { Pagination } from '@/components/pagination';
import { Posts } from '@/components/posts';
import { getPaginatedPosts, getPosts, postsPerPage } from '@/posts';
import { notFound, redirect } from 'next/navigation';

export default async function Page({ params }: { params: { page: number } }) {
let { page } = params;
page = Number(page);

if (page < 1) notFound();

if (page == 1) redirect('/');

const { posts, total } = await getPaginatedPosts({
page,
limit: postsPerPage,
});

if (!posts.length) notFound();

return (
<main>
<h1>Next.js MDX Blog (Page {page})</h1>
<Posts posts={posts} />

<Pagination
baseUrl="/page"
page={page}
perPage={postsPerPage}
total={total}
/>
</main>
);
}

export async function generateStaticParams() {
const posts = await getPosts();
const pages = Math.ceil(posts.length / postsPerPage);

return [...Array(pages)].map((_, i) => ({
page: `${i + 1}`,
}));
}

There’s a bit going on here so let’s walk through it. The first part is pretty simple, rather than getPosts(), we’re using our new function getPaginatedPosts() which accepts the page number and a limit (our posts per page). We then render our paginated posts through the Posts component. We’re also plugging in the page, posts per page, and total data into the Pagination component to show the appropriate paging controls.

Lastly, we’re using generateStaticParams() to statically generate the paginated pages at build time (just like our category pages). Here we get all of our posts with getPosts() and then get the number of pages that we’ll need to show all of them based off of how many total posts we have and how many posts per page we’re showing. Then we iterate over that number to create an array of parameter objects that Next.js needs to generate these pages.

The structure returned would look something like this:

[
{ page: '1' },
{ page: '2' },
]

Now we can tackle the category pages. We’ll create a new function called getPaginatedPostsByCategory() in src/posts.ts:

export async function getPaginatedPostsByCategory({
page,
limit,
category,
}: {
page: number;
limit: number;
category: Category;
}): Promise<{ posts: Post[]; total: number }> {
const allCategoryPosts = await getPostsByCategory({ category });

// Get a subset of posts pased on page and limit
const paginatedCategoryPosts = allCategoryPosts.slice(
(page - 1) * limit,
page * limit
);

return {
posts: paginatedCategoryPosts,
total: allCategoryPosts.length,
};
}

This works similarly to getPaginatedPosts(), except this function also takes in a category to filter on.

Just like the homepage, the category pages we created before will act as the first paginated page. We’ll update src/category/[category]/page.tsx with:

const { posts, total } = await getPaginatedPostsByCategory({
category,
page: 1,
limit: postsPerPage,
});

And we’ll add our paging controls too:

<Pagination
baseUrl={`/category/${category}/page`}
page={1}
perPage={postsPerPage}
total={total}
/>

Now to create the paginated category routes. We’ll need to use multiple dynamic route segments to capture both the category and page to display. The route folder structure for this looks like:

src/
└── app/
└── category/
└── [category]/
├── page.tsx
└── page/
└── [page]/
└── page.tsx

Our paginated category pages will be accessed at the /category/{category}/page/{pageNumber} route so /category/dog/page/2 will bring us to page 2 of posts categorized with dog.

In src/app/category/[category]/page/[page]/page.tsx:

import { Category, categories } from '@/categories';
import { Pagination } from '@/components/pagination';
import { Posts } from '@/components/posts';
import {
getPaginatedPostsByCategory,
getPostsByCategory,
postsPerPage,
} from '@/posts';
import { notFound, redirect } from 'next/navigation';

export default async function Page({
params,
}: {
params: { category: Category; page: number };
}) {
let { category, page } = params;
page = Number(page);

if (page < 1) notFound();

if (page == 1) redirect(`/category/${category}`);

const { posts, total } = await getPaginatedPostsByCategory({
category,
page,
limit: postsPerPage,
});

if (!posts.length) notFound();

return (
<main>
<h1>
Category: {category} (Page: {page})
</h1>
<Posts posts={posts} />

<Pagination
baseUrl={`/category/${category}/page`}
page={page}
perPage={postsPerPage}
total={total}
/>
</main>
);
}

export async function generateStaticParams() {
const paths = await Promise.all(
categories.map(async (category) => {
const posts = await getPostsByCategory({ category });
const pages = Math.ceil(posts.length / postsPerPage);

return [...Array(pages)].map((_, i) => ({
category,
page: `${i + 1}`,
}));
})
);

return paths.flat();
}

This is quite similar to what we created for the regular paginated pages, except we’re using getPaginatedPostsByCategory(). We’re also doing something a little bit more hairy in generateStaticParams() . In order to get the correct structure, we need to get how many pages each category has. To do this, we iterate over all of the categories, then apply the same technique as before to get the number of pages we’ll have. Then we iterate over each page and return the category and page parameters and flatten everything. The structure returned should look like this:

[
{ category: 'dog', page: '1' },
{ category: 'dog', page: '2' },
{ category: 'cat', page: '1' },
{ category: 'rock', page: '1' },
{ category: 'rock', page: '2' },
{ category: 'rock', page: '3' },
]

Wonderful! Now we’ve added pagination to our blog for both types of pages.

Wrapping it up

And there you have it, a fully functioning blog in Next.js powered by MDX. To recap, we learned how to set up MDX, add posts and metadata, define custom components, list all of our posts, add categories to our posts, and paginate everything!

Working through this was a huge unlock for me in terms of understanding (and appreciating) React server components. I think they really make a difference in terms of the developer experience when working with React and server-side code.

Check out the GitHub repository for the full source code or head over to CodeSandbox to play around with the example project in your browser.