Why I Built My Blog on Next.js + MDX (and Exactly How)

Rohit Ramachandran avatarRohit Ramachandran
Sep 16, 2025Updated Sep 16, 2025
Cover banner for the MDX + Next.js blog guide

Why I Built My Blog on Next.js + MDX (and Exactly How)

What to expect
This is a start‑to‑finish, copy‑and‑pasteable guide. I’ll explain the motivation, show the minimal architecture, and give you the exact setup (components, validation, SEO, feeds, OG images), plus a predictable authoring workflow. If you just want the checklist and templates, jump to Final Checklist and Minimal Template near the end.

Who This Is For (and What You’ll Build)

You’ll like this if you:

  • Prefer files over dashboards.
  • Want React components inside posts without bolting on a runtime.
  • Care about performance, accessibility, and long‑term durability.

You’ll have by the end:

  • A file‑based blog where each post is an .mdx file.
  • A small lib/ with type‑safe post metadata, validation, and reading time.
  • App Router pages for post rendering, a ToC, related posts, and share links.
  • First‑class SEO: Open Graph image per post, RSS at /feed, sitemap and robots, and clean canonicals.

The Story: No CMS, No Fuss—Just MDX Where I Write Code

I didn’t want yet another dashboard in my life. No headless CMS, no vendor lock‑in, no webhooks waking up a tiny service just to publish a paragraph.

I wanted writing to feel like coding: open the editor, type, commit. Next.js 15 finally made MDX a first‑class citizen, and everything clicked. I could keep content in the repo, use React components when I needed them, and still ship a fast, SEO‑friendly site with a minimal stack.

This post is both the story of those choices and the exact blueprint I used. If you want the same: copy it.


Constraints I Started With

  • Keep content in Git. I want reviews, diffs, and history alongside code.
  • Avoid external services. No dashboards, no API keys to rotate.
  • Make publishing boring. A commit should be enough; the build should validate.
  • Write once, share everywhere. OG images, RSS, sitemap, canonical—no plugin juggling.

Underneath those constraints was a bigger goal: reduce the cognitive cost of posting. If the system is fast and predictable, I’ll write more often.

The Shortlist I Considered

  • Static site generator + MD: simple, but I lose React components or bolt on a runtime.
  • Headless CMS: nice UIs, extra moving parts. Not for this project.
  • Contentlayer: powerful, more tooling than I needed.
  • Next.js + MDX: native support, zero extra services, components where I want them. This won.

Durability matters too: a folder of .mdx files will outlive most trends. If I ever migrate frameworks, content and search‑friendly slugs migrate with me.


