Why I Built My Blog on Next.js + MDX (and Exactly How)
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
(orSITE_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
- Create
content/blog/your-post-slug.mdx
and a folderpublic/images/blog/your-post-slug/
. - Add top‑level metadata (no YAML; export an object).
- Write in MDX and sprinkle components when you need them.
- Use stable, per‑post image URLs.
- 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" />

<!-- 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
andalt
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
- Draft with
draft: true
. - Publish with
draft: false
and the desired date. - For meaningful updates, bump
updated
. - Keep tags consistent with
content/tags.json
(target 3–6, max 8). - 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
anddate
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 andNEXT_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 accessiblealt
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
. Includewidth
/height
in MDX somdx-components.tsx
upgrades tonext/image
. Addsizes
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.