Back to Blog
Next.jsMDXTutorial

Add a Blog to Your Next.js Site in 30 Minutes with MDX

No CMS, no database, no monthly bill. Just MDX files in your repo with full SEO. Here's exactly how to set it up.

Anas Nadeem
6 min read

I used to run my blog on Sanity CMS. It worked fine. But every time I wanted to write a post, I had to open the Sanity Studio, wait for it to load, use their block editor, and hope the preview matched what I'd actually see on the site.

Then I realized something. I'm a developer. I live in my code editor. Why am I using a GUI to write text?

I ripped out Sanity and replaced it with MDX files in my repo. The entire migration took an afternoon. My blog now loads faster (fully static, no API calls), costs nothing (no CMS subscription), and I write posts in the same editor I write code in.

Here's how to do the same thing.

What You'll Build

By the end of this post, you'll have:

  • Blog posts as .mdx files with frontmatter metadata
  • A blog listing page at /blog
  • Individual post pages at /blog/[slug]
  • Full SEO: JSON-LD structured data, OpenGraph tags, Twitter cards
  • XML sitemap with all blog posts
  • RSS feed
  • Reading time estimates
  • Zero external dependencies for content management

Step 1: Install Dependencies

You need three packages:

npm install next-mdx-remote gray-matter reading-time
  • next-mdx-remote compiles MDX on the server
  • gray-matter parses YAML frontmatter from your files
  • reading-time calculates read time from word count

Step 2: Create Your Content Directory

Create a content/blog/ directory at the root of your project. This is where all your MDX files will live.

mkdir -p content/blog

Each blog post is a single .mdx file:

content/
  blog/
    my-first-post.mdx
    building-a-saas.mdx
    nextjs-performance-tips.mdx

Step 3: Define Your Types

Create src/lib/types.ts:

export interface PostMeta {
  slug: string;
  title: string;
  excerpt: string;
  coverImage: string;
  coverImageAlt: string;
  author: string;
  categories: string[];
  publishedAt: string;
  metaTitle?: string;
  metaDescription?: string;
  readingTime: string;
}

export interface Post extends PostMeta {
  content: string;
}

PostMeta is for listing pages (no body content needed). Post includes the raw MDX content for rendering.

Step 4: Build the Blog Utility

Create src/lib/blog.ts. This is the core of your blog. It reads MDX files from disk and returns typed data:

import fs from "fs";
import path from "path";
import matter from "gray-matter";
import readingTime from "reading-time";
import type { Post, PostMeta } from "./types";

const CONTENT_DIR = path.join(process.cwd(), "content/blog");

function parseFrontmatter(slug: string): PostMeta & { content: string } {
  const filePath = path.join(CONTENT_DIR, `${slug}.mdx`);
  const raw = fs.readFileSync(filePath, "utf-8");
  const { data, content } = matter(raw);
  const stats = readingTime(content);

  return {
    slug,
    title: data.title,
    excerpt: data.excerpt || "",
    coverImage: data.coverImage || "",
    coverImageAlt: data.coverImageAlt || data.title,
    author: data.author || "Your Name",
    categories: data.categories || [],
    publishedAt: data.publishedAt,
    metaTitle: data.metaTitle,
    metaDescription: data.metaDescription,
    readingTime: stats.text,
    content,
  };
}

export function getAllSlugs(): string[] {
  if (!fs.existsSync(CONTENT_DIR)) return [];
  return fs
    .readdirSync(CONTENT_DIR)
    .filter((file) => file.endsWith(".mdx"))
    .map((file) => file.replace(/\.mdx$/, ""));
}

export function getAllPosts(): PostMeta[] {
  return getAllSlugs()
    .map((slug) => {
      const { content: _, ...meta } = parseFrontmatter(slug);
      return meta;
    })
    .sort(
      (a, b) =>
        new Date(b.publishedAt).getTime() -
        new Date(a.publishedAt).getTime(),
    );
}

export function getPostBySlug(slug: string): Post | null {
  const slugs = getAllSlugs();
  if (!slugs.includes(slug)) return null;
  const { content, ...meta } = parseFrontmatter(slug);
  return { ...meta, content };
}

