MDX, NextJS, and You

Or How I Learned to Over-Engineer My Personal Blog and Love Vercel
5/3/2024

Hello Reader,

I began this pursuit with a perfectly capable personal website built with Jekyll and hosted for free on Github Pages. I populated it with my most pertinent professional information and used Jekyll’s to turn markdown into statically deployed blog pages to host a grand total of a single Lorem Ipsum example page. Then I slapped Riggraz’s “No Style Please” Jekyll theme on top and called it a day. Paradise on Earth.

Well, it would be had I not learned how to 3D model a low-poly rendition of my own head1 and needed the world to see my newest artistic endeavor. From a cursory Google Search I saw that Jekyll can include JavaScript/HTML literal content within its markdown contents but for some reason I thought I needed to migrate what I had over to NextJS23 and host on Vercel; what, it would take a weekend at most, right?

Incorrect.

MDX: JSX in Markdown

This project took me a little over two weeks to have functioning exactly how I wanted with a handful of significant twists and turns. Dear reader, it doesn't need to take you this long. The broad strokes of what I wanted are as follows:

  1. Create a NextJS app that mirrors the workflow of the Jekyll app.
  2. Host it off Vercel.

Point one necessitated turning markdown into HTML. This can be done using remark-rehype. However, I wanted to go further. To fully leverage the technology available to us by using a React-based framework like NextJS we need a way to integrate our markdown content with JSX. Enter MDX which does exactly that. NextJS offers documentation for integrating MDX within NextJS apps. Following these instructions so far should have you with NextJS app that serves MDX pages as valid routes.

To more closely follow my original Jekyll workflow I wanted to store the source MDX files on a local server (in my case a publish/ directory in the project root). This meant that I needed a package to handle fetching it dynamically. NextJS recommends the MDX Remote package, but I opted to go with MDX Bundler instead to import dependencies within the MDX file instead of handling mapping components myself.

And, finally, I needed to use NextJS's Dynamic Routing to handle creating routes based on my MDX slugs.

The crux of this emerging system rests on the Post interface which contains all the needed information for blog post style content4. The following code snippets live on a utils/posts.ts file in the project root.

export interface Post {     slug: string;     title: string;     date: Date;     author: string;     content: string; }

We need a function that takes in a slug corresponding to a proper MDX file and returns a full Post object.

import { join } from "path"; export async function getPostBySlug(slug: string): Promise<Post> {     const actualSlug = slug.replace(/\.mdx$/, "");     const fullPath = join(postsDirectory, `${actualSlug}.mdx`);     const fileContents = fs.readFileSync(fullPath, "utf-8");     // ... }

The following section extracts the full result object from the file by passing the fileContents as the source parameter. The mdxOptions section passes in RemarkGFM to enable parsing of Github Style Markdown in the markdown contents.

import { bundleMDX } from "mdx-bundler"; import remarkGfm from "remark-gfm"; export async function getPostBySlug(slug: string): Promise<Post> {     // ... const result = await bundleMDX({ source: fileContents, mdxOptions(options, frontmatter) { options.remarkPlugins = [ ...(options.remarkPlugins ?? []), remarkGfm ] return options } }); const { code, frontmatter } = result; // ... }

Finally we build the whole Post object from the data we get from the result's code (the MDX content) and frontmatter data.

export async function getPostBySlug(slug: string): Promise<Post> { // ...     const splitDate = frontmatter.date.split("/")     return ({         slug: actualSlug,         title: frontmatter.title,         date: new Date(splitDate[2], splitDate[1], splitDate[0]),         content: code     } as Post); }

In full, we have

import fs from "fs"; import { join } from "path"; import { bundleMDX } from "mdx-bundler"; import remarkGfm from "remark-gfm"; export async function getPostBySlug(slug: string): Promise<Post> {     const actualSlug = slug.replace(/\.mdx$/, "");     const fullPath = join(postsDirectory, `${actualSlug}.mdx`);     const fileContents = fs.readFileSync(fullPath, "utf-8");     const result = await bundleMDX({         source: fileContents,         mdxOptions(options, frontmatter) {             options.remarkPlugins = [ ...(options.remarkPlugins ?? []), remarkGfm ]             return options         }     });     const { code, frontmatter } = result;     const splitDate = frontmatter.date.split("/")     return ({         slug: actualSlug,         title: frontmatter.title,         date: new Date(splitDate[2], splitDate[1], splitDate[0]),         content: code     } as Post); }

Dynamic Routes

Now, in order to generate the app's routes according to the markdown files' slugs. Since these files can all be generated at build time we'll use the generateStaticParams function. This function will need to return an array of post slugs, where each individual slug represents a segment of a route. So, before we leave this utility file we need a function to return a list of all the post slugs.

export function getPostSlugs(): string[] {     return fs.readdirSync(postsDirectory); } export async function getAllPosts(): Promise<Post[]> {     const slugs = getPostSlugs();     const resolved = await Promise.all(         slugs             .map(async (slug) => await getPostBySlug(slug))     );     const posts = resolved         .sort((post1, post2) => (post1.date > post2.date ? -1 : 1));     return posts; }

In the App Router we can create dynamic routes by making a directory wrapped in square brackets. In this case I've made the directory posts/[slug]. In this directory is the page.tsx.

This is where we'll make our generateStaticParams function and use our getPostSlugs utility function to get all the post slugs to return the right kind of param our page function needs.

import { getAllPosts } from "@/utils/posts"; export async function generateStaticParams() {     const posts = await getAllPosts();     return posts.map((post) => ({         slug: post.slug,     })); }

The rest of this file is pretty straight forward: we only need to unpack the Post object we receive from getPostBySlug and return the page JSX.

import { notFound } from "next/navigation"; import { getAllPosts, getPostBySlug } from "@/utils/posts"; import { MDXContent, Page } from "@/app/components"; export default async function PostPage(     { params }: {         params: {             slug: string         }     } ) {     const { slug } = params;     const { title, date, content } = await getPostBySlug(slug);     if (!slug) {         return notFound();     }     return (         <Page hasBackbutton title={title}>             <div className="text-right" id="metadata">                 <span id="date">{ `${date.getDate()}/${date.getMonth()}/${date.getFullYear()}` }                 </span>             </div>             <div id="content">                 <MDXContent                     code={content}                 />             </div>         </Page>     ); }

We pass the content to a component MDXContent that uses MDX Bundler to generate the actual component and React's useMemo to memoize the component.

import { getMDXComponent } from "mdx-bundler/client"; import { useMemo } from "react"; export function MDXContent({ code }: { code: string }) {     const Component = useMemo( () => getMDXComponent(code), [code] );     return (         <Component />     ); }

There's a little bit more to the website, but that should be enough to get you started on making a NextJS blog app that can handle MDX.

Until Next Time,

Sean.


Footnotes

  1. For this project I am using the App Router. All techniques, directory structures, and code examples will be expressed for use under the App Router system.

  2. I will also be using TypeScript to take advantage of a strongly typed programming language.

  3. I did not end up using the Author field since I am the sole author of all content in this website.