
Article
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.
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 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.
Content lives as plain files on disk:
content/
├── blog/
│ └── rendering-mdx-without-a-markdown-library.mdx
└── projects/
└── craftiv.mdxEach file opens with frontmatter, which a small reader turns into a typed object:
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.
prose class.Being honest about the ceiling matters more than the pitch:
.mdx files but does not execute them, so a component in the body is just text.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.
More from the blog.
The full list of posts lives on the blog index.
Back to blog