Gatlin.io Icon
Image of Austin Gatlin's face
March 8, 2022 (last updated March 23, 2022)

Next.js Markdown Blog Setup

My website, gatlin.io, is a Next.js app on Vercel. I have a content/ folder with notes/, guides/, and posts/, each of which contain markdown documents which I pass through a processor built with unified ecosystem tools. In this article I will walk through a simplified version of my own setup, for posts in particular.

My approach will be to start with the markdown blog post, then show how read it, how to process it, and then to render it via Next.js and React.

An Example Blog Post

<!-- /content/posts/my-blog-post.md -->

---

title: My Blog Post
slug: my-blog-post
description: A blog post about something.

---

# My Blog Post

Wow, great content.

The most important thing in the example above is the yaml metadata content. It will eventually be parsed by gray-matter to give us the important metadata needed to programmatically setup the blog in Next.js.

Reading the Post

You'll have to trust me for the moment that the best way to read the post is to be passed the slug from the metadata above. That slug will double as the name of the markdown file. (There might be a better way to do this, but this has worked for me for years.) So, assuming I have a file content/posts/my-blog-post.md, and a slug of my-blog-post, this is how I will read it.

export function getPost(slug) {
  // get md file path
  const dir = join(process.cwd(), 'content/posts')
  const path = join(dir, `${slug}.md`)

  // read the file
  const { metadata, content } = readMdFile(path)

  // return post object
  return {
    metadata,
    html: mdToHtml(content),
  }
}

function readMdFile(mdFile) {
  // read file content
  const fileContent = readFileSync(mdFile)

  // pass it through gray-matter to read the yaml
  const { data, content } = matter(fileContent)

  // return content and metadata object
  return { content, metadata: toMetadata(data) }
}

We can now see how we are passing the raw file string to gray-matter, and how it returns the yaml data extracted from the remaining content. That remaining content is now just the markdown, which we now pass to mdToHtml() in the getPost() function above. Let's take a look at that mdToHtml() function now.

Markdown to HTML via Unified, Remark, Rehype

Unified is a complicated ecosystem, but its complexity is resultant from its variability, in that it works with arbitrary input data and output data. But, fundamentally it is pretty straightforward: It turns input data into output data. It does this by turning content into Abstract Syntax Trees which sounds way fancier that it really is. The upside is that ASTs create a lot of room for modification and manipulation when processing content.

Our goal is to turn markdown into html with some additional modifications along the way. You start with a basic processor by calling unified(), and then add sub-processors (or whatever they call them) to it by calling the .use() function. This chain of .use() calls is important to order correctly as it is a serial process. I've removed some of the options objects from what follows, but it is basically my actual processor setup as of this writing. Basically, the flow is: (a) turn markdown into mdast (markdown AST), (b) manipulate the mdast by adding support for github flavored markdown, (c) turn mdast into hast (Html AST), (d) manipulate the hast by adding support for heading ids/slugs, heading autolinks, and a table of contents, (e) transform the hast into Html.

function mdToHtml(content) {
  return unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkRehype)
    .use(rehypeSlug)
    .use(rehypeAutolinkHeadings)
    .use(rehypeToc)
    .use(rehypeHighlight)
    .use(rehypeStringify)
    .processSync(content)
    .toString()
}

Displaying the Post: The Next.js Blog Page

// pages/blog/post/[slug].tsx

export default function PostComponent({ post }) {
  return <article dangerouslySetInnerHTML={{ __html: post.html }} />
}

export async function getStaticProps({ params: { slug } }) {
  return {
    props: {
      post: getPost(slug),
    },
  }
}

export async function getStaticPaths() {
  return {
    paths: getSlugs().map(slug => ({ params: { slug } })),
    fallback: false,
  }
}

This is a simple PostComponent. Here are the key takeaways: All of my content is static, so I wrote a getStaticPaths() function to read all the slugs (we will explore the getSlugs() function in the next section). Next.js turns the list of slugs into static paths, so I'll now have router endpoints like /blog/post/my-blog-post. That slug is then passed in to the getStaticProps() function for each static path. Inside that function is where I fetch the post data for that specific slug, via the getPost() function we discussed above. All of this is done at compile time and pre-rendered on the server, so you get full SEO and fast time-to-first-paint and all that goodness. If you want to get more creative, recall that the post object contains all the metadata as well.

getSlugs

export function getSlugs() {
  // get dirname
  const dir = join(process.cwd(), 'content/posts')

  // read dir content, parse it, and map it to a list of slugs
  return readdirSync(dir)
    .map(fileName => join(dir, fileName))
    .map(readMdFile) // we saw this function earlier
    .map(md => md.metadata.slug)
}

In the above function we are reading the metadata generated by gray-matter to read the slug value, which we manually wrote at the top of our markdown file.

Conclusion

This should get you started down the path of a pretty hands-off blog setup. You can "just write" markdown files in your content/pages/ folder, and, so long as you have the metadata setup correctly, it "just works".

There are other interesting options to explore in the Markdown+Next.js content landscape, like MDX in Next.js, and of course there are other tools you can use for static-site generation. An even more hands-off approach is using GitBook and a custom domain, which I've used in the past and really enjoyed. But, I personally like the flexibility of Next.js and often use my gatlin.io website as a testbed for different frontend design ideas in React, etc.

Thanks for reading :D