development

Markdown Blog in Next.js

Date Published

ℹ️ What This Post Covers

How we built a file-based blog using MDX and Next.js 15 — no database, no CMS, just markdown files with React components baked right in.

Building an MDX Blog in Next.js

After experimenting with various CMS solutions, we came back to one of the simplest and most powerful approaches: an MDX-based blog system powered by Next.js. MDX gives us the portability of Markdown combined with the expressiveness of React — the best of both worlds.

Markdown vs MDX

Markdown is great for content. MDX takes it a step further by letting you use React components directly inside your posts — no plugins, no shortcodes, no post-processing step.

📄 Plain Markdown

  • Text, headings, lists, code blocks
  • Links and images
  • Tables (via GFM)
  • Static content only

✨ MDX

  • Everything Markdown can do
  • Custom React components inline
  • Props, layouts, interactivity
  • Rich structured content

The Architecture

Each post lives in its own folder under storage/posts/, named with the publication date and slug. The system reads these at build time — no database queries, no API calls at runtime.

storage/posts/
├── 2026-02-02-markdown-blog-in-next-js/
│   ├── post.mdxcontent + frontmatter
│   ├── cover.jpg         ← hero background
│   ├── cover.webp        ← hero (webp)
│   ├── thumb.jpg         ← card preview (2× retina)
│   └── thumb.webp        ← card preview (webp)
└── ...

Frontmatter with gray-matter

We use gray-matter to parse YAML frontmatter for post metadata:

import matter from 'gray-matter'

const { data, content } = matter(fileContent)

const meta = {
  title: data.title,
  description: data.description,
  heroImage: data.heroImage,
  thumbImage: data.thumbImage,
}

⚠️ Image Paths

Images are served via an API route at /api/posts/images/[...path], not from the public/ directory. If you omit heroImage or thumbImage from frontmatter, the system defaults to /api/posts/images/{folder}/cover.jpg and thumb.jpg respectively.

MDX Components

This is where MDX really shines over plain Markdown. We register custom components globally — they are available in every post with no imports needed inside the MDX file.

✅ Available Components

Layout

  • Callout — info, warning, danger, success, dark variants
  • Grid — responsive 2 or 4 column layouts

Content

  • TimelineItem — dated timeline entries
  • LessonGroup — titled bullet lists
  • ExploitChain / ExploitStep — numbered step flows

Using Components in Practice

Here's how a Grid of Callouts looks in MDX source:

<Grid cols={2}>
  <Callout variant="info" title="First">
    Content here...
  </Callout>
  <Callout variant="success" title="Second">
    More content...
  </Callout>
</Grid>

And a timeline:

<TimelineItem date="Jan 1" color="blue">
  Project kickoff — picked the tech stack
</TimelineItem>
<TimelineItem date="Jan 15" color="purple">
  First post published
</TimelineItem>

Rendering with next-mdx-remote

next-mdx-remote handles the heavy lifting on the rendering side:

  • Full App Router support with server components
  • Custom component registration via the components prop
  • Syntax highlighting via rehype-highlight
  • GitHub Flavored Markdown via remarkGfm
  • Auto-generated heading anchors via rehypeSlug

Handling Removed Posts

Posts can be "removed" without deleting the folder — truncate the file to 0 bytes:

const stats = await fs.stat(postPath)
if (stats.size === 0) {
  // Return 410 Gone — the SEO-friendly way to retire content
}

The folder, images, and URL all stay intact. The server returns a proper HTTP 410 status instead of a 404.

Performance

⚡ Build Time

  • No database — pure filesystem reads
  • Statically generated at build time
  • CDN-friendly output
  • File mtime-based cache invalidation

🖼️ Images

  • Dual format: .webp + .jpg fallback
  • Browser picks format via <picture> — no JS
  • Thumbs at 2× for retina displays
  • Long cache headers on all assets

Writing a New Post

🚀 The Workflow

Steps

  • Create a folder: storage/posts/YYYY-MM-DD-your-slug/
  • Add post.mdx with title, description, category, and tags
  • Drop in cover and thumb images (jpg + webp pairs)
  • Use any MDX component — no imports needed
  • Commit and push — done

No login required, no admin interface, no database migrations. Just files in git.

#nextjs#mdx#markdown#blog#typescript#react