(Update: I have modified my setup since writing this article. The new setup is discussed in How I Use Obsidian as a CMS for my Website.)
My website, gatlin.io, is a Next.js app on Vercel. Content for that website is written in markdown, 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.
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
The slug from the yaml frontmatter above doubles as the name of the file. 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.
As mentioned in the update at the start, I am now using Obsidian as the content source for my website. I document that setup in How I Use Obsidian as a CMS for my Website.
Thanks for reading :D