MDX Blog with Next.js 13

Published
· 2 years ago
next.jsmdxblogtypescriptreactmarkdown
Next.js version 13 introduced the /app directory, a new way of defining routes in your application.
In this article, we'll go over the basics of using next-mdx-remote to render MDX files with Next.js 13 and TypeScript.
The code we'll be writing in this article is available on GitHub.
Update
I now recommend using Contentlayer instead of next-mdx-remote.
It takes much less code to set up and it works beautifully with Next.js 13.

Setup

First, we'll need to create a new Next.js 13 app:
npx create-next-app@latest --experimental-app
# or
yarn create next-app --experimental-app
# or
pnpm create next-app --experimental-app
npx create-next-app@latest --experimental-app
# or
yarn create next-app --experimental-app
# or
pnpm create next-app --experimental-app
Next, we'll need to install next-mdx-remote:
npm install next-mdx-remote
# or
yarn add next-mdx-remote
# or
pnpm add next-mdx-remote
npm install next-mdx-remote
# or
yarn add next-mdx-remote
# or
pnpm add next-mdx-remote

Creating a Post

Let's create an example post. Create a new directory /content in the project root and create a new file /content/post.mdx:
/content/post.mdx
---
title: Example Post
date: '2022-12-11'
---
 
This is an example post.
 
# Heading 1
 
## Heading 2
 
### Heading 3
 
#### Heading 4
 
##### Heading 5
 
<Card>
 
    ### This Card is a **custom component**.
 
    Markdown **can** be used *inside* custom components.
 
</Card>
/content/post.mdx
---
title: Example Post
date: '2022-12-11'
---
 
This is an example post.
 
# Heading 1
 
## Heading 2
 
### Heading 3
 
#### Heading 4
 
##### Heading 5
 
<Card>
 
    ### This Card is a **custom component**.
 
    Markdown **can** be used *inside* custom components.
 
</Card>
Note that we're using a custom component called <Card><Card> in the post. We'll define this component later on.

Defining Types

Let's head over to /app/page.tsx, the home page of our app, and where we'll be rendering our post.
We'll define 2 types, FrontmatterFrontmatter and PostPost:
  • FrontmatterFrontmatter will define which properties we want to extract from the frontmatter of our MDX files. In this case, we'll extract the titletitle and datedate.
  • PostPost will define the shape of our post. It will contain the serialized MDX content and the frontmatter.
/app/page.tsx
import { type MDXRemoteSerializeResult } from 'next-mdx-remote';
 
type Frontmatter = {
  title: string;
  date: string;
};
 
type Post<TFrontmatter> = {
  serialized: MDXRemoteSerializeResult;
  frontmatter: TFrontmatter;
};
/app/page.tsx
import { type MDXRemoteSerializeResult } from 'next-mdx-remote';
 
type Frontmatter = {
  title: string;
  date: string;
};
 
type Post<TFrontmatter> = {
  serialized: MDXRemoteSerializeResult;
  frontmatter: TFrontmatter;
};

Serializing the Post

Next, we'll define an async functionasync function called getPostgetPost that takes a filepath, and returns a PostPost object by reading the file from the filesystem and serializing the MDX content.
/app/page.tsx
// ...
 
import { promises as fs } from 'fs';
import { serialize } from 'next-mdx-remote/serialize';
 
// ...
 
async function getPost(filepath: string): Promise<Post<Frontmatter>> {
  // Read the file from the filesystem
  const raw = await fs.readFile(filepath, 'utf-8');
 
  // Serialize the MDX content and parse the frontmatter
  const serialized = await serialize(raw, {
    parseFrontmatter: true,
  });
 
  // Typecast the frontmatter to the correct type
  const frontmatter = serialized.frontmatter as Frontmatter;
 
  // Return the serialized content and frontmatter
  return {
    frontmatter,
    serialized,
  };
}
/app/page.tsx
// ...
 
import { promises as fs } from 'fs';
import { serialize } from 'next-mdx-remote/serialize';
 
// ...
 
async function getPost(filepath: string): Promise<Post<Frontmatter>> {
  // Read the file from the filesystem
  const raw = await fs.readFile(filepath, 'utf-8');
 
  // Serialize the MDX content and parse the frontmatter
  const serialized = await serialize(raw, {
    parseFrontmatter: true,
  });
 
  // Typecast the frontmatter to the correct type
  const frontmatter = serialized.frontmatter as Frontmatter;
 
  // Return the serialized content and frontmatter
  return {
    frontmatter,
    serialized,
  };
}

Getting the Post Inside a Page Component

Now let's get this post inside our <Home><Home> component. We'll have to change the <Home><Home> component to be an asyncasync server component so we can use awaitawait in the top level of the component.
/app/page.tsx
export default async function Home() {
  // Get the serialized content and frontmatter
  const { serialized, frontmatter } = await getPost('content/post.mdx');
 
  return (
    <div style={{ maxWidth: 600, margin: 'auto' }}>
      <h1>{frontmatter.title}</h1>
      <p>Published {frontmatter.date}</p>
    </div>
  );
}
/app/page.tsx
export default async function Home() {
  // Get the serialized content and frontmatter
  const { serialized, frontmatter } = await getPost('content/post.mdx');
 
  return (
    <div style={{ maxWidth: 600, margin: 'auto' }}>
      <h1>{frontmatter.title}</h1>
      <p>Published {frontmatter.date}</p>
    </div>
  );
}
We should now see the title and date of our post rendered on the page.
Frontmatter
Our post's frontmatter rendered on the page

