Introduction
"Simplicity is the ultimate sophistication." — Leonardo da Vinci
Sometimes the best technical decisions are the ones that remove complexity rather than add it. This is the story of how I migrated my blog from Sanity CMS to plain MDX files, and why it turned out to be one of the best decisions I have made for this portfolio.
The Setup: Why I Originally Chose Sanity
When I first built this portfolio, Sanity CMS seemed like the obvious choice for managing blog content:
- Visual Editor — a nice WYSIWYG interface for writing
- Structured Content — schema-driven content modeling
- Real-time Collaboration — though I was the only author
- CDN-hosted Images — automatic image optimization
- Webhook Revalidation — on-demand ISR when content changed
It worked. But over time, the cracks started showing.
The Breaking Point: Why I Decided to Leave
1. Overhead for a Single Author
I was running an entire CMS infrastructure for... myself. The Sanity Studio added routes, dependencies, and complexity that felt increasingly unnecessary:
src/sanity/
├── env.ts
├── lib/
│ ├── client.ts
│ ├── image.ts
│ └── queries.ts
├── schemaTypes/
│ ├── authorType.ts
│ ├── blockContentType.ts
│ ├── categoryType.ts
│ └── postType.ts
└── structure.tsAll this infrastructure for what could be a simple markdown file.
2. The External Dependency Problem
Every time I wanted to write, I had to:
- Open my site
- Navigate to
/studio - Wait for the Sanity Studio to load
- Write in their editor
- Hope the webhook fired correctly for revalidation
My content lived on someone else's servers. If Sanity changed their pricing, had an outage, or sunset a feature, I would be scrambling.
3. Code Blocks Were a Pain
As a developer writing technical content, code blocks are essential. Sanity's Portable Text format required custom serializers, and getting syntax highlighting right was always a battle:
// Old Sanity code block serializer — verbose and fragile
const CodeBlock = ({ value }: { value: CodeBlockValue }) => {
return (
<SyntaxHighlighter
language={value.language || 'text'}
style={oneDark}
customStyle={{ margin: '1.5rem 0', borderRadius: '8px' }}
>
{value.code}
</SyntaxHighlighter>
);
};With MDX, it is just... markdown:
```typescript
const greeting = "Hello, World!";
```4. Version Control? What Version Control?
My code was in Git. My content was in Sanity. Two sources of truth, zero unified history. I could not easily:
- Review content changes in PRs
- Roll back a post to a previous version
- See what changed alongside code changes
The Solution: MDX with Next.js
MDX gives you the best of both worlds: Markdown's simplicity with React's power.
Step 1: Install the Dependencies
pnpm add @next/mdx @mdx-js/loader @mdx-js/react
pnpm add remark-gfm rehype-slug rehype-prism-plus@next/mdx— official Next.js MDX integrationremark-gfm— GitHub Flavored Markdown (tables, strikethrough, etc.)rehype-slug— auto-generates IDs for headingsrehype-prism-plus— syntax highlighting with Prism.js
Step 2: Configure Next.js
// next.config.mjs
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import rehypeSlug from 'rehype-slug';
import rehypePrismPlus from 'rehype-prism-plus';
const nextConfig = {
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
};
const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeSlug, [rehypePrismPlus, { ignoreMissing: true }]],
},
});
export default withMDX(nextConfig);Step 3: Structure the Content
Each blog post is now a simple .mdx file with frontmatter metadata:
content/
└── blog/
├── my-first-post.mdx
├── another-post.mdx
└── this-post.mdxStep 4: Build the MDX Utilities
A small utility library to handle blog operations:
// src/lib/mdx/index.ts
import fs from 'fs';
import path from 'path';
const CONTENT_DIR = path.join(process.cwd(), 'content', 'blog');
export function getBlogSlugs(): string[] {
const files = fs.readdirSync(CONTENT_DIR);
return files
.filter((file) => file.endsWith('.mdx'))
.map((file) => file.replace(/\.mdx$/, ''));
}
export async function getBlogBySlug(slug: string) {
const { metadata } = await import(`@/content/blog/${slug}.mdx`);
const rawContent = fs.readFileSync(
path.join(CONTENT_DIR, `${slug}.mdx`),
'utf-8'
);
const readingTime = calculateReadingTime(rawContent);
return { metadata, slug, readingTime };
}Step 5: Render the Blog Page
The dynamic route imports and renders MDX directly:
// src/app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await getBlogBySlug(slug);
const { default: MDXContent } = await import(
`@/content/blog/${slug}.mdx`
);
return (
<article>
<h1>{post.metadata.title}</h1>
<MDXContent />
</article>
);
}
export async function generateStaticParams() {
const slugs = getBlogSlugs();
return slugs.map((slug) => ({ slug }));
}The Migration: What Changed
| Action | Details | Lines of Code |
|---|---|---|
| Removed | Sanity directory, Studio routes, webhook endpoint, Portable Text serializers, 8 npm packages | ~12,000 deleted |
| Added | 7 MDX blog posts, MDX utilities, custom components, Prism.js theme, cover images | ~7,500 added |
| Net result | Less code, fewer bugs, simpler maintenance | −4,700 lines |
The Benefits
1. Write Anywhere
Your favorite markdown editor, VS Code, Obsidian, or even neovim. No browser required.
2. Git-Native Content
Every post is version controlled. Full history, branches for draft posts, content reviews in PRs alongside code.
3. Blazing Fast Builds
No API calls during build. Everything is local filesystem reads. The build is noticeably faster.
4. True Ownership
Content lives in the repo. No vendor lock-in, no surprise pricing changes, no external dependencies.
5. Better Code Blocks
Prism.js with syntax highlighting, automatic language detection, and keyboard-accessible code regions. It just works:
const sum = (a: number, b: number): number => a + b;6. React Components in Markdown
Need a custom callout or an interactive demo? Just use it directly in your MDX file.
Gotchas and Solutions
OpenGraph Images Need Node.js Runtime
The OG image generator uses fs to read MDX files, but Next.js image routes default to Edge runtime:
// src/app/blog/[slug]/opengraph-image.tsx
export const runtime = 'nodejs';Reading Time Calculation
With MDX, you calculate reading time from the raw content:
export function calculateReadingTime(content: string) {
const text = content
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]*`/g, '')
.replace(/<[^>]*>/g, '');
const words = text.split(/\s+/).filter(Boolean).length;
const minutes = Math.ceil(words / 200);
return { text: `${minutes} min read`, minutes, words };
}Table of Contents
Without a structured AST from the CMS, headings are extracted from the raw markdown:
export function extractHeadings(content: string) {
const headingRegex = /^(#{1,4})\s+(.+)$/gm;
const headings = [];
let match;
while ((match = headingRegex.exec(content)) !== null) {
const level = match[1].length;
const text = match[2].trim();
const id = text.toLowerCase().replace(/\s+/g, '-');
headings.push({ id, text, level });
}
return headings;
}Should You Make the Switch?
MDX is perfect if you:
- Are a solo author or small team
- Write technical content with code blocks
- Want content in version control
- Value simplicity over features
Stick with a CMS if you:
- Have non-technical content editors
- Need complex workflows and approvals
- Require real-time collaboration
- Want a visual editing experience
Frequently Asked Questions
Conclusion: Key Takeaways
- Remove complexity — an entire CMS infrastructure is overkill for a single-author blog
- Own your content — MDX files in Git mean true version control and zero vendor lock-in
- Better DX — write in any editor, preview instantly, review in PRs
- Less code — the migration removed ~4,700 lines and 8 npm dependencies
Next step: If you are a solo developer running a CMS for your blog, consider whether the complexity is really serving you. Sometimes the best tool is the simplest one.
Stop Losing Traffic
to Invisible Pages
Pre-rendering makes your JavaScript site fully indexable — 15-minute setup, zero code changes.
Related Articles
JavaScript Rendering for SEO: Complete Guide to CSR, SSR, SSG, and ISR
Compare four JavaScript rendering architectures and learn which one maximizes your SEO performance. Includes real-world benchmarks and decision framework.

What Is Prerendering and Why Does It Matter for SEO
Learn how prerendering serves static HTML to search engine bots, solving JavaScript indexing problems and boosting organic traffic by up to 300%.
Crawl Budget Optimization: Make Every Bot Visit Count
Understand how search engines allocate crawl budget and learn practical techniques to ensure your most important pages get indexed efficiently.