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
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
{
"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.
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
---
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
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
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.
- Create a react component
- Import the component to your markdown file below the metadata section, and call the component
- 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
"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
---
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
// 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
---
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>
)
// 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.