Rendering MDX Without a Markdown Library
Back to blog

Article

Rendering MDX Without a Markdown Library

This site renders its case studies and posts from .mdx files without shipping a markdown parser. Here's the whole approach, and where it stops being a good idea.

August 14, 20263 min readNext.jsMDXPerformance

Every content-driven site eventually reaches the same fork: pull in a markdown pipeline, or write the ~200 lines that cover the syntax you actually use. For this portfolio I took the second road, and after a year of case studies it still holds up.

The constraint that decided it

The content here is written by exactly one person — me — and it uses a small, stable subset of markdown: headings, lists, links, bold, inline code, block quotes, and the occasional code fence. Nothing that needs a full CommonMark implementation.

Pulling in a parser plus a plugin ecosystem to handle that subset is a lot of dependency surface for a problem that is genuinely small.

Every dependency is a bet that its maintenance cost stays below the cost of the code it replaces.

How it works

Content lives as plain files on disk:

txt
content/
├── blog/
│   └── rendering-mdx-without-a-markdown-library.mdx
└── projects/
    └── craftiv.mdx

Each file opens with frontmatter, which a small reader turns into a typed object:

ts
export const parseFrontmatter = (raw: string): MdxDocument => {
  const match = raw.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?/)
  if (!match) return { frontmatter: {}, content: raw.trim() }

  const frontmatter: RawFrontmatter = {}
  for (const line of match[1].split(/\r?\n/)) {
    const separator = line.indexOf(":")
    if (separator === -1) continue
    frontmatter[line.slice(0, separator).trim()] = parseValue(
      line.slice(separator + 1)
    )
  }

  return { frontmatter, content: raw.slice(match[0].length).trim() }
}

The body is parsed into a flat list of blocks — heading, paragraph, list, quote, code, image, rule — and each block maps to a styled React element. Because every page is a Server Component reading from the filesystem, the parse happens once at build time and the browser only ever receives HTML.

What this buys

  • Zero client bytes. No parser in the bundle, because no parsing happens in the browser.
  • Styling is not a fight. Each block renders a component I wrote, so the design language of the site applies directly instead of being reverse-engineered through a prose class.
  • The failure mode is boring. Unsupported syntax renders as literal text. It looks wrong; it never crashes the page.

What it costs

Being honest about the ceiling matters more than the pitch:

  1. No JSX in content. The real value of MDX is embedding components mid-article. This approach reads .mdx files but does not execute them, so a component in the body is just text.
  2. No syntax highlighting. Code blocks are monospaced and readable, not colored. Highlighting means Shiki or Prism, and that is a real dependency.
  3. Edge cases are yours. Nested lists, tables, and footnotes all need writing. Each one is small; together they are how homegrown parsers slowly become bad libraries.

When to switch

The moment content needs interactivity — a live demo, a chart, a component playground — switch to `@next/mdx`. It compiles .mdx into real components, and the frontmatter reader here maps onto it cleanly.

Until then, the tradeoff is straightforward: a parser I fully understand, for a syntax I fully control.


Every code sample above ships in this site. If a post ever renders oddly, that is the parser telling me it has outgrown its subset.

Keep Reading

More from the blog.

Every Post

The full list of posts lives on the blog index.

Back to blog