Publishing an MDX file as a page route in Next.js


When it was time to implement the uses page of Bolaji's portfolio (yes, again), I asked for the updated list of his tools and he sent it over in markdown.

I didn't want to convert it to HTML and since we currently store his blog posts in markdown, I wanted to see if there was a workaround available. While this page wasn't a blog post, the styling was going to be similiar to one.

I had two options:

  • Use template literals and import the content of the file as a string and feed it to the markdown parser or
  • Publish the page as a blog post, filter it from the displayed posts and setup a redirect to the post when you visit the uses route.

The first option was easier to implement compared to second one which involved writing a custom filter and middleware to handle the flow.

I decided to find out if I could publish a markdown file as a page route. While checking through Next.js's Docs, I found the pageExtensions config that allows developers to extend the default page extensions that Next.js's compiler checks to render content.

Since I already had a markdown formatter for the blog posts, all I had to do was modify the pageExtensions config to include the .md|mdx extension and load the content.

Luckily enough, Next.js has a package @next/mdx that handles the compilation of MDX content and outputs JS for use.

Implementation (App Directory)

  1. Install the following packages:

    yarn add @next/mdx @types/mdx
    npm install @next/mdx @types/mdx
    
  2. If you already use MDX to store content, you have a file where you keep all your custom MDX components. You can chose to move that file to your project's root and rename it as mdx-components.tsx or import your custom components into this file and update it with the following content:

    // mdx-component.tsx
    import type { MDXComponents } from "mdx/types";
    
    // your custom components
    let components = {
      h1: ({ children }) => <h1 className="2xl">{children}</h1>,
      // other components
    };
    
    // the compiler needs this export
    export function useMDXComponents(components: MDXComponents): MDXComponents {
      return {
        ...components,
      };
    }
    
  3. Update the next.config.js file at your project's root like this to configure it to use MDX:

    // next.config.js
    const path = require("path");
    const withMDX = require("@next/mdx")();
    
    const nextConfig = {
      pageExtensions: ["mdx", "ts", "tsx", "js", "jsx"],
    
      // other config options
      reactStrictMode: true,
    };
    
    module.exports = withMDX(nextConfig);
    

And you are good to go.

Note that you can also extend the withMDX config to add support for custom plugins. You can check the docs for more details.

Implementation (Pages Directory)

The setup is similiar with the App Directory but you have to install two additional packages @mdx-js/loader & @mdx-js/react and you also don't need an mdx-components.tsx file for this setup.

However, if you need to parse .md files, you need to extend the config like this:

// next.config.js
const withMDX = require("@next/mdx")({
  extension: /\.(md|mdx)$/,
});

const nextConfig = {
  // config options
};

module.exports = withMDX(nextConfig);

This allows you to use .mdx and .md files as page routes in the Pages Directory.

Conclusion

In this guide, we've explored how to leverage Next.js's pageExtensions config and the @next/mdx package to integrate Markdown and MDX Content directly into a Next.js application in the App and Pages Directory structure. This not only streamlines our workflow but also makes it easier to manage and update content-heavy pages without compromising on functionality.

Bonus Tip #1

Another usage of the pageExtensions config is setting up custom extensions for your page files:

// next.config.js
const nextConfig = {
  pageExtensions: ["page.mdx", "page.ts", "page.tsx", "page.js", "page.jsx"],

  // other config options
  reactStrictMode: true,
};

With this configuration, files named home.page.tsx and home.page.mdx will be recognized as pages, while files named page.tsx won't work.

Bonus Tip #2

Just in case you are curious about the second method, this is what the middleware function is going to look like:

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname === "/uses") {
    // Rewrite the URL internally but keep the visible URL as /uses
    return NextResponse.rewrite(new URL("/writings/uses", request.url));
  }
}

export const config = {
  matcher: ["/uses"],
};

Writing a custom filter is trivial and this left to the reader as an exercise.

Note that while this approach might look simplier, the complexity increases if we decide to add more index routes.