Benito Lopez

How to create a simple static blog with Next.js and markdown

September 8, 2024

Although there are more immediate solutions for creating a blog, I have always been fascinated by static site generators, especially for this type of application: they are easy to maintain and generally perform well. Of course, the world is full of such tools, and I have no reason to prefer Next.js to Hugo, Astro, etc. And probably Next.js is slightly overkill for just a static blog.

But then, why did I choose Next.js to create my site? Well, mainly because I consider the knowledge of Next.js a must-have skill for a web developer in 2024, and I wanted to work with the new App Router. Second, I needed React for features that I plan to add in the future. And, despite my reluctance to use frameworks as a first or only solution, I consider Next.js a nice and comfortable environment to work with React.

So, what features are we going to discuss? As per the title, the blog will be static using Next.js (App Router). Thanks to MDX, posts can be written in Markdown. Finally, we will use Rehype Pretty Code for syntax highlighting.

Let's start.

Setting up the Next.js project

In this guide, I will use TypeScript and Next.js 14 with the new App Router. So be sure to select both at installation time with the command:

npx create-next-app@latest

Integrating MDX for markdown content

To use markdown to write our posts and simultaneously use JSX in the content, we need to install MDX. To install the package and its dependencies, run the command:

npm install @next/mdx @mdx-js/loader @mdx-js/react

And update the next.config.mjs file to allow you to use MDX and have the .mdx files act as pages and routes in the application:

File: next.config.mjs

import nextMDX from "@next/mdx";
 
const withMDX = nextMDX({
  extension: /\.mdx?$/,
});
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  pageExtensions: ["js", "jsx", "mdx", "ts", "tsx"],
};
 
export default withMDX(nextConfig);
Important info:

Since we will have to use Rehype which is ESM only, you will have to use next.config.mjs as the file for configuration.

As a final step, we need to add an mdx-components.tsx file in the root of the project. This file can be used to extend the default components. For example, you could extend the H1 tag and add Tailwind classes to style it. And so on.

File: mdx-components.tsx

import type { MDXComponents } from "mdx/types";
 
export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    ...components,
  };
}
Important info:

Without this file @next/mdx cannot work.

At this point, you will be able to use page.mdx files inside the /app folder to write content in both Markdown and JSX.

Setting up routing for articles and categories

The idea is to use dynamic routing to organize articles and categories. So, /app/blog/(posts)/hello-world/page.mdx for posts and /app/category/[category]/page.tsx for categories.

Posts

First, we take advantage of the metadata object to add some information in posts:

File: /app/blog/(posts)/hello-world/page.mdx

export const metadata = {
  title: "Hello world",
  description: "Your description here",
  date: "2024-01-01T00:00:00Z",
  categories: ["javascript"],
};

Let's add some content to verify that the markdown is properly converted to HTML:

File: /app/blog/(posts)/hello-world/page.mdx

# Heading
 
## Sub-heading
 
# Alternative heading
 
## Alternative sub-heading
 
Paragraphs are separated
by a blank line.
 
Two spaces at the end of a line  
produce a line break.

At this point, all we have to do is write the function to retrieve all posts. We know that all posts are in the /app/blog/ folder, so we need to read the names of all the folders in the /app/blog/ folder to get a list of all the post slugs. Thanks to this list, it is possible to import each metadata to get the information we need for each post: the title, publication date, and categories. Of course, it is possible to leverage the metadata to pass other information, such as author names, tags, etc. This is just a starting point.

We then create a posts.ts file in /app/lib/ (or wherever you prefer) with the following content:

File: /app/lib/posts.ts

import { promises as fs } from "fs";
import path from "path";
 
export type Post = {
  slug: string;
  title: string;
  date: string;
  categories: string[];
};
 
export async function getPosts(): Promise<Post[]> {
  const blogDirPath = path.join(process.cwd(), `app/blog/(posts)`);
 
  const slugs = (await fs.readdir(blogDirPath, { withFileTypes: true })).filter(
    (dirent) =>
      dirent.isDirectory() &&
      !dirent.name.startsWith("[") &&
      dirent.name !== "page"
  );
 
  const posts = await Promise.all(
    slugs.map(async ({ name }) => {
      const { metadata } = await import(`./../blog/(posts)/${name}/page.mdx`);
      return { slug: name, ...metadata };
    })
  );
 
  posts.sort((a, b) => +new Date(b.date) - +new Date(a.date));
 
  return posts;
}

