Published at May 16, 2025

How to create blog in MDX

Thumbnail for How to create blog in MDX

What is MDX?

MDX is a markdown extension that allows you to use JSX in markdown. It's a great way to write blog posts without any external dependencies. With MDX, you can create a blog page without any backend system like CMS. Everything is stored in the frontend itself, and using SSR frameworks like Next.js, you can build simple blog pages.

Let's get started

Let's start with the project setup. If you already have a project set up, you can skip this step.

bash npx create-next-app@latest my-blog --flag

Next, we'll install the necessary dependencies for MDX:

npm install @mdx-js/react @mdx-js/loader @next/mdx
npm install --save-dev @types/mdx

Now we need to modify our next.config.js file to support MDX files:

import type { NextConfig } from "next";
import createMDX from "@next/mdx";

const nextConfig: NextConfig = {
  pageExtensions: ["ts", "tsx", "js", "jsx", "mdx"],

  // if you are using next 15 with turbopack also add it here
  turbopack: {
    resolveExtensions: [".mdx", ".tsx", ".ts", ".jsx", ".js", ".mjs", ".json"],
  },

  // other options...
};

const configWithMDX = createMDX();

export default configWithMDX(nextConfig);

The last step before diving into the code is to create a file called mdx-components.tsx in the src directory.

It should be placed directly in src/mdx-components.tsx. This is important.

Now let's dive into .mdx files

The first step is to create a directory where you'll store your .mdx files. This is basically a directory with all your posts and articles for your blog. I prefer to store it in the src/blog directory, but it's up to you. I might refer to this specific directory in the code, so please keep in mind that you might need to adjust the path accordingly.

Let's create your first post and call it hello-world.mdx.

import { Button } from "@/components/button";

export const metadata = {
  title: "Hello world!",
  slug: "hello-world",
  description: "Our first post",
  publishedAt: new Date("2023-01-01"),
  tags: ["blog", "nextjs", "mdx"],
};

# Hello world!

It's my first post written in MDX. It's super useful 🚁

## Steps we already did:

- Install dependencies
- Create directory for posts
- Create first post

<Button>Click me</Button>

If you want to copy this code, make sure to adjust it to your components in your project.

As you can see, there are a few interesting things here:

Markdown specific features:

MDX specific features:

Prepare a route to handle MDX

Now we'll create a route that will handle MDX files. We'll use a dynamic route here, so create a page.tsx inside your app directory. I'd prefer to do it in app/blog/[slug]/page.tsx.

import { tryCatch } from "@/utils/try-catch";
import { notFound } from "next/navigation";

export default async function BlogPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  const { data, error: notFoundError } = await tryCatch(
    import(`@/blog/${slug}.mdx`)
  );

  if (notFoundError) return notFound();

  const MDXContent = data.default;

  return <MDXContent />;
}

The code above is pretty simple. We're just getting the slug from the current route and then looking for the file that's named like the slug we're looking for.

If it returns any error, it probably means that the file doesn't exist, so we just return the notFound() function which shows the user a 404 page.

If it returns an actual file, we're just getting the default import from it with data.default and rendering it normally like a regular component. And it just works! But we can do more interesting stuff with it!

As you might notice, it's pretty simple and you probably want to customize it more or make it more advanced.

First, we'll use our metadata object that we defined earlier. It can contain any data you need. For this case, we'll use the zod library to validate the data to make sure the data is there and create type-safe applications. Just install it:

npm install zod

And create a simple validator for the metadata object:

import { z } from "zod";

export const metadataSchema = z.object({
  slug: z.string(),
  title: z.string(),
  description: z.string(),
  date: z.date(),
  tags: z.array(z.string()),
});

export type PostMetadata = z.infer<typeof metadataSchema>;

Before we continue, I want to make this cleaner and create some basic utility functions that will handle code that will probably be used multiple times - getting all articles and single ones.

Getter for all posts

export const getAllPosts = async (): Promise<PostMetadata[]> => {
  const allPosts = fs.readdirSync(path.join(process.cwd(), "src/blog"));

  const posts = await Promise.all(
    allPosts.map(async (post) => {
      const { data: postData, error: notFoundError } = await tryCatch(
        import(`@/blog/${post}`)
      );

      if (notFoundError) return false;

      const { data: metadata, error } = metadataSchema.safeParse(
        postData.metadata ?? {}
      );

      if (error) return false;

      return metadata;
    })
  );

  return posts.filter(Boolean) as PostMetadata[];
};

The code above just returns an array of metadata objects.