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.
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
.mdxfiles 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-remotecompiles MDX on the servergray-matterparses YAML frontmatter from your filesreading-timecalculates 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.