npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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-core

Peer 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.mdxhello).
  • Recursive: slug = relative path without extension, POSIX-style (guides/hello.mdxguides/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