That's it. No database queries. No API calls. Just fs.readFileSync.

Step 5: Create the Blog Post Page

Create src/app/blog/[slug]/page.tsx:

import { MDXRemote } from "next-mdx-remote/rsc";
import { notFound } from "next/navigation";
import { getAllSlugs, getPostBySlug } from "@/lib/blog";

export function generateStaticParams() {
  return getAllSlugs().map((slug) => ({ slug }));
}

export async function generateMetadata({ params }) {
  const { slug } = await params;
  const post = getPostBySlug(slug);
  if (!post) return { title: "Not Found" };

  return {
    title: post.metaTitle || post.title,
    description: post.metaDescription || post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.publishedAt,
    },
  };
}

export default async function BlogPostPage({ params }) {
  const { slug } = await params;
  const post = getPostBySlug(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.publishedAt}</time>
      <span>{post.readingTime}</span>
      <div className="prose dark:prose-invert">
        <MDXRemote source={post.content} />
      </div>
    </article>
  );
}

generateStaticParams pre-renders every post at build time. Fully static. No server-side rendering on each request.

Step 6: Add SEO

JSON-LD Structured Data

Add a BlogPosting schema to every post page:

function BlogPostJsonLd({ post }) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    description: post.excerpt,
    datePublished: post.publishedAt,
    author: { "@type": "Person", name: post.author },
    publisher: { "@type": "Person", name: post.author },
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
    />
  );
}

XML Sitemap

Update your src/app/sitemap.ts to include blog posts:

import { getAllPosts } from "@/lib/blog";

export default function sitemap() {
  const posts = getAllPosts();

  const blogPages = posts.map((post) => ({
    url: `https://yoursite.com/blog/${post.slug}`,
    lastModified: new Date(post.publishedAt),
    changeFrequency: "weekly",
    priority: 0.8,
  }));

  return [
    { url: "https://yoursite.com", priority: 1 },
    { url: "https://yoursite.com/blog", priority: 0.9 },
    ...blogPages,
  ];
}

RSS Feed

Create src/app/feed.xml/route.ts:

import { getAllPosts } from "@/lib/blog";

export function GET() {
  const posts = getAllPosts();
  const items = posts
    .map((p) => `<item>
      <title><![CDATA[${p.title}]]></title>
      <link>https://yoursite.com/blog/${p.slug}</link>
      <pubDate>${new Date(p.publishedAt).toUTCString()}</pubDate>
      <description><![CDATA[${p.excerpt}]]></description>
    </item>`)
    .join("");

  return new Response(
    `<?xml version="1.0" encoding="UTF-8"?>
    <rss version="2.0">
      <channel>
        <title>Your Blog</title>
        <link>https://yoursite.com/blog</link>
        ${items}
      </channel>
    </rss>`,
    { headers: { "Content-Type": "application/xml" } },
  );
}

Step 7: Write Your First Post

Create content/blog/hello-world.mdx:

---
title: "Hello World"
excerpt: "My first blog post."
author: "Your Name"
categories: ["General"]
publishedAt: "2025-01-01"
---

This is my first post. It supports **bold**, *italic*, `code`,
and everything else Markdown supports.

## You can use headings

And code blocks:

\`\`\`javascript
console.log("Hello from MDX!");
\`\`\`

And since it's MDX, you can even import and use React components
directly in your posts.

Run npm run build and your blog is live.

Why This Beats a CMS

  • Zero cost. No Sanity, Contentful, or Strapi subscription.
  • Zero latency. Pages are static HTML. No API calls at runtime.
  • Version controlled. Every post is a git commit. You get history, diffs, and rollback for free.
  • Write in your editor. VS Code, Cursor, Neovim, whatever you already use.
  • Full control. No block editor limitations. Write raw MDX with React components.

The tradeoff is that non-technical team members can't edit posts through a GUI. If that's a requirement, use a CMS. If it's just you writing, MDX is better in every way.


I'm Anas. I used this exact setup for my blog. If this saved you time, follow me for more on building with Next.js.