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.
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.
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:
- Create a directory named
blog
under theapp
directory. - Inside the
blog
directory, create a folder named[slug]
. - Within the
[slug]
folder, create a file namedpage.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 GuideNext.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.tsimport 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) filesreturn 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.tsximport { 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.tsimport { 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.tsximport { 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.tsimport 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.tsximport { 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.tsexport 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.tsximport { 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.tsexport 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.tsimport { 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!