Building a blog with Next.js and Markdown
After using Next.js in a few personal projects, I thought it would be fun to build a blog with the framework. This project gives me an opportunity to write more and talk about the things I'm learning.
Originally I wanted to use a database like mongoDB or Postgres to store the post data. However, the more I thought about it I decided to go with a static approach instead of pulling in the posts from a database. This allows me to make use of server side rendering and keep costs down since these will be static pages.
I'm going to store the posts as markdown files, and then parse them out using the library gray-matter
. Then we can use the library Remote MDX
to display the contents of the post.
You can learn more about mark down and MDX with Next.js here. The page also includes other alternatives to gray-matter
if you want to check those out.
You can find the repo for this blog template here.
Project setup
Let's get started by creating a Next.js project.
npx create-next-app@latest
The Next.js options I'm going with are as follows:
- What is your project named?
blog-example
- Would you like to use TypeScript?
Yes
- Would you like to use ESLint?
Yes
- Would you like to use Tailwind CSS?
Yes
- Would you like to use
src/
directory?No
- Would you like to use App Router? (recommended)
Yes
- Would you like to customize the default import alias (@/*)?
No
We'll be using gray-matter to parse our markdown files for us. The library will convert the file into an object that includes the metadata and content.
npm install gray-matter
Once we have parsed the markdown files, we can use Remote MDX to display the content.
npm install next-mdx-remote
The markdown content will not be styled by default, but tailwind has the typography plugin that will style the content for us.
npm install -D @tailwindcss/typography
We're also going to install DaisyUI. This one is optional if you would like to use a different component library.
npm install -D daisyui@latest
File structure
The file structure looks like the diagram below. We're going to be using app routing in the project. This means any folder inside the app
folder with a page.tsx
inside of it will turn into a route. So if you want to add an about page it would look like app/about/page.tsx
.
├── app
│ ├── about
│ │ └── page.tsx
│ ├── blog
│ │ ├── [slug]
│ │ │ ├── not-found.tsx
│ │ │ └── page.tsx
│ │ └── page.tsx
│ ├── components
│ │ ├── BlogPostList.tsx
│ │ ├── Footer.tsx
│ │ └── Nav.tsx
│ ├── types
│ │ └── global.d.ts
│ ├── CommonFunctions.ts
│ ├── global.css
│ ├── layout.tsx
│ ├── not-found.tsx
│ └── page.tsx
└── posts
├── post-one.md
└── post-two.md
- app/about/page.tsx - Optional, but you may want an about page.
- app/blog/page.tsx - Blog post home page.
- app/blog/[slug]/page.tsx - Individual blog posts page.
- app/blog/[slug]/not-found.tsx - Optional, but you may want to customize the look of your blog post 404 page.
- app/components - Folder where our components will live. Currently we only have Nav, Footer and Blog post list components in here.
- app/types/global.d.ts - If we have any types that are shared over multiple files we can add them here and import them in.
- app/CommonFunctions.ts - Common functions that we can import to multiple files.
- app/not-found.tsx - Optional, but you may want to customize the look of your 404 page.
- posts - Folder to store your post markdown files.
app/blog/[slug]/page.tsx
Let's start in the app/blog/[slug]/page.tsx
file. Importing the file system, Remote MDX and gray-matter. We need file system to read the markdown file and the other two to parse and display the markdown content.
import fs from 'fs';
import matter from 'gray-matter';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { getMarkdownMetaData } from '@/app/CommonFunctions';
Now we can create a function to read the file from the post slug. For reference, the slug is what we're going to use to identify the post in the URL. The slug will be what we name the markdown files in the posts
folder. So if you have a post file my-first-post.md
, the slug would be my-first-post
and the URL to the post would be myblog.com/blog/my-first-post
.
Once we have the slug, we can try to find the file and read it. If the file is there, we'll pass it to gray-matter
and then return that object. If we don't find the file, we'll push the user to the blog not found page.
const getPostData = ( slug : string ) => {
let postData = null;
try{
const file = `posts/${slug}.md`;
const post = fs.readFileSync(file, 'utf8');
postData = matter(post);
} catch(error) {
notFound();
}
return postData;
};
Now that we can retrieve the post data and return the parsed data object, we can use the data in the page function. When a user tries to access a blog post, we'll get the slug from the params and pass that to our getPostData
. Once we have the data, we can generate the page. Currently I'm using post.data.title
, post.data.date
and post.content
, but you can add more metadata to the top of your markdown file and access that here.
export default function page( { params } : PageParams ) {
const slug = params.slug;
const post = getPostData(slug);
return (
<main>
<article className='prose lg:prose-ms w-full max-w-full mb-20 pre-tag'>
<h1>{post.data.title}</h1>
<div className='mb-12'>{post.data.date}</div>
<MDXRemote source={post.content} />
</article>
</main>
)
};
We're almost done with the blog post page, but we have an issue. Currently we're using the app/blog/[slug]/page.tsx
to display our posts. However, by using [slug]
in the folder path, we're telling Next.js that this is a dynamic route. This isn't ideal in this case, because the server will assume that these are dynamic pages which is not what we want. We want these pages to be static, because we are hosting them on the server.
Next.js offers a couple of functions to handle this for us. generateStaticParams can be used to generate a list of our posts and tell the server that these are static pages at build time.
We're using the function getMarkdownMetaData
to generate a list of our posts from the posts
folder. I've added this to the CommonFunctions.ts
file, so we'll come back to it once we're done with the blog post page. Once we have the list, we can use a map function on the list to get all of our slugs and pass that to the server. This way the server will know about all of the post urls when we build the project and turn them into static files.
export const generateStaticParams = async () => {
const posts = getMarkdownMetaData();
let slugs : { slug: string } [] = [];
posts.map((post) => {
slugs = [...slugs, { slug : post.slug }]
});
return slugs;
};
We can also use generateMetadata to generate SEO meta data from the parsed post data.
export async function generateMetadata( { params } : PageParams ): Promise<Metadata> {
const slug = params.slug;
const post = getPostData(slug);
return {
title: post.data.title,
description: post.data.subtitle
}
};
app/CommonFunctions.ts
Next up, let's talk about the getMarkdownMetaData
function in the common functions file.
This function is going to read all of the files in the posts
folder and then map each file to create an object from what gray-matter returns.
export const getMarkdownMetaData = () : BlogPostData[] => {
const folder = 'posts/';
const files = fs.readdirSync(folder);
let slugs : BlogPostData[] = [];
files.map((file)=> {
const fileData = fs.readFileSync( `posts/${file}`, 'utf8' );
const matterRes = matter(fileData);
if(file.endsWith('.md')) {
slugs = [...slugs, {
title: matterRes.data.title,
subtitle: matterRes.data.subtitle,
slug: file.replace('.md', ''),
category: matterRes.data.category,
date: matterRes.data.date
}];
}
});
return slugs;
};
tailwind.config.ts
Now that our blog page is working, we'll need to style it. If you look back at the code for the blog post page, we use this line to display the post content <MDXRemote source={post.content} />
. We don't really have much control over what happens in the MDXRemote tag as far as styling goes, so we need the tailwind typography plugin to help us with it.
You'll need to add the typography plugin. You can also add DaisyUI into the config while we're at it.
const config: Config = {
...,
plugins: [
require('@tailwindcss/typography'),
require("daisyui")
],
daisyui: {
themes: ["dim"]
},
};
Once the plugin is set up, the only other thing you need to do to get it to work is to wrap the markdown in an article tag with some css classes. The prose lg:prose-ms
css classes are part of tailwind typography.
<article className='prose lg:prose-ms'>
<MDXRemote source={post.content} />
</article>
app/layout.tsx
The main thing we're going to be focusing on here is adding in the DaiseyUI theme to the html tag for now.
<html lang='en' data-theme='dim'>
app/components/BlogPostList.tsx
Next we'll need a way to display a list of our posts in the blog home page. We can make use of the getMarkdownMetaData
function from the common functions file and then list out the posts. We'll create a simple link with the data for the user to click. If you're new to Next.js or React make sure to use the Link
tag for internal links. You can read more about react links here.
import React from 'react';
import { getMarkdownMetaData } from '@/app/CommonFunctions';
import { BlogPostData } from '../types/global';
import Link from 'next/link';
export default function BlogPostList() {
let postData = getMarkdownMetaData();
return (
<>
{postData.map(( post : BlogPostData, i) => {
return (
<Link key={i} href={`/blog/${post.slug}`}>
<div className='group m-5 ml-0 px-8 py-5 bg-base-300 rounded-sm'>
<div className='text-sm mb-1 text-primary'>{post.category}</div>
<div className='text-2xl font-medium group-hover:text-primary'>{post.title}</div>
<div className='text-xs mb-2'>{post.date}</div>
<div className='text-sm'>{post.subtitle}</div>
</div>
</Link>
)
})}
</>
)
};
app/blog/page.tsx
Over at the blog home page, we'll make a simple page using the BlogPostList
. The only thing this page will do for now is list all of our posts.
import BlogPostList from '../components/BlogPostList';
export default function page() {
return (
<main>
<div className='text-4xl mb-1'>Blog posts</div>
<BlogPostList />
</main>
)
};
app/types/global.d.ts
These are the types I've used in a few of the files.
export interface BlogPostData {
title : string,
subtitle : string,
slug : string,
category: string,
date: string
}
export interface PageParams {
params: {
slug: string
}
}
posts/post-one.md
An example of a post will look something like this. I would also recommend checking out this cheat sheet for markdown here.
---
title: "Post two"
subtitle: "..."
date: "April 12, 2024"
category: "web"
---
# Quoque malum mihi has origine arbor
## Fingetur quoque
Lorem markdownum limoso conlato, probat: victus suam illis ore refeci. Tanti
positas Molpeus iterum, qui cruentum certe!
Inmixtaque **spemque matre rugosoque** et mare verba coniecit setae: ossa
incerta! In fremunt tantum clipeo auctor terga pia; portante in silvas
_emeritis_ flammis fuit. Eandem mite instare docebo fama obstrusaque antra
calorem Camenae, Achille, montis, profuso excipit, origine simillima.
## Cupidinis pugnem
Tigno et memor violenta sumere Rhodopen et multo? Natalibus his nec, his
colligit frigore umbra in _fuisti_. Gestanda in lupi! Vultu te quinos poenamque
et dixit tecta pro matertera gaudet! Caelesti illa mors Forsitan post perosam
deducit illum radiis ignescere **fatale**.
Wrapping up
You can find the repo for this blog template here.
The rest of the files can be found on the repo or you can create them yourself. The about page, not found pages, nav component and footer component are all basic files.
You can also add images into the public\images
folder.