Use markdown files like a database. Read directories of .md or .mdx files, query them by frontmatter fields, sort, filter, and limit — with an API modeled after Firestore.
npm install markdown-repository
Given a directory of blog posts:
posts/
firebase-cost-optimization.mdx
why-mvps-fail.mdx
react-server-components.mdx
Each with YAML frontmatter:
---
title: Firebase Cost Optimization
date: "2025-08-22"
tags: [firebase, devops]
hidden: false
---
Post content here.
Create a repository and query it:
import { MarkdownRepository, query, where, orderBy, limit, get } from "markdown-repository";
const posts = new MarkdownRepository("posts", {
extensions: [".mdx"],
});
// Get all posts
const all = get(posts);
// Get one post by slug (filename without extension)
const post = get(posts, "firebase-cost-optimization");
// Filter by tag
const firebasePosts = get(
query(posts, where("tags", "array-contains", "firebase"))
);
// Sort by date, newest first, take 5
const recent = get(
query(posts, orderBy("date", "desc"), limit(5))
);
const repo = new MarkdownRepository(directory, options);
| Option | Type | Default | Description |
|---|---|---|---|
extensions |
string[] |
[".md"] |
File extensions to include |
recursive |
boolean |
false |
Traverse subdirectories |
validator |
(item) => item is T |
accepts all | Type guard for content validation |
const posts = new MarkdownRepository("content/posts", {
extensions: [".mdx"],
});
For content organized in nested folders:
content/
discovery/
worth-building.mdx
user-research.mdx
delivery/
ship-early.mdx
incremental-releases.mdx
discovery.mdx
delivery.mdx
const principles = new MarkdownRepository("content", {
extensions: [".mdx"],
recursive: true,
});
// Slugs reflect the directory structure
get(principles, "discovery"); // → discovery.mdx
get(principles, "discovery/worth-building"); // → discovery/worth-building.mdx
// Get all — returns flat array with nested slugs
const all = get(principles);
// [
// { slug: "delivery", title: "Delivery", ... },
// { slug: "delivery/ship-early", title: "Ship Early", ... },
// { slug: "discovery", title: "Discovery", ... },
// { slug: "discovery/user-research", title: "User Research", ... },
// { slug: "discovery/worth-building", title: "Worth Building", ... },
// ]
Define a type guard to get type-safe access to frontmatter fields:
interface BlogPost extends MarkdownItem {
title: string;
date: string;
tags: string[];
}
function isBlogPost(item: MarkdownItem): item is BlogPost {
return (
typeof item.title === "string" &&
typeof item.date === "string" &&
Array.isArray(item.tags)
);
}
const posts = new MarkdownRepository<BlogPost>("posts", {
extensions: [".mdx"],
validator: isBlogPost,
});
// TypeScript knows post.title, post.date, post.tags exist
const post = get(posts, "firebase-cost-optimization");
console.log(post.title);
Files that fail validation are silently excluded from getAll() results. getBySlug() throws MarkdownRepositoryInvalidTypeError.
The query API follows Firestore’s functional pattern. Constraints are standalone functions that compose via query() and execute via get().
query(repository, ...constraints)Combines a repository with constraints. Returns a query object — no data is read until get() is called.
const q = query(
posts,
where("hidden", "!=", true),
orderBy("date", "desc"),
limit(10)
);
const results = get(q);
get(source, slug?)Executes a read. Three overloads:
get(posts, "my-slug") // → single item (T)
get(posts) // → all items (T[])
get(q) // → query results (T[])
where(field, operator, value)Filters items by a frontmatter field. Supports all Firestore comparison operators.
Equality
where("hidden", "==", true)
where("hidden", "!=", true)
Comparison
where("order", ">", 1)
where("order", ">=", 1)
where("order", "<", 10)
where("order", "<=", 10)
Array membership
// Field is an array, check if it contains a value
where("tags", "array-contains", "firebase")
// Field is an array, check if it contains any of these values
where("tags", "array-contains-any", ["firebase", "devops"])
Value membership
// Field value is one of these
where("status", "in", ["draft", "review"])
// Field value is none of these
where("status", "not-in", ["archived", "deleted"])
orderBy(field, direction?)Sorts results. Direction is "asc" (default) or "desc". Multiple orderBy constraints are applied in order — first is primary sort, second breaks ties.
orderBy("date", "desc")
orderBy("order", "asc")
orderBy("title") // defaults to "asc"
limit(count)Caps the number of returned items. Applied after filtering and sorting.
limit(5)
Constraints are plain objects. Build them conditionally, store them in arrays, spread them into query().
function getPosts({ tag, sortBy = "date", max } = {}) {
const constraints = [];
constraints.push(where("hidden", "!=", true));
if (tag) {
constraints.push(where("tags", "array-contains", tag));
}
constraints.push(orderBy(sortBy, "desc"));
if (max) {
constraints.push(limit(max));
}
return get(query(posts, ...constraints));
}
// All posts, newest first
getPosts();
// Firebase posts, 5 max
getPosts({ tag: "firebase", max: 5 });
Posts have a date field and optional hidden flag. A post is published when its date is today or earlier and it’s not hidden.
import { MarkdownRepository, query, where, get } from "markdown-repository";
import { parse, startOfDay, isBefore, isEqual } from "date-fns";
const posts = new MarkdownRepository("mod/jurij/posts", {
extensions: [".mdx"],
});
function parseDate(str) {
return str.includes(" ")
? parse(str, "yyyy-MM-dd HH:mm", new Date())
: parse(str, "yyyy-MM-dd", new Date());
}
function isPublished(post) {
if (post.hidden || !post.date) return false;
const postDate = startOfDay(parseDate(post.date));
const today = startOfDay(new Date());
return isBefore(postDate, today) || isEqual(postDate, today);
}
// All published posts, newest first
function getAllPosts() {
return get(posts)
.filter(isPublished)
.sort((a, b) => (parseDate(a.date) < parseDate(b.date) ? 1 : -1));
}
// Posts with a specific tag
function getPostsByTag(tag) {
return get(
query(posts, where("tags", "array-contains", tag))
).filter(isPublished)
.sort((a, b) => (parseDate(a.date) < parseDate(b.date) ? 1 : -1));
}
// Tag cloud with counts
function getAllTags() {
const tagMap = {};
for (const post of getAllPosts()) {
for (const tag of post.tags || []) {
tagMap[tag] = (tagMap[tag] || 0) + 1;
}
}
return Object.entries(tagMap)
.sort((a, b) => b[1] - a[1])
.map(([tag, count]) => ({ tag, count }));
}
A principles handbook organized by category, with ordered pages inside each category.
content/
discovery.mdx # category parent
discovery/
worth-building.mdx # order: 1
user-research.mdx # order: 2
delivery.mdx
delivery/
ship-early.mdx
incremental-releases.mdx
import { MarkdownRepository, query, where, orderBy, get } from "markdown-repository";
const principles = new MarkdownRepository("mod/principles/content", {
extensions: [".mdx"],
recursive: true,
});
// All principles as a flat list
function getAllPrinciples() {
return get(principles);
}
// Single principle by path — accepts string or array
function getPrincipleBySlug(slug) {
const slugString = Array.isArray(slug) ? slug.join("/") : slug;
return get(principles, slugString);
}
// All slugs as path arrays (for Next.js generateStaticParams)
function getAllSlugs() {
return get(principles).map((p) => p.slug.split("/"));
}
// Children of a category, sorted by order
function getCategoryChildren(category) {
return get(
query(
principles,
where("slug", "!=", category),
orderBy("order", "asc")
)
).filter((p) => p.slug.startsWith(category + "/"));
}
// app/blog/[slug]/page.js
import { posts } from "@/lib/posts";
import { get } from "markdown-repository";
export async function generateStaticParams() {
return get(posts).map((post) => ({ slug: post.slug }));
}
export default async function PostPage({ params }) {
const { slug } = await params;
const post = get(posts, slug);
return <article>{/* render post */}</article>;
}
// app/blog/[slug]/page.js (catch-all for nested slugs)
import { principles } from "@/lib/principles";
import { get } from "markdown-repository";
export async function generateStaticParams() {
return get(principles).map((p) => ({
slug: p.slug.split("/"),
}));
}
export default async function PrinciplePage({ params }) {
const { slug } = await params;
const post = get(principles, slug.join("/"));
return <article>{/* render principle */}</article>;
}
markdown-repository reads files from disk at runtime using fs.readdirSync. Next.js’s output file tracing can’t detect these paths automatically because they’re passed as strings to the constructor, not as static imports.
Add outputFileTracingIncludes to your next.config.mjs so the content directories are bundled into the serverless function:
// next.config.mjs
const nextConfig = {
outputFileTracingIncludes: {
"/blog/*": ["./content/posts/**"],
"/docs/*": ["./content/docs/**"],
},
};
The keys are route patterns, the values are glob patterns for the directories your repositories read from. Without this, you’ll get ENOENT: no such file or directory, scandir errors in production.
Every item returned by get() has this base shape:
interface MarkdownItem {
slug: string; // derived from filename (without extension)
content: string; // markdown body (everything after frontmatter)
excerpt: string; // auto-extracted excerpt
[key: string]: any; // all frontmatter fields spread here
}
The slug for flat directories is the filename: my-post.mdx becomes "my-post". For recursive directories, it includes the path: discovery/worth-building.mdx becomes "discovery/worth-building".
import {
MarkdownRepositoryNotFoundError,
MarkdownRepositoryInvalidTypeError,
} from "markdown-repository";
try {
const post = get(posts, "nonexistent-slug");
} catch (error) {
if (error instanceof MarkdownRepositoryNotFoundError) {
// File doesn't exist
}
if (error instanceof MarkdownRepositoryInvalidTypeError) {
// File exists but fails validator
}
}
Built and maintained by Jurij Tokarski from Varstatt. MIT licensed.