We now have a function that retrieves all of our blog posts so that we can conveniently pass them to a component. Create a new file in /app/components/posts.tsx:

File: /app/components/posts.tsx

import { type Post } from "@/app/lib/posts";
import Link from "next/link";
import { parseISO, format } from "date-fns";
 
export default function Posts({ posts }: { posts: Post[] }) {
  return (
    <>
      {posts.map(({ slug, title, date }) => (
        <div key={slug}>
          <h2>
            <Link href={`/blog/${slug}`}>{title}</Link>
          </h2>
          <span>{format(parseISO(date), "MMMM d, yyyy")}</span>
        </div>
      ))}
    </>
  );
}

As you can see, the component uses date-fns to format the date, so you have to install the package with:

npm install date-fns

Now that you have this component, you can use it on your pages. A good place might be in your site's /blog/ page. So, let's add a page.tsx file in the /app/blog/ folder with the following code:

File: /app/blog/page.tsx

import Posts from "@/app/components/posts";
import { getPosts } from "@/app/lib/posts";
 
export default async function Page() {
  const posts = await getPosts();
 
  return <Posts posts={posts} />;
}

Categories

Categories are assigned to the metadata of each post. However, to make the code more stable and simple, the categories must be part of a predefined list that you must create. First, let's build this list. In /app/lib/constants.ts we create a constant by adding some categories:

File: /app/lib/constants.ts

export const CATEGORIES = [
  {
    id: "react",
    name: "React",
  },
  {
    id: "javascript",
    name: "JavaScript",
  },
  {
    id: "wordpress",
    name: "WordPress",
  },
] as const;

And we create the functions needed to manage categories in /app/lib/categories.ts:

File: /app/lib/categories.ts

import { CATEGORIES } from "@/app/lib/constants";
 
export type Category = {
  id: string;
  name: string;
};
 
export function getCategoryById(id: string): Category | undefined {
  return CATEGORIES.find((c) => c.id === id);
}
 
export function getCategoryURL(id: string): string {
  return `/category/${id}`;
}

This file contains two functions: getCategoryById which allows you to find a category using its ID and getCategoryURL which returns the URL of a category, for example /category/javascript.

We then need a function that can return a list of posts filtered by a category. To do this, we need to edit the file /app/components/posts.ts adding this new function:

File: /app/lib/posts.ts

import { promises as fs } from "fs";
import path from "path";
 
export type Post = {
  slug: string;
  title: string;
  date: string;
  categories: string[];
};
 
export async function getPosts(): Promise<Post[]> {
  const blogDirPath = path.join(process.cwd(), `app/blog/(posts)`);
 
  const slugs = (await fs.readdir(blogDirPath, { withFileTypes: true })).filter(
    (dirent) =>
      dirent.isDirectory() &&
      !dirent.name.startsWith("[") &&
      dirent.name !== "page"
  );
 
  const posts = await Promise.all(
    slugs.map(async ({ name }) => {
      const { metadata } = await import(`./../blog/(posts)/${name}/page.mdx`);
      return { slug: name, ...metadata };
    })
  );
 
  posts.sort((a, b) => +new Date(b.date) - +new Date(a.date));
 
  return posts;
}
 
// New function
export async function getPostsByCategory({
  category,
}: {
  category: string;
}): Promise<Post[]> {
  const allPosts = await getPosts();
 
  const posts = allPosts.filter((post) => post.categories?.includes(category));
 
  return posts;
}

Finally, we edit our /app/components/posts.ts file to show categories under each post:

File: /app/components/posts.ts

import { type Post } from "@/app/lib/posts";
import Link from "next/link";
import { parseISO, format } from "date-fns";
import { CATEGORIES } from "@/app/lib/constants";
import { Category, getCategoryURL } from "@/app/lib/categories";
 