Architecture at a Glance

  • Posts live at content/blog/<slug>.mdx.
  • Images live at public/images/blog/<slug>/* with absolute paths like /images/blog/<slug>/cover.webp.
  • @next/mdx wires MDX into the App Router so .mdx files are pages or content modules.
  • mdx-components.tsx sets typography defaults, smart links, and code styles.
  • lib/blog.ts validates metadata, computes reading time, and sorts posts.
  • The post page (app/blog/[slug]/page.tsx) renders ToC, author box, related posts, prev/next nav, and share links.
  • OG images are generated per post at app/blog/[slug]/opengraph-image.tsx.
  • RSS lives at /feed; sitemap and robots are configured in the app dir.

In practice, my “CMS” is just the repo. Posts are versioned, reviewed, and tested by the same CI that tests code.

Folder Structure

.
├─ app/
│  ├─ blog/
│  │  ├─ [slug]/
│  │  │  ├─ page.tsx
│  │  │  └─ opengraph-image.tsx
│  ├─ feed/route.ts
│  ├─ sitemap.ts
│  └─ robots.ts
├─ content/
│  └─ blog/
│     └─ your-post-slug.mdx
├─ lib/
│  ├─ blog.ts
│  └─ site.ts
├─ public/
│  └─ images/
│     └─ blog/
│        └─ your-post-slug/
│           ├─ cover.webp
│           └─ screenshot.webp
├─ mdx-components.tsx
└─ next.config.mjs

Set It Up (One Time)

1) Enable MDX in Next.js

// next.config.mjs
import createMDX from '@next/mdx';

const nextConfig = {
  reactStrictMode: true,
  pageExtensions: ['js','jsx','mdx','ts','tsx'],
};

export default createMDX({})(nextConfig);

2) Define global MDX components

Keep content readable by default: smart links, image upgrades, and lightweight code styles.

// mdx-components.tsx
import Link from 'next/link';
import Image from 'next/image';
import type { MDXComponents } from 'mdx/types';

function SmartA(props: React.ComponentProps<'a'>) {
  const isExternal = props.href?.startsWith('http');
  if (isExternal) {
    return <a {...props} target="_blank" rel="noreferrer noopener" />;
  }
  return <Link href={props.href ?? '#'}>{props.children}</Link>;
}

function Img(props: React.ComponentProps<'img'>) {
  const { width, height, alt = '', src = '' } = props;
  if (width && height) {
    return <Image src={src} alt={alt} width={+width} height={+height} sizes="100vw" style={{ height: 'auto', width: '100%' }} />;
  }
  return <img {...props} loading="lazy" decoding="async" />;
}

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    a: SmartA,
    img: Img,
    code: (p) => <code {...p} />,
    pre: (p) => <pre {...p} />,
    ...components,
  };
}

3) Add lib/blog.ts (types, validation, reading time)

// lib/blog.ts
export type PostMeta = {
  title: string;
  description: string; // 40–200 chars
  date: string;        // ISO
  updated?: string;
  tags: string[];
  draft?: boolean;
  featured?: boolean;
  cover?: string;
  coverAlt?: string;
  canonical?: string;
  author?: string;
  authorUrl?: string;
  authorAvatar?: string;
  authorBio?: string;
};

const TAGS = ['nextjs','react','typescript','ai','llm','mdx','javascript','css'] as const;
type Tag = typeof TAGS[number];

export function validateMeta(meta: PostMeta): { ok: true } | { ok: false; errors: string[] } {
  const errors: string[] = [];
  if (!meta.title || meta.title.length < 3) errors.push('title is required');
  const dlen = meta.description?.length ?? 0;
  if (dlen < 40 || dlen > 200) errors.push('description must be 40–200 chars');
  if (!/^\d{4}-\d{2}-\d{2}/.test(meta.date)) errors.push('date must be ISO (YYYY-MM-DD)');
  if (meta.updated && !/^\d{4}-\d{2}-\d{2}/.test(meta.updated)) errors.push('updated must be ISO (YYYY-MM-DD)');
  const unknown = (meta.tags ?? []).filter(t => !TAGS.includes(t as Tag));
  if (unknown.length) errors.push(`unknown tags: ${unknown.join(', ')}`);
  if (meta.cover && !meta.coverAlt) errors.push('coverAlt is required when cover is provided');
  return errors.length ? { ok: false, errors } : { ok: true };
}

export function readingTime(text: string, wpm = 225): number {
  const words = (text.trim().match(/\S+/g) ?? []).length;
  return Math.max(1, Math.round(words / wpm));
}

4) Post page UI (ToC, author box, related, share)

Keep it simple; the ToC appears when there are ## / ### headings.

// app/blog/[slug]/page.tsx (excerpt – adapt to your data layer)
import { notFound } from 'next/navigation';
import { readingTime, validateMeta } from '@/lib/blog';

export default async function Page({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);           // your loader
  if (!post) return notFound();

  const meta = post.metadata;
  const val = validateMeta(meta);
  if (!val.ok) throw new Error(val.errors.join('\n'));

  const minutes = readingTime(post.plainText);
  return (
    <article>
      {/* Breadcrumbs */}
      <header>
        <h1>{meta.title}</h1>
        <p>{new Date(meta.date).toLocaleDateString()} · {minutes} min read</p>
        {meta.tags?.length ? <ul aria-label="tags">{meta.tags.map(t => <li key={t}>#{t}</li>)}</ul> : null}
        {meta.cover && <img src={meta.cover} alt={meta.coverAlt ?? ''} />}
      </header>

      {/* Auto-ToC rendered by your layout if desired */}

      <div dangerouslySetInnerHTML={{ __html: post.html }} />

      {/* Related posts & prev/next */}
      {/* Author box & share row */}
    </article>
  );
}

5) Distribution built‑in

