How to create a simple static blog with Next.js and markdown
September 8, 2024Although 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);
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,
};
}
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,
}));
}
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.