mdx-blog-core
v0.1.0
Published
Headless MDX blog utilities for Next.js: filesystem loading, TOC, RSS, neighbours, search filter, LLM markdown shell.
Readme
mdx-blog-core
Headless utilities for MDX- or Markdown-based blogs in Next.js (App Router) and other React apps.
This package does not render UI or compile MDX. You keep your own MDX pipeline (next-mdx-remote, next-mdx-rsc, custom loaders, etc.) and your own React components. mdx-blog-core focuses on loading posts from disk, TOC extraction, RSS XML, neighbour navigation, search filtering, and LLM-oriented markdown shells.
Optional companion: **mdx-blog-ui** (copy-paste blog UI via CLI from the same workspace)—separate package, not required for mdx-blog-core.
Install
npm install mdx-blog-core
# or
pnpm add mdx-blog-core
# or
yarn add mdx-blog-corePeer dependencies
| Package | Required | Notes |
| --------- | ----------------- | ------------------------------------------------------------------------ |
| react | Yes (>=18) | Used by React.cache in the Node loader and by client entry points. |
| next | Optional (>=14) | Only needed if you import mdx-blog-core/next (uses next/navigation). |
Runtime Node.js is required for mdx-blog-core/node (filesystem access).
Transitive dependencies
The package depends on **gray-matter** (frontmatter) and **fumadocs-core** (table of contents). You do not need to install those separately for normal use.
Package entry points
Use the subpath that matches where your code runs (Node server vs browser).
| Import | Environment | Purpose |
| --------------------- | -------------------- | --------------------------------------------------------------------------- |
| mdx-blog-core | Server / shared | Types, post helpers, TOC, RSS, LLM markdown helpers, escapeXml |
| mdx-blog-core/node | Node only | createBlogSource — read .md/.mdx from disk, cached with React.cache |
| mdx-blog-core/react | Client | useFilteredPosts — memoized filter by title/description query |
| mdx-blog-core/next | Client (Next.js) | PostKeyboardNavigation, usePostKeyboardNavigation — arrow-key prev/next |
Important: Import mdx-blog-core/node only from code that runs in the Node.js runtime (e.g. Next.js Server Components, getStaticProps-style data loaders), not from Edge bundles if your toolchain cannot resolve node:fs.
Types (mdx-blog-core)
These shapes match what createBlogSource expects by default; you can extend metadata with parseMetadata + generics.
PostMetadata
| Field | Type | Description |
| ------------- | ---------- | ------------------------------------------------------------- |
| title | string | Post title |
| description | string | Short summary |
| image | string? | Cover / OG image URL |
| category | string? | Used with getPostsByCategory / filterPostsByCategory |
| new | boolean? | Optional flag for UI |
| pinned | boolean? | Pinned posts sort first (see sortPostsByPinnedAndCreatedAt) |
| createdAt | string | ISO date string (sorting) |
| updatedAt | string | ISO date string |
Post
type Post = {
metadata: PostMetadata
slug: string
content: string // body without frontmatter (MDX/Markdown)
}FsPost (mdx-blog-core/node)
Same as a Post for metadata, slug, and content, plus:
filePath: string— absolute path to the source file.
PostPreview
Minimal shape: { slug, title, category? } for list UIs that should not ship full content.
TOCItemType
Re-exported from fumadocs-core/toc. Returned items from getTableOfContents include title, url, depth, etc.—use them in your own TOC component.
Loading content: createBlogSource (mdx-blog-core/node)
import { createBlogSource } from "mdx-blog-core/node"
const blog = createBlogSource({
contentDir: "content/blog",
extensions: [".mdx", ".md"],
recursive: true,
})
const all = blog.getAllPosts()
const one = blog.getPostBySlug("my-post")
const guides = blog.getPostsByCategory("guides")Options
| Option | Type | Default | Description |
| ---------------------- | ---------- | ------------------------- | ----------------------------------------------------------------------------------------------------- |
| contentDir | string | (required) | Absolute path, or relative to process.cwd() |
| extensions | string[] | [".mdx"] | Allowed file extensions (case-insensitive) |
| recursive | boolean | false | If true, walk subfolders; slug becomes path relative to contentDir with / (e.g. guides/hello) |
| parseMetadata | function | identity cast | (data, { filePath, slug }) => Meta — use Zod (or similar) here |
| sort | function | pinned + createdAt desc | Custom sort; affects neighbour order |
| throwOnDuplicateSlug | boolean | true | Throw if two files resolve to the same slug |
Slug rules
- Flat directory: slug = filename without extension (
hello.mdx→hello). - Recursive: slug = relative path without extension, POSIX-style (
guides/hello.mdx→guides/hello).
Caching
getAllPosts is wrapped in **React.cache**, so within a single request the file system is read once. Ensure this module is only used in contexts where React’s cache is valid (RSC / Next data layer).
Example frontmatter
---
title: "Hello world"
description: "A short summary for lists and RSS."
createdAt: "2026-04-01"
updatedAt: "2026-04-05"
category: "guides"
image: "/og/hello.png"
pinned: false
new: true
---
Your MDX body starts here.Validating frontmatter with Zod
import { z } from "zod"
import { createBlogSource } from "mdx-blog-core/node"
import type { PostMetadata } from "mdx-blog-core"
const Schema = z.object({
title: z.string(),
description: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
category: z.string().optional(),
image: z.string().optional(),
pinned: z.boolean().optional(),
new: z.boolean().optional(),
}) satisfies z.ZodType<PostMetadata>
const blog = createBlogSource({
contentDir: "content/blog",
parseMetadata: (data, _ctx) => Schema.parse(data),
})Table of contents (mdx-blog-core)
import { getTableOfContents, type TOCItemType } from "mdx-blog-core"
const items = getTableOfContents(post.content)Uses fumadocs-core’s TOC extractor on the raw MDX/Markdown string. Heading IDs in the rendered page should match item.url fragments (e.g. #section-id)—configure your MDX pipeline (e.g. rehype-slug) accordingly.
Previous / next post (mdx-blog-core)
import { findNeighbour } from "mdx-blog-core"
const { previous, next } = findNeighbour(allPosts, currentSlug)Order matches the array order of allPosts (usually your sorted list from getAllPosts()). If the slug is missing, both neighbours are null.
Sorting and filters (mdx-blog-core)
import {
sortPostsByPinnedAndCreatedAt,
filterPostsByCategory,
filterPostsByQuery,
} from "mdx-blog-core"
posts.sort(sortPostsByPinnedAndCreatedAt)
const guides = filterPostsByCategory(posts, "guides")
const hits = filterPostsByQuery(posts, searchQuery)filterPostsByQuery normalizes spaces and case; it matches title and description substrings.
RSS (mdx-blog-core)
import { buildRssFeedXml, type RssChannelInfo, type RssFeedItem } from "mdx-blog-core"
const channel: RssChannelInfo = {
title: "My blog",
link: "https://example.com",
description: "Latest posts",
}
const items: RssFeedItem[] = posts.map((p) => ({
title: p.metadata.title,
link: `${siteUrl}/blog/${p.slug}`,
description: p.metadata.description,
pubDate: p.metadata.createdAt,
}))
const xml = buildRssFeedXml(channel, items)escapeXml is used internally; export it if you build custom XML.
Next.js App Router example (app/blog/rss.xml/route.ts or similar):
import { buildRssFeedXml } from "mdx-blog-core"
export function GET() {
const xml = buildRssFeedXml(channel, items)
return new Response(xml, {
headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
})
}LLM / plain markdown export (mdx-blog-core)
For “view as markdown” or RAG-friendly text, process MDX with your own remark/unified pipeline, then wrap:
import { buildLlmMarkdownDocument } from "mdx-blog-core"
const markdown = buildLlmMarkdownDocument({
title: post.metadata.title,
description: post.metadata.description,
bodyMarkdown: processedBody,
updatedAtLabel: "April 5, 2026",
})Or use **buildLlmMarkdownFromPost** when you want the package to assemble title, description, and “Last updated” around an async body converter:
import { buildLlmMarkdownFromPost } from "mdx-blog-core"
const markdown = await buildLlmMarkdownFromPost({
post,
toBodyMarkdown: async ({ content }) => {
// run remark/rehype here; return plain markdown string
return content
},
getUpdatedAtLabel: ({ updatedAt }) => updatedAt,
})Client: search list (mdx-blog-core/react)
"use client"
import { useFilteredPosts } from "mdx-blog-core/react"
const visible = useFilteredPosts(posts, query)Pass query from URL state (nuqs, useSearchParams, etc.). null/undefined/empty string returns all posts.
Client: keyboard navigation (mdx-blog-core/next)
Requires Next.js (next/navigation).
"use client"
import { PostKeyboardNavigation } from "mdx-blog-core/next"
export function BlogPostChrome({
basePath,
previous,
next,
}: {
basePath: string
previous: { slug: string } | null
next: { slug: string } | null
}) {
return (
<PostKeyboardNavigation
basePath={basePath.replace(/\/$/, "")}
previous={previous}
next={next}
/>
)
}ArrowLeft / ArrowRight navigate when defaultPrevented is false on the key event. For custom routing, use **usePostKeyboardNavigation** with your own push function.
License
MIT