Open Graph image per post, RSS at /feed, and discovery via sitemap/robots.

// app/blog/[slug]/opengraph-image.tsx (excerpt)
import { ImageResponse } from 'next/og';

export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default async function OG() {
  const { title, description } = await getCurrentPostMeta(); // implement
  return new ImageResponse(
    <div style={{ display: 'flex', width: '100%', height: '100%', padding: 64, background: '#0b1220', color: '#e5e7eb' }}>
      <div style={{ fontSize: 60, fontWeight: 800, lineHeight: 1.1, maxWidth: 960 }}>{title}</div>
      <div style={{ position: 'absolute', bottom: 64, left: 64, fontSize: 28, opacity: 0.8 }}>{description}</div>
      <div style={{ position: 'absolute', top: 32, right: 32, display: 'flex', gap: 12, alignItems: 'center' }}>
        <img src={process.env.NEXT_PUBLIC_OG_LOGO ?? 'https://rohitai.com/logo-owl.svg'} width={48} height={48} />
        <span style={{ fontSize: 22, color: '#94a3b8' }}>RohitAi.com</span>
      </div>
    </div>,
    size
  );
}
// app/feed/route.ts – minimal RSS 2.0
import { NextResponse } from 'next/server';

export async function GET() {
  const posts = await getAllPublishedPosts(); // { title, description, link, date }
  const items = posts.map(p => `
    <item>
      <title><![CDATA[${p.title}]]></title>
      <link>${p.link}</link>
      <guid>${p.link}</guid>
      <pubDate>${new Date(p.date).toUTCString()}</pubDate>
      <description><![CDATA[${p.description}]]></description>
    </item>`).join('');

  const xml = `<?xml version="1.0" encoding="UTF-8" ?>
  <rss version="2.0">
    <channel>
      <title>RohitAI – Blog</title>
      <link>${process.env.NEXT_PUBLIC_SITE_URL}</link>
      <description>Thoughtful, accessible engineering notes.</description>
      ${items}
    </channel>
  </rss>`;
  return new NextResponse(xml, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } });
}
// app/sitemap.ts
export default async function sitemap() {
  const base = process.env.NEXT_PUBLIC_SITE_URL!;
  const posts = await getAllPublishedPosts(); // { slug, updated|date }
  return [
    { url: `${base}/`, lastModified: new Date() },
    ...posts.map(p => ({ url: `${base}/blog/${p.slug}`, lastModified: new Date(p.updated ?? p.date) })),
  ];
}
// app/robots.ts
export default function robots() {
  const base = process.env.NEXT_PUBLIC_SITE_URL!;
  return {
    rules: [{ userAgent: '*', allow: '/' }],
    sitemap: `${base}/sitemap.xml`,
    host: base,
  };
}

