I wanted to add markdown blog to my NextJS app. Initially I feared it would be a lot of work. But it turned out to be easy when using NextJS server side components, i.e. the appDir. This is the end result:

To get started I added the scaffolding for the markdown blog to the appDir:

app/
├── posts/
│   ├── [slug]
│   │   ├── head.tsx
│   │   └── page.tsx
│   ├── head.tsx
│   ├── page.tsx
│   └── utils.ts

The routing logic behind the folder structure is the same used in the pages folder. The blog will be located on /posts and each blog post on /posts/[slug]. The page file in each folder is rendered only on the server and contains the markup for the page. The head files add <title> tags and a few other relevant meta tags.

To render the actual markdown blog posts I added markdown-it as well as a few plugins (markdown-it-anchor and markdown-it-table-of-contents) and code highlighting via highlight.js.

Bootstrap is used for styling and layout (this declarative style of just slamming classes on is highly productive, especially if you aren’t good at designing). This is what the /posts page looks like:

import Link from 'next/link';
import { getPosts, formattedDate } from './utils';

export default function Posts() {
  const posts = getPosts(); // I will come back to this file, no worries 🤞

  return (
    <div className="row">
      <div className="col-12 col-md-8">
        <h1>Posts</h1>
        <h2 className="lead mb-3">
          Explore how to use AI Bot to improve your workflow and other insights
          into the fascinating world of AI and technology
        </h2>
        <ul className="list-unstyled">
          {posts.map((post) => (
            <li key={post.slug}>
              <article>
                <h2 className="h5 mb-1">
                  <Link href={`/posts/${post.slug}`}>{post.data.title}</Link>
                  {post.data.draft ? (
                    <span className="badge text-bg-primary ms-2">Draft</span>
                  ) : null}
                </h2>
                <p
                  className="text-muted d-flex align-items-center"
                  itemProp="dateline"
                >
                  <time
                    dateTime={post.data.date}
                    title={post.data.date}
                    itemProp="datePublished"
                  >
                    {formattedDate(post.data.date)}
                  </time>
                  {post.data.tags
                    ? post.data.tags.map((tag: string) => (
                        <span
                          key={tag}
                          className="badge text-bg-secondary ms-2"
                        >
                          {tag}
                        </span>
                      ))
                    : null}
                </p>
              </article>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

And the /posts/[slug] page:

import hljs from '@lib/highlight';
import Link from 'next/link';
import { getPosts, formattedDate } from '../utils';

const md = require('markdown-it')({
  highlight: function (str: string, lang: string) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return hljs.highlight(str, { language: lang }).value;
      } catch (__) {}
    }

    return ''; // use external default escaping
  },
  linkify: true,
  html: true,
  breaks: true,
});

md.use(require('markdown-it-anchor').default);
md.use(require('markdown-it-table-of-contents'), {
  includeLevel: [2, 3, 4, 5, 6],
  containerHeaderHtml: '<h6>Table of Contents</h6>',
  listType: 'ol',
});

type Props = { params: { slug: string } };

export default function Post({ params }: Props) {
  const posts = getPosts();
  const post = posts.find((p) => p.slug === params.slug);

  if (!post) {
    return <p>Post not found</p>;
  }

  return (
    <div className="row">
      <div className="col-12 col-md-8 mb-3">
        <article className="post">
          <header>
            <h1>
              {post.data.title}{' '}
              {post.data.draft ? (
                <span className="badge text-bg-primary">Draft</span>
              ) : null}
            </h1>
            <p
              className="text-muted d-flex align-items-center"
              itemProp="dateline"
            >
              <time
                dateTime={post.data.date}
                title={post.data.date}
                itemProp="datePublished"
              >
                {formattedDate(post.data.date)}
              </time>
              {post.data.tags
                ? post.data.tags.map((tag: string) => (
                    <span key={tag} className="badge text-bg-secondary ms-2">
                      {tag}
                    </span>
                  ))
                : null}
            </p>
          </header>
          <div dangerouslySetInnerHTML={{ __html: md.render(post.content) }} />
        </article>
      </div>
      <div className="col-12 col-md-4">
        <aside>
          <h2 className="h5">Posts</h2>
          <ul className="list-unstyled">
            {posts.map((p) => (
              <li key={p.slug} className="mb-2">
                <article>
                  <Link href={`/posts/${p.slug}`}>{p.data.title}</Link>
                  <p className="text-muted mb-0">
                    <time dateTime={p.data.date}>
                      {formattedDate(p.data.date)}
                    </time>
                  </p>
                </article>
              </li>
            ))}
          </ul>
        </aside>
        <Link href="/posts" className="link-dark">
          Back to posts
        </Link>
      </div>
    </div>
  );
}

The utils file contains the logic for getting the posts and formatting the date:

import fs from 'fs';
import matter from 'gray-matter';
import { cache } from 'react';

// The cache function is used to cache the posts so that we don't have to read the files every time the getPosts function is called
export const getPosts = cache(() => {
  return fs
    .readdirSync('posts') // posts are located in the posts folder in the root of the project
    .map((file) => ({
      slug: file.replace(/\.md$/, ''),
      ...matter(fs.readFileSync(`posts/${file}`, 'utf8')),
    }))
    .filter((post) => {
      if (process.env.NODE_ENV === 'development') {
        return true;
      }

      return post.data.draft !== true;
    })
    .sort((a, b) => (a.data.date > b.data.date ? -1 : 1));
});

export const formattedDate = (date: Date) =>
  new Date(date).toLocaleDateString('en-US', {
    day: 'numeric',
    month: 'long',
    year: 'numeric',
  });

Initially I deployed it without the cache function and it was using close to 900MB RAM, after adding the cache function it uses around 400MB RAM. Including researching how to add markdown blog to Next.js, it took me only around an hour to get it working.