Back to Blog

From Static Hell to MDX Heaven

How I transformed my static blog nightmare into a dynamic, maintainable system that scales beautifully.

July 5, 2025
10 min read

When I first started building my personal blog with Next.js, I made the classic mistake every developer makes: I hard-coded everything. Every new blog post meant diving into components, manually adding entries to arrays, and praying I didn't break anything in the process.

After the third time I had to hunt down scattered blog post data across multiple files just to fix a typo, I knew something had to change. Here's how I transformed my static blog nightmare into a dynamic, maintainable system that actually brings joy back to writing.

(Thanks to Josh Comeau's blog for the inspiration and some of the code snippets!)

The Problem: Static Blog Data Hell

My original setup looked something like this:

TSX
// The old way - DON'T do this
const blogPosts = [
  {
    slug: "post-1",
    title: "My First Post",
    excerpt: "This is...",
    // ... more hardcoded data
  },
  {
    slug: "post-2",
    title: "Another Post",
    excerpt: "Yet another...",
    // ... even more hardcoded data
  },
];

Every new post meant:

  • ✅ Write the content
  • ✅ Update the posts array
  • ✅ Create the route file
  • ✅ Add tags manually
  • ✅ Remember to update the date
  • ✅ Hope I didn't break anything

Sound familiar? Let's fix this mess.

The Solution: File-System Based Blog Management

Instead of managing blog data in code, I moved everything to the filesystem using MDX files with frontmatter. Here's what the new system looks like:

Core Architecture

The heart of the system is a simple but powerful blog utility library:

TypeScript
// lib/blog.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";

export interface BlogPost {
  slug: string;
  title: string;
  excerpt: string;
  date: string;
  readTime: string;
  tags: string[];
  content?: string;
}

const postsDirectory = path.join(process.cwd(), "app/blog/posts");

export function getAllBlogPosts(): BlogPost[] {
  const entries = fs.readdirSync(postsDirectory, { withFileTypes: true });
  const posts: BlogPost[] = [];

  for (const entry of entries) {
    if (entry.isDirectory()) {
      // Handle directory structure (slug/page.mdx)
      const slug = entry.name;
      const postPath = path.join(postsDirectory, slug, "page.mdx");

      if (fs.existsSync(postPath)) {
        const post = getPostBySlug(slug);
        if (post) posts.push(post);
      }
    } else if (entry.isFile() && entry.name.endsWith(".mdx")) {
      // Handle direct MDX files
      const slug = entry.name.replace(/\.mdx$/, "");
      const post = getPostBySlug(slug);
      if (post) posts.push(post);
    }
  }

  // Sort posts by date (newest first)
  return posts.sort(
    (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
  );
}

This approach gives us incredible flexibility. Blog posts can be structured as either:

  • posts/my-post.mdx (simple posts)
  • posts/my-post/page.mdx (complex posts with assets)

Frontmatter: The Magic Sauce

Each blog post starts with YAML frontmatter that contains all the metadata:

Markdown
---
title: "Building a Dynamic Blog Management System in Next.js"
excerpt: "Transform your static blog nightmare into a maintainable, dynamic system."
date: "2024-12-20"
readTime: "8 min read"
tags: ["Next.js", "Blog", "MDX", "Web Development"]
---

# Your actual blog content starts here

Write your amazing content using MDX...

The gray-matter library parses this frontmatter, giving us structured data while keeping content separate from code.

Smart Error Handling

Real-world filesystems can be unpredictable. My system includes fallback mechanisms:

TypeScript
export function getAllBlogPosts(): BlogPost[] {
  try {
    // ... main logic
  } catch (error) {
    console.warn("Error reading blog posts:", error);
    return getFallbackPosts(); // Always return something useful
  }
}

This ensures your blog never completely breaks, even if there are filesystem issues.

Server-Side Data Fetching

The blog listing page leverages Next.js Server Components for optimal performance:

TSX
// app/blog/page.tsx
import { getAllBlogPosts, getAllTags } from "@/lib/blog";
import BlogPageClient from "./BlogPageClient";

export default async function BlogPage() {
  const blogPosts = getAllBlogPosts();
  const allTags = getAllTags();

  return <BlogPageClient blogPosts={blogPosts} allTags={allTags} />;
}

Data is fetched on the server, then passed to the client component for interactive features like filtering and animations.

Automated Post Creation

Writing new posts shouldn't feel like a chore. I created a CLI script that handles all the boilerplate:

JavaScript
// scripts/new-post.js
async function createBlogPost() {
  const title = await question("Enter the blog post title: ");
  const excerpt = await question("Enter a brief excerpt: ");
  const readTime = await question("Enter read time: ");
  const tagsInput = await question("Enter tags (comma-separated): ");

  const slug = slugify(title);
  const date = getCurrentDate();
  const tags = tagsInput.split(",").map((tag) => tag.trim());

  const frontmatter = `---
title: "${title}"
excerpt: "${excerpt}"
date: "${date}"
readTime: "${readTime}"
tags: [${tags.map((tag) => `"${tag}"`).join(", ")}]
---

# ${title}

Write your blog post content here...
`;

  // Create directory and write file
  const postDir = path.join("app", "blog", "posts", slug);
  fs.mkdirSync(postDir, { recursive: true });
  fs.writeFileSync(path.join(postDir, "page.mdx"), frontmatter);
}

Now creating a new post is as simple as:

Bash
npm run new-post

The script prompts for all necessary information and generates the file structure automatically.

Dynamic Tag Management

Tags are extracted dynamically from all posts, eliminating the need to manually maintain tag lists:

TypeScript
export function getAllTags(): string[] {
  const posts = getAllBlogPosts();
  const tagSet = new Set<string>();

  posts.forEach((post) => {
    post.tags.forEach((tag) => tagSet.add(tag));
  });

  return Array.from(tagSet).sort();
}

This ensures your tag filter always reflects what's actually in your content.

The Client-Side Experience

The client component handles interactive features like filtering with smooth animations:

TSX
// Filter posts based on selected tag
const filteredPosts =
  filter === "All"
    ? blogPosts
    : blogPosts.filter((post) => post.tags.includes(filter));

// Smooth animations when filter changes
useEffect(() => {
  if (!isInitialLoad) {
    const postItems = Array.from(postsRef.current?.children || []);

    gsap.set(postItems, { opacity: 0, y: 20 });
    gsap.to(postItems, {
      opacity: 1,
      y: 0,
      duration: 0.4,
      ease: "power2.out",
      stagger: 0.05,
    });
  }
}, [filteredPosts]);

The File Structure That Works

Here's what my posts directory looks like now:

Text
app/blog/posts/
├── getting-started-with-nextjs/
│   ├── page.mdx
│   └── images/
│       └── demo.png
├── mastering-tailwind-css.mdx
├── building-blog-system/
│   ├── page.mdx
│   └── components/
│       └── CodeDemo.tsx
└── quick-tip-post.mdx

Simple posts get simple files. Complex posts with assets get their own directories.

Package Dependencies

The magic happens with just a few key dependencies:

JSON
{
  "dependencies": {
    "@mdx-js/loader": "^3.1.0",
    "@mdx-js/react": "^3.1.0",
    "@next/mdx": "^15.3.5",
    "gray-matter": "^4.0.3"
  }
}

gray-matter handles frontmatter parsing, while the MDX packages enable React component usage within markdown.

Lessons Learned

Start Simple, Scale Smart

I began with basic MDX files and added complexity only when needed. The system grew organically with my requirements. I'm planning to add more features, but I won't rush into them.

Embrace the Filesystem

Instead of fighting against file-based routing, I embraced it. The filesystem becomes your CMS. However, there are still some limitations, such as writing blog posts requires a specific structure, and you can't just write a post anywhere. I'll need to automate this in the future.

Next Steps

This system has room to grow:

  • Draft Support: Add a published frontmatter field
  • Category Hierarchy: Implement nested categorization
  • Search Functionality: Add full-text search across posts
  • RSS Generation: Auto-generate RSS feeds from the post data
  • Image Optimization: Integrate Next.js Image optimization for post assets

and so much more. (Do your job, Daniel!)

Conclusion

The things is, I'm not trying to build the next big blogging platform. I just wanted a simple, maintainable way to write and manage my blog posts.

The key insight? Your content management system doesn't need to be complex to be powerful. By leveraging the filesystem, frontmatter, and a few smart utilities, you can build something that scales beautifully while remaining simple to understand and maintain.


Found this helpful? Share it with other developers who are having the same struggles.

Back to Blog
Loading