
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.mdx ← content + 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
componentsprop - 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+.jpgfallback - 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.mdxwith 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.