export default function Posts({ posts }: { posts: Post[] }) {
  return (
    <>
      {posts.map(({ slug, title, date, categories }) => (
        <div key={slug}>
          <h2>
            <Link href={`/blog/${slug}`}>{title}</Link>
          </h2>
          <span>{format(parseISO(date), "MMMM d, yyyy")}</span>
          {categories && categories.length > 0 && (
            <div>
              <span>Categories:</span>
              {categories.map((catId: string) => {
                const category = CATEGORIES.find((c) => c.id === catId) as
                  | Category
                  | undefined;
 
                if (!category) {
                  return null;
                }
 
                return (
                  <a href={getCategoryURL(category.id)} key={category.id}>
                    {category.name}
                  </a>
                );
              })}
            </div>
          )}
        </div>
      ))}
    </>
  );
}

As mentioned, we need a way to display a category page using the URL /category/slug. For example /category/javascript. To do this, we can take advantage of Next.js's dynamic router by creating a page.tsx file in the /app/category/[category]/ folder.

As with the post archive, we can leverage the same component to display all the posts in the current category:

File: /app/category/[category]/page.tsx

import Posts from "@/app/components/posts";
import { getPostsByCategory } from "@/app/lib/posts";
import { CATEGORIES } from "@/app/lib/constants";
import { notFound } from "next/navigation";
import { Category, getCategoryById } from "@/app/lib/categories";
 
export default async function Page({
  params,
}: {
  params: { category: string };
}) {
  const { category } = params;
 
  const categoryData = getCategoryById(category);
 
  if (categoryData === undefined) {
    return notFound();
  }
 
  const posts = await getPostsByCategory({ category });
 
  return <Posts posts={posts} />;
}
 
export function generateStaticParams() {
  return CATEGORIES.map((category: Category) => ({
    category: category.id,
  }));
}
Important info:

We make use of the generateStaticParams function in the categories routes to allow them to be generated statically at build time. More information can be found here.

Adding syntax highlighting with Rehype Pretty Code

The last step is entirely optional, as it adds syntax highlighting for blocks of code. Of course, not all blogs need this feature, so if you are not interested, skip this step.

First, let's install the necessary packages:

npm install rehype-pretty-code rehype-stringify @rehype-pretty/transformer

Now that Rehype is installed, we can edit the next.config.mjs file:

File: next.config.mjs

import nextMDX from "@next/mdx";
import rehypePrettyCode from "rehype-pretty-code";
 
/** @type {import('rehype-pretty-code').Options} */
const options = {
  theme: "min-light",
};
 
const withMDX = nextMDX({
  extension: /\.mdx?$/,
  options: {
    remarkPlugins: [],
    rehypePlugins: [[rehypePrettyCode, options]],
  },
});
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  pageExtensions: ["js", "jsx", "mdx", "ts", "tsx"],
};
 
export default withMDX(nextConfig);

In order to use Rehype, we need to create a new component, so in the /app/components/ folder, add a code.tsx file with the following code:

File: /app/components/code.ts

import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypePrettyCode from "rehype-pretty-code";
import { transformerCopyButton } from "@rehype-pretty/transformers";
 
export async function Code({ code }: { code: string }) {
  const highlightedCode = await highlightCode(code);
  return (
    <section
      dangerouslySetInnerHTML={{
        __html: highlightedCode,
      }}
    />
  );
}
 
async function highlightCode(code: string) {
  const file = await unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(rehypePrettyCode, {
      theme: "min-light",
      transformers: [
        transformerCopyButton({
          visibility: "always",
          feedbackDuration: 3_000,
        }),
      ],
    })
    .use(rehypeStringify)
    .process(code);
 
  return String(file);
}

Through this new Code component, it will then be possible to pass a string and have a block of code with a syntax highlight. For example:

<Code code={`
\`\`\`jsx {4} showLineNumbers
// server.mjs
import { createServer } from 'node:http';
 
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World!
');
});
 
// starts a simple http server locally on port 3000
server.listen(3000, '127.0.0.1', () => {
console.log('Listening on 127.0.0.1:3000');
});
\`\`\`
`} />

Conclusion

That's it. As you can see, it is relatively easy to create a static blog with Next.js if you have some familiarity with React and modern JavaScript tools. On the other hand, if this is not your case and you want to learn Next.js, I consider the static blog a great first approach because it is not too overwhelming, but at the same time, it allows you to touch some of the main features of this framework.

For access to the complete project, feel free to explore the GitHub repository.