Why I Ditched Sanity CMS for MDX (And Never Looked Back)

Sometimes the best technical decisions are the ones that remove complexity. Here's how migrating from Sanity CMS to MDX files simplified everything — and why it was one of the best decisions I ever made.

ostr.io Teamostr.io Team··8 min read
MDX vs CMS migration concept
ostr.io Team

About the author of this guide

ostr.io TeamEngineering Team with 10+ years of experience

Building pre-rendering infrastructure since 2015.

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.ts

All this infrastructure for what could be a simple markdown file.

2. The External Dependency Problem

Every time I wanted to write, I had to:

  1. Open my site
  2. Navigate to /studio
  3. Wait for the Sanity Studio to load
  4. Write in their editor
  5. 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 integration
  • remark-gfm — GitHub Flavored Markdown (tables, strikethrough, etc.)
  • rehype-slug — auto-generates IDs for headings
  • rehype-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.mdx

Step 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 table
ActionDetailsLines of Code
RemovedSanity directory, Studio routes, webhook endpoint, Portable Text serializers, 8 npm packages~12,000 deleted
Added7 MDX blog posts, MDX utilities, custom components, Prism.js theme, cover images~7,500 added
Net resultLess 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.

About the Author

ostr.io Team

ostr.io Team

Engineering Team at Ostrio Systems, Inc

The ostr.io team builds pre-rendering infrastructure that makes JavaScript sites visible to every search engine and AI bot. Since 2015, we have helped thousands of websites improve their organic traffic through proper rendering solutions.

Experience
10+ years
Try Free

Stop Losing Traffic
to Invisible Pages

Pre-rendering makes your JavaScript site fully indexable — 15-minute setup, zero code changes.

Stay Updated

Get SEO insights delivered to your inbox

Technical SEO tips, pre-rendering guides, and industry updates. No spam — unsubscribe anytime.