Many static site generators and Next.js blogs store content as Markdown or MDX files on disk rather than in a database or CMS. Each post is a file in a content/posts/ directory with YAML frontmatter (title, date, tags, description) followed by the body content. A set of utility functions reads these files, parses the frontmatter, sorts posts by date, extracts slugs from filenames, and exposes the data to the site’s rendering pipeline. This “filesystem as database” pattern is simple, version-controlled, and requires no external services.
Teams commonly use this pattern for product documentation sites and engineering blogs. A lib/posts.ts module uses Node’s fs module and the gray-matter library to scan the posts directory, parse each file’s frontmatter, and return sorted post metadata for listing pages and full post content for individual pages. Next.js’s getStaticPaths and getStaticProps (or the App Router equivalents) call these utilities at build time to generate static HTML for every post.
This document covers the architecture of filesystem-backed content utilities: frontmatter parsing, slug extraction, date sorting, integration with static site generation, and patterns for scaling this approach as the content library grows.
Frontmatter is a block of YAML metadata at the top of a Markdown file, delimited by ---. It contains structured data like title, date, tags, description, and draft that describe the post without being part of the rendered content. The gray-matter library parses this block and returns a typed object with the frontmatter fields and the remaining Markdown body as separate properties. Frontmatter turns plain text files into structured content records.
Each post file is named with a URL-friendly slug: building-vector-search.mdx becomes the slug building-vector-search, which maps to the URL /blog/building-vector-search. The utility strips the file extension and optionally removes date prefixes (like 2024-01-15-building-vector-search.mdx). This convention means the filesystem directory listing directly generates the site’s URL structure—no routing configuration or database lookup needed.
Next.js calls getStaticPaths at build time to discover all valid routes and getStaticProps to fetch data for each route. The blog utilities provide getAllPostSlugs() for path discovery and getPostBySlug(slug) for data fetching. At build time, Next.js generates a static HTML page for every post, resulting in a site that loads instantly, requires no server-side rendering at request time, and can be served from a CDN.
MDX extends Markdown with JSX support, allowing authors to embed React components directly in their content. A blog post can include <CodePlayground />, <ApiReference endpoint="/search" />, or <Chart data={benchmarks} /> alongside standard Markdown. The MDX compiler transforms the file into a React component, and the rendering pipeline provides the component map that resolves these custom elements.
gray-matter is the de facto standard Node.js library for parsing frontmatter from text files. It supports YAML, TOML, and JSON frontmatter formats, handles edge cases like empty frontmatter and Windows line endings, and returns a clean object with data (parsed frontmatter), content (the body), and excerpt (optional auto-generated excerpt). It’s fast enough for hundreds of files at build time and has zero runtime dependencies beyond js-yaml.
The first step is defining TypeScript types for post metadata and content. These types enforce the frontmatter contract and provide IDE autocomplete throughout the codebase:
// lib/types.ts
export interface PostFrontmatter {
title: string;
date: string; // ISO 8601 date string
description: string;
tags: string[];
draft?: boolean;
author?: string;
coverImage?: string;
}
export interface PostMeta extends PostFrontmatter {
slug: string;
}
export interface Post extends PostMeta {
content: string; // raw MDX body (without frontmatter)
}
The core utility reads all MDX files from the posts directory, parses their frontmatter, extracts slugs, and returns sorted metadata:
// lib/posts.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import type { PostMeta, Post } from "./types";
const POSTS_DIR = path.join(process.cwd(), "content", "posts");
export function getAllPostSlugs(): string[] {
return fs
.readdirSync(POSTS_DIR)
.filter((f) => f.endsWith(".mdx") || f.endsWith(".md"))
.map((f) => f.replace(/\.mdx?$/, ""));
}
export function getPostBySlug(slug: string): Post {
const mdxPath = path.join(POSTS_DIR, `${slug}.mdx`);
const mdPath = path.join(POSTS_DIR, `${slug}.md`);
const filePath = fs.existsSync(mdxPath) ? mdxPath : mdPath;
const raw = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(raw);
return {
slug,
title: data.title,
date: data.date,
description: data.description,
tags: data.tags ?? [],
draft: data.draft ?? false,
author: data.author,
coverImage: data.coverImage,
content,
};
}
export function getAllPosts(): PostMeta[] {
const slugs = getAllPostSlugs();
return slugs
.map((slug) => {
const { content, ...meta } = getPostBySlug(slug);
return meta;
})
.filter((post) => !post.draft)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
The utilities plug directly into Next.js’s data fetching functions. For the Pages Router, getStaticPaths generates paths from slugs and getStaticProps fetches individual posts. For the App Router, these become generateStaticParams and direct function calls in Server Components:
// Pages Router: pages/blog/[slug].tsx
import { getAllPostSlugs, getPostBySlug } from "@/lib/posts";
import type { GetStaticPaths, GetStaticProps } from "next";
export const getStaticPaths: GetStaticPaths = async () => {
const slugs = getAllPostSlugs();
return {
paths: slugs.map((slug) => ({ params: { slug } })),
fallback: false,
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const post = getPostBySlug(params!.slug as string);
return { props: { post } };
};
// App Router: app/blog/[slug]/page.tsx
import { getAllPostSlugs, getPostBySlug } from "@/lib/posts";
export function generateStaticParams() {
return getAllPostSlugs().map((slug) => ({ slug }));
}
export default function BlogPost({ params }: { params: { slug: string } }) {
const post = getPostBySlug(params.slug);
// render post.content with MDX compiler
}
The listing page uses getAllPosts() to render a sorted, filtered list of post cards:
// app/blog/page.tsx
import { getAllPosts } from "@/lib/posts";
import Link from "next/link";
export default function BlogIndex() {
const posts = getAllPosts();
return (
<main>
<h1>Blog</h1>
{posts.map((post) => (
<article key={post.slug}>
<Link href={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
</Link>
<time dateTime={post.date}>
{new Date(post.date).toLocaleDateString()}
</time>
<p>{post.description}</p>
<div>
{post.tags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
</article>
))}
</main>
);
}
Filesystem-backed content utilities are commonly used for several purposes:
content/changelog/, and the changelog page renders them in reverse chronological order.Reading and parsing every MDX file on every page request (even at build time) can be slow with hundreds of posts. A simple in-memory cache stores parsed posts keyed by slug and invalidates on file modification time changes. In development mode, the cache checks fs.statSync(filePath).mtimeMs on each access; in production builds, the cache is populated once and never invalidated.
Building tag pages (/blog/tags/vector-search) requires an inverted index from tags to posts. The utility scans all posts, collects unique tags, and groups posts by tag. This runs at build time and generates a static page per tag:
export function getPostsByTag(): Map<string, PostMeta[]> {
const posts = getAllPosts();
const tagMap = new Map<string, PostMeta[]>();
for (const post of posts) {
for (const tag of post.tags) {
const existing = tagMap.get(tag) ?? [];
existing.push(post);
tagMap.set(tag, existing);
}
}
return tagMap;
}
The same post utilities power RSS feed generation. A build script calls getAllPosts(), formats each post as an RSS <item>, and writes the result to public/feed.xml. Because the utilities return structured data, generating Atom, JSON Feed, or Sitemap formats is equally straightforward.
fs.readFileSync is fine at build time but blocks the event loop in server-side rendering. If posts are loaded at request time (ISR, SSR), switch to fs.promises.readFile or pre-load all posts into memory at startup.date), the build fails with a cryptic error deep in the sorting function. Validate frontmatter early and surface clear error messages pointing to the problematic file.2024-01-15 is parsed as a Date object by js-yaml, while "2024-01-15" (quoted) remains a string. Always quote dates in frontmatter or normalize them in the utility function to avoid timezone-related sorting bugs.fs.readdirSync and sequential parsing become slow. Batch read with Promise.all and consider a build-time manifest file that caches parsed frontmatter so the listing page doesn’t re-parse every file.draft: true posts from getAllPosts() publishes unfinished content. Always filter drafts in the listing function and conditionally include them only in development mode.