6) Configure your site URL (for canonical, sitemap, feed)

  • Set NEXT_PUBLIC_SITE_URL (or SITE_URL) to your production origin (e.g., https://rohitai.com).
  • lib/site.ts reads this to build absolute URLs across metadata, feed, and OG.

Writing Now Feels Like This

  1. Create content/blog/your-post-slug.mdx and a folder public/images/blog/your-post-slug/.
  2. Add top‑level metadata (no YAML; export an object).
  3. Write in MDX and sprinkle components when you need them.
  4. Use stable, per‑post image URLs.
  5. Preview locally; flip draft: false when ready. The build validates before shipping.
// Minimal metadata block you can paste into any post
export const metadata = {
  title: 'My Post Title',
  description: 'A short, compelling summary (40–200 chars).',
  date: '2025-08-26',
  updated: '2025-08-26', // when you revise
  tags: ['nextjs','mdx'],
  draft: true,
  cover: '/images/blog/your-post-slug/cover.webp',
  coverAlt: 'Meaningful alt text',
  canonical: 'https://example.com/my-post-original', // set only when cross‑posting
  author: 'Rohit Ramachandran',
  authorUrl: 'https://rohitai.com',
  authorAvatar: '/images/rohit.jpg',
  authorBio: 'AI engineer and writer.',
}
import YouTube from '@/app/components/mdx/YouTube'

## A YouTube Demo

<YouTube id="dQw4w9WgXcQ" title="My demo video" />
![Key concept diagram showing the request flow](/images/blog/your-post-slug/screenshot.webp)

<!-- If you know width/height, include them to enable Next/Image optimization automatically: -->
<img src="/images/blog/your-post-slug/screenshot.webp" alt="Request flow diagram" width="1600" height="900" />

<!-- Or specify sizes for responsive behavior: -->
<img src="/images/blog/your-post-slug/screenshot.webp" alt="Request flow diagram" width="1600" height="900" sizes="(max-width: 840px) 100vw, 800px" />

SEO I Don’t Have to Think About (Much)

The metadata export drives the page’s <title>, description, canonical URL, OG/Twitter tags, and a generated OG image. Because images live under /public, both RSS and OG get stable URLs.

  • Titles: clear and honest; keep readable under ~65 characters.
  • Descriptions: 40–200 characters; lead with the value.
  • Canonical: set it if you cross‑post.
  • Alt text: always add coverAlt and alt on inline images.
  • Performance: embeds are light; images are optimized.

Internal Linking Strategy

  • Link related posts inside the introduction and relevant sections—this lifts discovery and time on site.
  • Prefer descriptive anchors: MDX components guide over “click here”.
  • Use tag pages (/blog/tags) and search (/blog/search) as cross‑links when helpful.

Manage Tags with content/tags.json

Tags are validated at build time. Keep the list curated and small.

// content/tags.json
[
  "nextjs",
  "react",
  "typescript",
  "ai",
  "llm",
  "mdx",
  "javascript",
  "css"
]

If you add a new tag, put it here first; posts referencing unknown tags will warn (dev) or fail (prod).

Optional: JSON‑LD for Articles

// app/components/JsonLd.tsx
export default function JsonLd({ data }: { data: any }) {
  return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />;
}

Use it in a post or layout:

<JsonLd data={{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": metadata.title,
  "description": metadata.description,
  "datePublished": metadata.date,
  "dateModified": metadata.updated ?? metadata.date,
  "author": { "@type": "Person", "name": metadata.author ?? "Rohit Ramachandran", "url": metadata.authorUrl },
  "image": metadata.cover ? [new URL(metadata.cover, process.env.NEXT_PUBLIC_SITE_URL!).toString()] : []
}} />

Customize the Open Graph Image

Keep it simple: OG images must be legible at thumbnail sizes. (See the opengraph-image.tsx snippet above.)


Tradeoffs I’m Happy With

  • No GUI editor. I prefer Git and an editor; collaborators can still PR content.
  • No database or CMS. Fewer moving parts; content ships with the build.
  • MDX is code. That’s power, but also code reviews for content—which I like.

The Maintenance Loop

  1. Draft with draft: true.
  2. Publish with draft: false and the desired date.
  3. For meaningful updates, bump updated.
  4. Keep tags consistent with content/tags.json (target 3–6, max 8).
  5. Glance at /feed and the OG preview when shipping.

Editorial Workflow (Optional but Helpful)

  • Branch per post: feat/blog/my-post-slug.
  • Commit messages: blog: draft intro and architecture, blog: add images and SEO pass.
  • PR template: goals, audience, key links, and checklist (title, description length, tags, images, internal links).
  • Merge only after a second pair of eyes checks tone, accuracy, and links.

Troubleshooting Quick Hits

  • Not showing in prod? Ensure draft: false and date isn’t in the future.
  • Tags error? Add the tag to content/tags.json or remove it.
  • Broken image? Use absolute /images/... paths under /public.
  • No ToC? Use ##/### headings.
  • OG image missing? Confirm the /blog/[slug]/opengraph-image.tsx route and NEXT_PUBLIC_SITE_URL.

FAQ

Why not a CMS?
For this project, a CMS adds complexity I don’t need: auth, webhooks, rate limits, and another UI. Git already solves drafts, reviews, and history.

Can this scale?
Yes. Content is just files; pages can be statically generated; the feed and sitemap are small. If I ever outgrow it, migrating files is straightforward.

What about collaboration?
PRs. Content changes get the same review flow as code—typos, tone, structure, and links.


Minimal Template (Copy/Paste)

export const metadata = {
  title: 'Your Title',
  description: 'One or two clear sentences (40–200 chars).',
  date: '2025-08-26',
  tags: ['nextjs','mdx'],
  draft: false,
}

# Your H1

Short introduction.

## Section

Explain the thing.

~~~ts
// code samples go here
console.log('hello');
~~~

Final Checklist

  • ✅ Strong, unique title and description (40–200 chars)
  • date / updated set; draft disabled when publishing
  • ✅ Tags valid (max 8) and present in content/tags.json
  • ✅ Images under /public/images/blog/<slug>/ with accessible alt text
  • ✅ OG preview looks good; RSS populated at /feed
  • ✅ Helpful internal links in intro and body
  • ✅ Skim for accessibility: headings, link text, contrast, image alts

Performance Notes (Advanced)

  • Images: Prefer .webp. Include width/height in MDX so mdx-components.tsx upgrades to next/image. Add sizes for responsive pages.
  • Code blocks: Keep lightweight styles. If you add syntax highlighting, prefer a build‑time solution (e.g., rehype-pretty-code) to avoid heavy client JS.
  • Client components in MDX: Use sparingly—small, focused components (like the YouTube embed) keep hydration cost low.

Accessibility Essentials

  • Headings: One H1 per post. Nest H2 → H3 to form a logical outline (helps screen readers and ToC generation).
  • Link text: Avoid “here.” Use meaningful labels, e.g., “Read the MDX setup guide.”
  • Alt text: Describe the information in the image, not the file name. Omit purely decorative images.
  • Contrast and font size: Favor readable defaults; avoid tiny inline code or low‑contrast callouts.

Governance for Consistency

  • Descriptions: Keep 40–200 chars. The validator in lib/blog.ts will warn/fail if out of bounds.
  • Tags: Use content/tags.json. Limit to 3–6 per post (max 8).
  • Reviews: Treat content like code—PRs for tone, clarity, and technical accuracy.

At Scale: 1,000 Posts Plan

  • Build time: App Router with static generation handles hundreds of posts. For thousands, consider partial generation and route groups.
  • Search: Generate a small JSON index (slug, title, desc, tags) from getAllPosts() and load it on the client (powering /blog/search).
  • Pagination: Implement app/blog/page/[n]/page.tsx (10–20 posts per page).
  • Caching: Keep images under /public for stable caching; use content hashes if you frequently update images.
  • Editing policy: Avoid changing slugs. If you must, add a redirect.

Migration Notes

  • From Medium/WordPress/Notion: Export content, convert to MD/MDX (e.g., with pandoc), then massage headings, code blocks, and images.
  • Map slugs: Keep old paths alive with redirects.
// next.config.mjs (with an example redirect)
import createMDX from '@next/mdx';
export default createMDX({})(
  {
    reactStrictMode: true,
    pageExtensions: ['js','jsx','mdx','ts','tsx'],
    async redirects() {
      return [
        { source: '/old-post', destination: '/blog/new-post', permanent: true },
      ];
    },
  }
);

Analytics and Privacy (Optional)

If you add analytics, prefer a privacy‑friendly script, load it after interaction, and document it in the privacy policy. Keep analytics out of MDX content—add it in the layout.

If Needed: Pipeline and Scaling Improvements

  • Add a build‑time syntax highlighter and image optimization pipeline (Sharp) for screenshots.
  • Add author profiles and per‑author pages if multiple contributors join.
  • Add scheduled publishing via CI (commit to a branch; auto‑merge on a date) if needed.

I built this to keep writing close to the code, without ceremony. If that resonates, fork the approach—no CMS required.