Skip to content
kobiakov.dev
Back to blog

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.

Vladyslav Kobiakov2 min read
Building a Zero-Maintenance Blog with MDX and Next.js 15

Modern portfolio sites often 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 static
ts

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 portfolios)

Conclusion

For a developer portfolio, 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.

Share: