Build a Markdown Blog

Create a blog with markdown-generated posts, with support for custom React components and syntax highlighting on code snippets.

Motivation

I've wanted to create a blog for awhile. When I decided to start building this blog a few weeks ago, I knew that I wanted to use Markdown for the following reasons, among others:

  • It's easy to write a new blog post
  • I don't have to worry about an external Content Management System
  • I can turn a note from Obsidian into a blog post seamlessly. I use Obsidian to take notes at work and in my personal life.

Technology Stack

Next.js

I wanted to use React, and I've watched some YouTube videos about Next.js, which takes advantage of some cool new features like React Server Components which I'd like to try out. Also, the built in router is really nice.


MDX

MDX allows you to transform markdown into HTML - pretty cool, but nothing special. But it also allows you to insert JSX into your markdown (hence the name), so you can render complicated React components in the middle of your markdown blog post.


Contentlayer

Contentlayer takes care of transforming our local markdown files into data that can be consumed by the Next.js application.


Tailwind

Tailwind describes itself as a 'utility-first' CSS framework. I won't go into detail here, but I highly recommend using Tailwind in front-end projects. If you already know CSS, there's very little to learn to get started.


Rehype Pretty Code

This is a plugin for Rehype, a module which relies on plugins to transform HTML. We'll use Rehype Pretty Code to create code blocks styled by language.

Note: This tutorial will not cover how to configure rehype-pretty-code within Contentlayer. Some helpful resources I used to accomplish this include Contentlayer's makeSource docs and a blog post from Delba Oliveira.

Building the blog

Let's begin by creating a new nextjs application

npx create-next-app@latest --typescript --tailwind --src-dir --experimental-app --app --eslint my-markdown-blog

If you check out the Contentlayer documentation, you might notice that the first section of my tutorial here overlaps heavily with their Getting Started page. If you're looking for more detail on specific properties or configurations, check out their docs.

Open the project in your favorite IDE and replace the contents of next.config.js with the following

next.config.js
const { withContentlayer } = require('next-contentlayer')
 
/** @type {import('next').NextConfig} */
const nextConfig = { reactStrictMode: true, swcMinify: true }
 
module.exports = withContentlayer(nextConfig)

Next, add the following lines to tsconfig.json

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"]
      // etc.
    }
    // etc.
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".contentlayer/generated"
    // etc.
  ]
}

Now add the following line to your .gitignore

.contentlayer

Creating a schema for our posts

First, we need to install Contentlayer

npm install contentlayer
npm install next/contentlayer

Because we're using Contentlayer, we need to define a schema for our posts in a file called contentlayer.config.ts in the root of our project.

contentlayer.config.ts
import { defineDocumentType, makeSource } from 'contentlayer/source-files'
 
export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `**/*.md*`,
  contentType: 'mdx',
  fields: {
    title: { type: 'string', required: true },
    date: { type: 'date', required: true },
    description: { type: 'string', required: true },
    keywords: {type: 'list', of: {type: 'string'}, required: true}
  },
  computedFields: {
    url: { type: 'string', resolve: (post) => `posts/${post._raw.flattenedPath}` },
  },
}))
 
export default makeSource({ contentDirPath: 'src/posts', documentTypes: [Post] })

There's a few important things to note here. First, I'm defining specific fields to be required at the top of my MDX files in order for Contentlayer to process them. These fields are completely optional, but we'll see their utility soon.

Also, I'm placing my posts in a folder called posts, which is inside the src folder in the root of my project. You should replace the contentDirPath property in the final line of the file to the path to your folder of MDX files.

Create your first post

Let's make our first post. To give you an idea of the output, I'll show you the first few lines of the markdown file used to generate this post

your-first-post.mdx
---
title: Build a Markdown Blog
date: 2023-9-18
description: Create a blog with markdown-generated posts, with support for custom React components and syntax highlighting on code snippets. 
keywords: ['blog', 'nextjs', 'mdx', 'markdown']
---
 
## Motivation
 
I've wanted to create a blog for awhile.

Now that we have a post, let's create a home page where users can see a list of all of our posts at src/app/page.tsx

src/app/page.tsx
import Link from 'next/link'
import { compareDesc, format, parseISO } from 'date-fns'
import { allPosts, Post } from 'contentlayer/generated'
 
function PostCard(post: Post) {
  return (
    <div className="mb-8">
      <h2 className="mb-1 text-xl">
        <Link href={post.url} className="text-blue-700 hover:text-blue-900 dark:text-blue-400">
          {post.title}
        </Link>
      </h2>
      <time dateTime={post.date} className="mb-2 block text-xs text-gray-600">
        {format(parseISO(post.date), 'LLLL d, yyyy')}
      </time>
    </div>
  )
}
 
export default function Home() {
  const posts = allPosts.sort((a, b) => compareDesc(new Date(a.date), new Date(b.date)))
 
  return (
    <div className="mx-auto max-w-xl py-8">
      <h1 className="mb-8 text-center text-2xl font-black">Next.js + Contentlayer Example</h1>
      {posts.map((post, idx) => (
        <PostCard key={idx} {...post} />
      ))}
    </div>
  )
}

We can run npm run dev to start a local server and then visit localhost:3000 to see our first page. If we click on the link for our first post, we'll get a 404 error, since we never created a page in our application that renders our post's content. Let's create that now at src/app/posts/[slug]/page.tsx

src/app/posts/[slug]/page.tsx
import { allPosts } from 'contentlayer/generated'
import { useMDXComponent } from 'next-contentlayer/hooks'
import { notFound } from 'next/navigation'
import { MDXComponents } from 'mdx/types'
import { format, parseISO } from 'date-fns'
 
