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

Published: (December 31, 2025 at 04:07 AM EST)
7 min read
Source: Dev.to

Source: Dev.to

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’d 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 (
    <pre>
      <code>{value.code}</code>
    </pre>
  );
};

With MDX, it’s 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 couldn’t 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. Here’s how I set it up.

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 (for a table of contents)
  • 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'],
  // …other config
};

const withMDX = createMDX({
  options: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [rehypeSlug, [rehypePrismPlus, { ignoreMissing: true }]],
  },
});

export default withMDX(nextConfig);

Step 3 – Create MDX Components

The mdx-components.tsx file at the project root customizes how MDX elements render:

// mdx-components.tsx
import type { MDXComponents } from 'mdx/types';
import Image from 'next/image';
import Link from 'next/link';

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    // Custom heading with auto‑generated ID
    h2: ({ children, id }) => (
      <h2 id={id}>## {children}</h2>
    ),

    // Smart links: internal vs external
    a: ({ href, children }) => {
      if (href?.startsWith('/')) {
        return <Link href={href}>{children}</Link>;
      }
      return (
        <a href={href} target="_blank" rel="noopener noreferrer">
          {children}
        </a>
      );
    },

    // Accessible code blocks
    pre: ({ children }) => (
      <pre className="code-block">
        {children}
      </pre>
    ),

    ...components,
  };
}

Step 4 – Structure the Content

Each blog post is now a simple .mdx file with exported metadata:

content/
└── blog/
    ├── my-first-post.mdx
    ├── another-post.mdx
    └── this-post.mdx

A typical file looks like this:

export const metadata = {
  title: 'My Blog Post',
  slug: 'my-blog-post',
  publishedAt: '2025-01-01',
  categories: ['nextjs', 'react'],
  coverImage: '/images/blog/my-post.avif',
  author: {
    name: 'Akshay Gupta',
    avatar: '/images/blog-author.png',
  },
  excerpt: 'A short description of the post.',
};

TL;DR

Running a full‑blown CMS for a single author added unnecessary complexity.
Switching to MDX gave me:

  • Zero external dependencies – everything lives in the repo
  • Native Git history – content changes are part of PRs
  • Simpler code – no custom serializers for code blocks
  • Fast, reliable builds – no runtime fetches from a remote API

If you’re juggling a personal blog or a small‑scale site, consider ditching the heavyweight CMS and let MDX do the heavy lifting. Simplicity truly is the ultimate sophistication.

Introduction

Your markdown content goes here…

Step 5: Build the MDX Utilities

I created 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 6: 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);

  // Dynamic import of the MDX content
  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

Removed (~12 000 lines)

  • Entire src/sanity/ directory
  • Sanity Studio routes (/studio)
  • Webhook revalidation endpoint
  • Portable Text serializers
  • 8 Sanity‑related npm packages

Added (~7 500 lines)

  • 7 MDX blog posts in content/blog/
  • MDX utilities in src/lib/mdx/
    • Custom MDX components
    • Prism.js syntax‑highlighting theme
  • Cover images in public/images/blog/

Net result: ~4 700 fewer lines of code – less code, fewer bugs, simpler maintenance.

The Benefits I’m Enjoying Now

  1. Write Anywhere – My favorite markdown editor, VS Code, Obsidian, or even neovim in a pinch. No browser required.

  2. Git‑Native Content – Every post is version‑controlled. I can see the full history, create branches for drafts, and review content changes in PRs alongside code.

  3. Blazing Fast Builds – No API calls during build. Everything is local filesystem reads, so the build is noticeably faster.

  4. True Ownership – Content lives in my repo. No vendor lock‑in, no surprise pricing changes, no external dependencies.

  5. Better Code Blocks – Prism.js with the Dracula theme, automatic language detection, and keyboard‑accessible code regions. It just works:

    // Look ma, beautiful syntax highlighting!
    const sum = (a: number, b: number): number => a + b;
  6. React Components in Markdown – Need a custom callout? An interactive demo? Just import and use it:

    import { InteractiveDemo } from '@/components/Demo';
    
    {/* Here’s a live demo: */}
    <InteractiveDemo />

Gotchas and Solutions

1. OpenGraph Images Need Node.js Runtime

The OG image generator uses fs to read MDX files, but Next.js image routes default to the Edge runtime. Fix it by forcing the Node.js runtime:

// src/app/blog/[slug]/opengraph-image.tsx
export const runtime = 'nodejs';

2. Reading‑Time Calculation

With Sanity I could query a computed field. With MDX I calculate it from the raw content:

export function calculateReadingTime(content: string) {
  const text = content
    .replace(/```[\s\S]*?```/g, '') // Remove code blocks
    .replace(/`[^`]*`/g, '')        // Remove inline code
    .replace(/]*>/g, '');           // Remove HTML

  const words = text.split(/\s+/).filter(Boolean).length;
  const minutes = Math.ceil(words / 200);

  return { text: `${minutes} min read`, minutes, words };
}

3. Table of Contents

Without a structured AST from Sanity, I extract headings with a simple regex:

export function extractHeadings(content: string) {
  const headingRegex = /^(#{1,4})\s+(.+)$/gm;
  const headings: { id: string; text: string; level: number }[] = [];

  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 feature‑richness
  • Are comfortable with markdown

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

Conclusion

Moving from Sanity to MDX was like cleaning out a cluttered closet. The immediate benefit is obvious: less stuff, more space, easier to find things. The real joy comes from the daily experience of just… writing.

No dashboards. No loading spinners. No “syncing content.” Just me, my editor, and markdown—the way blogging should be.

The code for this entire blog system is open source at
github.com/gupta-akshay/portfolio-v2. Feel free to steal it. 🚀

“Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.” – Antoine de Saint‑Exupéry

Back to Blog

Related posts

Read more »

Please Don’t Use HTMX

HTMX is actually very good. This post is about why I don’t use it yet, even though people keep telling me to. What People Say About HTMX When people talk about...

Web development is fun again

Article URL: https://ma.ttias.be/web-development-is-fun-again/ Comments URL: https://news.ycombinator.com/item?id=46488576 Points: 53 Comments: 41...