Building a Zero-Maintenance Blog with MDX and Next.js 15
How to build a git-powered blog using MDX, gray-matter, and Next.js App Router — no database required.

This is a technical article for developers. If you run a salon, restaurant or trade business, start here instead: what a website really costs and what your website actually needs.
Many small websites over-engineer their blog. You set up a CMS, configure webhooks, provision a database, and spend more time on infrastructure than writing. MDX with Next.js App Router eliminates all of that — your content lives in the repository, versioned alongside your code, with zero runtime dependencies.
Why MDX Over a Headless CMS
A headless CMS like Sanity or Contentful is the right choice when a non-technical team needs to publish content. For a developer's personal blog, it introduces unnecessary coupling:
- Cold-start latency — every page render hits an external API
- Availability dependency — your CMS going down takes your blog with it
- Context switching — writing in a browser editor instead of your IDE
MDX flips this. Content is a .mdx file. Deployment is a git push. There is no CMS to maintain.
Project Structure
content/
blog/
nextjs-15-architecture.mdx
trading-bot-design.mdx
lib/
mdx.ts ← parse frontmatter + content
app/[locale]/blog/
page.tsx ← listing
[slug]/page.tsx ← detail
Parsing MDX Files with gray-matter
The core of the system is a single utility that reads .mdx files from disk and maps them to your data model:
import fs from 'fs'; import path from 'path'; import matter from 'gray-matter'; const BLOG_DIR = path.join(process.cwd(), 'content/blog'); export async function getMdxPosts() { const files = fs.readdirSync(BLOG_DIR).filter((f) => f.endsWith('.mdx')); return files .map((file) => { const raw = fs.readFileSync(path.join(BLOG_DIR, file), 'utf8'); const { data, content } = matter(raw); const slug = file.replace(/\.mdx$/, ''); return { slug: data.slug ?? slug, title: data.title ?? 'Untitled', excerpt: data.excerpt ?? '', content, coverImage: data.coverImage ?? null, date: new Date(data.date ?? Date.now()), author: data.author ?? 'Admin', tags: data.tags ?? [], published: data.published ?? true, }; }) .filter((p) => p.published) .sort((a, b) => b.date.getTime() - a.date.getTime()); }ts
gray-matter splits the YAML frontmatter from the body. The rest is plain TypeScript — no special MDX runtime needed at this stage.
Rendering MDX in the App Router
For the detail page, Next.js recommends @next/mdx or the lighter next-mdx-remote. Because we're loading files at request time (not statically importing), next-mdx-remote fits better:
// app/[locale]/blog/[slug]/page.tsx import { MDXRemote } from 'next-mdx-remote/rsc'; import { getMdxPostBySlug } from '@/lib/mdx'; import { notFound } from 'next/navigation'; export default async function BlogPostPage({ params, }: { params: Promise<{ slug: string; locale: string }>; }) { const { slug } = await params; const post = await getMdxPostBySlug(slug); if (!post) notFound(); return ( <article className="prose prose-invert max-w-3xl mx-auto py-16 px-6"> <h1>{post.title}</h1> <MDXRemote source={post.content} /> </article> ); }ts
MDXRemote from the /rsc export is a React Server Component — it compiles and renders MDX on the server with zero client JavaScript.
Frontmatter Schema
A consistent frontmatter structure prevents edge cases in the listing page:
--- title: 'Post Title' description: 'One-sentence summary used for SEO meta description.' date: '2026-05-01' author: 'Vladyslav Kobiakov' tags: ['nextjs', 'performance'] coverImage: '/projects/blog/mdx-blog.webp' published: true ---yaml
Keep published: false on drafts — the getMdxPosts filter excludes them without touching routing.
Static Generation with generateStaticParams
For maximum performance, pre-render all posts at build time:
export async function generateStaticParams() { const posts = await getMdxPosts(); return posts.map((post) => ({ slug: post.slug })); } export const revalidate = false; // fully staticts
Combined with next/image for cover images and a CDN in front of Vercel, Time to First Byte drops to single-digit milliseconds.
Adding Custom MDX Components
One of MDX's biggest advantages is embedding React components in prose. Map component names to implementations in one place:
const components = { CodeBlock: ({ code, lang }: { code: string; lang: string }) => ( <SyntaxHighlighter language={lang}>{code}</SyntaxHighlighter> ), Callout: ({ children }: { children: React.ReactNode }) => ( <div className="border-l-4 border-primary pl-4 my-6 text-muted-foreground"> {children} </div> ), }; // Pass to MDXRemote <MDXRemote source={post.content} components={components} />ts
Now any .mdx file can use <Callout> or <CodeBlock> inline.
Trade-offs
What you gain:
- No database, no CMS API, no webhooks
- Content versioned in git — drafts are branches, reviews are PRs
- Full IDE tooling: autocomplete, spell-check, refactoring
What you give up:
- No visual editor for non-technical collaborators
- No built-in media management (use Cloudinary or
public/) - Redeployment required to publish (acceptable for solo-maintained sites)
Conclusion
For a site maintained by a single developer, an MDX-based blog is the right level of complexity. The setup fits in two files, scales to hundreds of posts without a database, and keeps the authoring experience inside the tools you already use. The only maintenance cost is an occasional npm update for gray-matter and next-mdx-remote.
Working on something similar?
I build fast marketing sites and booking systems for small businesses across Europe — freelance, remote. I reply within 24 hours.
Get in touch