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:
- Create a NextJS app that mirrors the workflow of the Jekyll app.
- 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
-
↩ -
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. ↩
-
I will also be using TypeScript to take advantage of a strongly typed programming language. ↩
-
I did not end up using the
Author
field since I am the sole author of all content in this website. ↩