Return_to_vault
[CONSTRUCT: 2026-02-20]
Next.js MDX Blog Loader
Next.jsMDXTypeScript
Next.js MDX Blog Loader
A file-system blog loader that reads MDX files from a directory, parses frontmatter with gray-matter, calculates reading time, and returns sorted posts with pinned entries first. No database, no CMS, no API. Just files on disk and a function that reads them. This powers the blog on this site.
When to Use
- Building a static MDX blog in the Next.js App Router without a headless CMS
- You want reading time estimation and pinned post support out of the box
- Keeping your content workflow simple: write an .mdx file, it shows up
The Code
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
const BLOG_DIR = path.join(process.cwd(), 'src/content/blog');
interface BlogPostMeta {
slug: string;
title: string;
excerpt: string;
date: string;
readingTime: string;
tags: string[];
published: boolean;
pinned?: boolean;
}
export async function getBlogPosts(): Promise<BlogPostMeta[]> {
const posts = getLocalPosts();
return posts.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
}
function getLocalPosts(): BlogPostMeta[] {
if (!fs.existsSync(BLOG_DIR)) return [];
return fs.readdirSync(BLOG_DIR)
.filter((file) => file.endsWith('.mdx'))
.map((file) => {
const content = fs.readFileSync(path.join(BLOG_DIR, file), 'utf-8');
const { data } = matter(content);
return {
slug: file.replace('.mdx', ''),
title: data.title,
excerpt: data.excerpt,
date: data.date,
readingTime: `${Math.ceil(content.split(/\s+/).length / 200)} min read`,
tags: data.tags || [],
published: data.published,
pinned: data.pinned,
} as BlogPostMeta;
})
.filter((post) => post.published);
}
Notes
The published frontmatter field is your draft system. Set it to false and the post won't appear anywhere. Reading time assumes 200 words per minute, which is conservative but honest.