Rendering the Post's Content

next-mdx-remote provides a component called <MDXRemote><MDXRemote>, that is used to render MDX content. To use it, we must wrap it in a client component.
Client Components are rendered on the client. With Next.js, Client Components can also be pre-rendered on the server and hydrated on the client.
Today, many components from npm packages that use client-only features do not yet have the directive. These third-party components will work as expected within your own Client Components, since they themselves have the "use client" directive, but they won't work within Server Components. source
Let's create a new component file called /app/mdx-content.tsx:
/app/mdx-content.tsx
'use client';
 
import { MDXRemote, type MDXRemoteSerializeResult } from 'next-mdx-remote';
 
type MdxContentProps = {
  source: MDXRemoteSerializeResult;
};
 
export function MdxContent({ source }: MdxContentProps) {
  return <MDXRemote {...source} />;
}
/app/mdx-content.tsx
'use client';
 
import { MDXRemote, type MDXRemoteSerializeResult } from 'next-mdx-remote';
 
type MdxContentProps = {
  source: MDXRemoteSerializeResult;
};
 
export function MdxContent({ source }: MdxContentProps) {
  return <MDXRemote {...source} />;
}
All this component does is render the <MDXRemote><MDXRemote> component.
Meme
If we want to add custom components to our posts, we can do so by passing a componentscomponents prop:
/app/mdx-content.tsx
// ...
 
/** Place your custom MDX components here */
const MdxComponents = {
  /** h1 colored in yellow */
  h1: (props: React.HTMLProps<HTMLHeadingElement>) => (
    <h1 style={{ color: '#FFF676' }} {...props} />
  ),
  /** Card component */
  Card: (props: React.HTMLProps<HTMLDivElement>) => (
    <div
      style={{
        background: '#333',
        borderRadius: '0.25rem',
        padding: '0.5rem 1rem',
      }}
      {...props}
    />
  ),
};
 
export function MdxContent({ source }: MdxContentProps) {
  return <MDXRemote {...source} components={MdxComponents} />;
}
/app/mdx-content.tsx
// ...
 
/** Place your custom MDX components here */
const MdxComponents = {
  /** h1 colored in yellow */
  h1: (props: React.HTMLProps<HTMLHeadingElement>) => (
    <h1 style={{ color: '#FFF676' }} {...props} />
  ),
  /** Card component */
  Card: (props: React.HTMLProps<HTMLDivElement>) => (
    <div
      style={{
        background: '#333',
        borderRadius: '0.25rem',
        padding: '0.5rem 1rem',
      }}
      {...props}
    />
  ),
};
 
export function MdxContent({ source }: MdxContentProps) {
  return <MDXRemote {...source} components={MdxComponents} />;
}
Now let's import the <MdxContent><MdxContent> component into our <Home><Home> component and render it, passing in the serialized content from our post:
/app/page.tsx
// ...
import { MdxContent } from './mdx-content';
 
// ...
 
export default async function Home() {
  // Get the serialized content and frontmatter
  const { serialized, frontmatter } = await getPost('content/post.mdx');
 
  return (
    <div style={{ maxWidth: 600, margin: 'auto' }}>
      <h1>{frontmatter.title}</h1>
      <p>Published {frontmatter.date}</p>
      <hr />
      <MdxContent source={serialized} />
    </div>
  );
}
/app/page.tsx
// ...
import { MdxContent } from './mdx-content';
 
// ...
 
export default async function Home() {
  // Get the serialized content and frontmatter
  const { serialized, frontmatter } = await getPost('content/post.mdx');
 
  return (
    <div style={{ maxWidth: 600, margin: 'auto' }}>
      <h1>{frontmatter.title}</h1>
      <p>Published {frontmatter.date}</p>
      <hr />
      <MdxContent source={serialized} />
    </div>
  );
}
That's it! We've successfully rendered an MDX file on the page using next-mdx-remote, and we've added custom components to it.
Result
Our post rendered on the page

Conclusion and Next Steps

In this article, we've only scratched the surface of using next-mdx-remote with Next.js 13.
To make an actual blog, we might want to move our getPostgetPost function to a file called /lib/mdx.ts (or similar) and add two more functions: getAllPostsgetAllPosts, which returns all of the posts in our blog, and getAllMdxFilesgetAllMdxFiles, which returns all of the MDX files in the content directory.
We can then leverage Next.js 13's Dynamic Segments and Static Params Generation to create a dynamic route for each post, and render the post's content on the page.
You can find my implementation of this on github.com/kfirfitousi/blog for reference and inspiration.
Update
My blog now uses Contentlayer instead of next-mdx-remote to render MDX files.
2024 · 서강대학교 미래교육원