export async function generateStaticParams() {
  return allPosts.map((post) => ({
    slug: post._raw.flattenedPath,
  }))
}
 
export const generateMetadata = ({ params }: { params: { slug: string } }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug);
  if (!post) notFound();
  return { 
    title: post.title,
    description: post.description,
    keywords: post.keywords,
  };
};
 
const mdxComponents: MDXComponents = {};
 
export default function Page({ params }: { params: { slug: string } }) {
  // Find the post for the current page.
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug)
 
  // 404 if the post does not exist.
  if (!post) notFound()
 
  // Parse the MDX file via the useMDXComponent hook.
  const MDXContent = useMDXComponent(post.body.code)
 
  return (
    <div className='max-w-2xl mx-auto mt-8 px-4'>
      <h1 className='text-3xl'>
        {post.title}
      </h1>
      <time dateTime={post.date} className='text-base opacity-60'>
        {format(parseISO(post.date), 'LLLL d, yyyy')}
      </time>
      <h2 className='text-sm'>
        {post.description}
      </h2>
      <div className='my-8'>
        <MDXContent components={mdxComponents} />
      </div>
    </div>
  )
}

Now try navigating to the post again. You'll see a fully rendered version of the markdown you wrote! If the header looks like a paragraph, that's because we haven't styled it yet. If you'd like, you can set the font size for an h2 element in src/app/globals.css to make it stand out.

Adding JSX to MDX

At this point, everything should be working. So we have our markdown files correctly being transformed into HTML, but we still need to figure out how to insert custom react components into our blog posts. There's three steps here.

  1. Create a react component
  2. Import the component to your markdown file below the metadata section, and call the component
  3. Add the component to the mdxComponents object in src/app/posts/[slug]/page.tsx

Okay, so first, let's build a really simple React component

src/components/Test/Test.tsx
"use client";
 
export default function Test() {
  return (
    <button
      className="bg-white text-black px-2 rounded-sm hover:opacity-60"
      onClick={() => console.log('clicked')}
    >
      Click Me
    </button>
  )
}

Next, let's add this <Test> component to our MDX file

your-first-post.mdx
---
title: Build a Markdown Blog
date: 2023-9-18
description: Create a blog with markdown-generated posts, with support for custom React components and syntax highlighting on code snippets. 
keywords: ['blog', 'nextjs', 'mdx', 'markdown']
---
 
## Motivation
 
I've wanted to create a blog for awhile.
<Test>

Finally, let's update our the page.tsx file that renders each of our blog posts

src/app/posts/[slug]/page.tsx
// Etc...
import Test from '@/components/Test/Test'
 
export async function generateStaticParams() {
  return allPosts.map((post) => ({
    slug: post._raw.flattenedPath,
  }))
}
 
export const generateMetadata = ({ params }: { params: { slug: string } }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug);
  if (!post) notFound();
  return { 
    title: post.title,
    description: post.description,
    keywords: post.keywords,
  };
};
 
const mdxComponents: MDXComponents = { 
  Test
};
 
export default function Page({ params }: { params: { slug: string } }) {
  {/* Etc.. */}
 
  return (
    {/* Etc.. */}
    <MDXContent components={mdxComponents} />
    {/* Etc.. */}
  )
}

Now if we load our app and select our post, we should see the button! If you open up a console in developer tools and click the button, you'll see the message logged. If you want to understand the purpose of the 'use client' directive we included at the top of our <Test> component, check out this page from the Next.js docs.

At this point, everything should be set up for you to start writing new blog posts

Bells and Whistles

One of the cool features of MDX is that we can easily transform any HTML component that would be produced by MDX into something different. As an example, let's transform all of the <a> tags on our site.

First, let's understand what's going on right now. In our markdown, we can insert a link with the following syntax

your-first-post.mdx
---
title: Build a Markdown Blog
date: 2023-9-18
description: Create a blog with markdown-generated posts, with support for custom React components and syntax highlighting on code snippets. 
keywords: ['blog', 'nextjs', 'mdx', 'markdown']
---
 
## Motivation
 
I've wanted to create a blog for awhile.
<Test>
[This is a link to example.com](https://example.com)

Currently, MDX transforms our markdown into an <a> tag. Now, let's see how we can transform all of those into Next.js Link components (<Link>)

src/app/posts/[slug]/page.tsx
// Etc...
import Link from 'next/link'
import Test from '@/components/Test/Test'
 
export async function generateStaticParams() {
  return allPosts.map((post) => ({
    slug: post._raw.flattenedPath,
  }))
}
 
export const generateMetadata = ({ params }: { params: { slug: string } }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug);
  if (!post) notFound();
  return { 
    title: post.title,
    description: post.description,
    keywords: post.keywords,
  };
};
 
const mdxComponents: MDXComponents = { 
  Test
  a: ({ href, children }) => <Link target='_blank' href={href as string}>{children}</Link>,
};
 
export default function Page({ params }: { params: { slug: string } }) {
  {/* Etc.. */}
 
  return (
    {/* Etc.. */}
    <MDXContent components={mdxComponents} />
    {/* Etc.. */}
  )
}

That's it! Adding this line to our mdxComponents Object, which gets passed into our call to <MDXContent>, tells MDX to transform all of the <a> tags to <Link> React components.

Using the same method with an <img> tag, we could easily format all of our images the exact same way.

Hosting

Personally, I'm hosting this site using Vercel, which is probably the easiest option for a Next.js site, although I've used it for plenty of other sites in the past too! It's completely free to use (unless your blog becomes really popular) - and you can configure your domain to point at Vercel's servers very easily.