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.
You’ll like this if you:
You’ll have by the end:
.mdx file.lib/ with type‑safe post metadata, validation, and reading time./feed, sitemap and robots, and clean canonicals.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.
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.
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.
content/blog/<slug>.mdx.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.app/blog/[slug]/page.tsx) renders ToC, author box, related posts, prev/next nav, and share links.app/blog/[slug]/opengraph-image.tsx./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.
.
├─ 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
// next.config.mjs
import createMDX from '@next/mdx';
const nextConfig = {
reactStrictMode: true,
pageExtensions: ['js','jsx','mdx','ts','tsx'],
};
export default createMDX({})(nextConfig);
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,
};
}
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));
}
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>
);
}
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,
};
}
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.content/blog/your-post-slug.mdx and a folder public/images/blog/your-post-slug/.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" />
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.
coverAlt and alt on inline images./blog/tags) and search (/blog/search) as cross‑links when helpful.content/tags.jsonTags 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).
// 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()] : []
}} />
Keep it simple: OG images must be legible at thumbnail sizes. (See the opengraph-image.tsx snippet above.)
draft: true.draft: false and the desired date.updated.content/tags.json (target 3–6, max 8)./feed and the OG preview when shipping.feat/blog/my-post-slug.blog: draft intro and architecture, blog: add images and SEO pass.draft: false and date isn’t in the future.content/tags.json or remove it./images/... paths under /public.##/### headings./blog/[slug]/opengraph-image.tsx route and NEXT_PUBLIC_SITE_URL.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.
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');
~~~
date / updated set; draft disabled when publishingcontent/tags.json/public/images/blog/<slug>/ with accessible alt text/feed.webp. Include width/height in MDX so mdx-components.tsx upgrades to next/image. Add sizes for responsive pages.rehype-pretty-code) to avoid heavy client JS.lib/blog.ts will warn/fail if out of bounds.content/tags.json. Limit to 3–6 per post (max 8).getAllPosts() and load it on the client (powering /blog/search).app/blog/page/[n]/page.tsx (10–20 posts per page)./public for stable caching; use content hashes if you frequently update images.pandoc), then massage headings, code blocks, and images.// 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 },
];
},
}
);
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.
I built this to keep writing close to the code, without ceremony. If that resonates, fork the approach—no CMS required.