Using GitHub as a Headless CMS for Blogs with Next.js – and Publishing to dev.to + RSS!
ullas kunder

ullas kunder @ullas0bito

About: Developer in transition, learning to build better with Go and beyond. Driven by curiosity, clean design, and code that makes an impact. Always building, always evolving.

Location:
Mumbai, India
Joined:
Apr 6, 2025

Using GitHub as a Headless CMS for Blogs with Next.js – and Publishing to dev.to + RSS!

Publish Date: May 8
4 1

Using GitHub as a Headless CMS for Blogs with Next.js – and Publishing to dev.to + RSS!

📝 GitHub → 🔁 Next.js API → 🌍 Blog + 🔗 RSS → ✨ dev.to

TL;DR: We're managing blog posts with MDX files inside a GitHub repo (private for safety), pulling them into our Next.js app as a read-only CMS , rendering them dynamically with Markdown, generating an RSS feed, and publishing to platforms like dev.to. Clean, modern, no bloated CMS, no rebuilds on blog edits. 💡


🧐 Why GitHub as a Blog CMS?

Most devs use tools like Notion, Sanity, WordPress (ew 😬), or even headless CMSes like Contentful. But what if I told you...

“You already have a version-controlled, Markdown-friendly, Git-powered CMS sitting in your toolbelt.”

That’s right: GitHub.

We manage all blog content as .mdx files in a dedicated repo , separate from our actual portfolio or blog frontend. Why?

🔥 The Pain Point

If you include blog .mdx content inside your Next.js app repo , every time you tweak a typo, you have to:

  • Push code
  • Rebuild the whole app
  • Redeploy

That’s... not ideal. Especially when the blog isn’t even changing the codebase.

💡 The Solution

So we decoupled it. Blog content lives in a separate GitHub repo (like blog-api) and our main blog frontend fetches blog files from it using the GitHub API.

Private repo? Absolutely.

Because if it’s public, some clever clowns 🤡 might try to PR garbage or scrape it aggressively. Not today, Internet.


🗂️ Blog Content Structure

Our blog-api repo looks like this:

C:\dev\blog-api
|
|-- advance-javascript.mdx
|-- basic-of-react-hooks.mdx
|-- basic-of-react17.mdx

Enter fullscreen mode Exit fullscreen mode

Each file contains frontmatter metadata at the top:

---
title: "Advance JavaScript"
subtitle: "Exploring closures, scopes and the weird parts"
id: 001
date: "Aug 3, 2023"
tag: ["js", "closures", "advanced"]
---

Your content goes here in MDX...

Enter fullscreen mode Exit fullscreen mode

That’s it. Simple, readable, version-controlled.

🔌 Wiring It into Next.js

C:.
│ favicon.ico
│ head.tsx
│ Home.tsx
│ layout.tsx
│ loading.tsx
│ manifest.ts
│ not-found.tsx
│ page.tsx
│ robots.ts
│ sitemap.ts
│
├───api
│ ├───blog
│ │ └───v2
│ │ │ route.ts // GET all blog posts
│ │ └───[slug]
| └───route.ts // GET blog by slug
│
├───blogs
│ │ BlogClient.tsx
│ │ page.tsx // Blogs list page ssr
│ └───[slug]
│ └───page.tsx // Dynamic blog post page
│
├───rss
│ └───route.ts // RSS feed route
│
└───lib
    ├───assets.ts
    ├───dateFormater.ts
    ├───fetchBlogFromGithub.ts
    // Fetch all MDX posts via GitHub API
    ├───fetchPostFromAPI.ts
    // Fetch single post from GitHub raw
    ├───getBaseUrl.ts
    ├───getProjectsData.ts
    ├───mailApi.ts
    ├───markdownToHtml.ts
    ├───nodemailer.ts
    ├───utils.ts
    └───getPostMetaData.ts
    // Pull metadata from all blog posts

Enter fullscreen mode Exit fullscreen mode

Now let’s break down how we make this dynamic in Next.js (App Router), without rebuilding every time.


🧠 Step 1: API route to fetch all blog posts

Inside /app/api/blog/v2/route.ts, we add:

export const dynamic = "force-dynamic";

export async function GET() {
  const posts = await fetchBlogPostsFromGitHub();
  return NextResponse.json(posts);
}

Enter fullscreen mode Exit fullscreen mode

Why dynamic? Because we want on-demand rendering with periodic revalidation, not static build-time fetches.


🪝 Step 2: fetchBlogPostsFromGitHub() from our GitHub repo

In /lib/fetchBlogFromGithub.ts:

const res = await fetch(
  `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/contents?ref=${BRANCH}`,
  { headers: { Authorization: `Bearer ${GITHUB_TOKEN}` } }
);

const files = await res.json();
const mdxFiles = files.filter((file) => file.name.endsWith(".mdx"));

Enter fullscreen mode Exit fullscreen mode

We loop through each file and use gray-matter to parse the frontmatter:

const { data, content } = matter(rawContent);

Enter fullscreen mode Exit fullscreen mode

That gives us metadata like title, date, and the actual blog content.

This returns a nice, digestible array of blog posts to the API consumer (our blog frontend).


📦 Client-side Fetch & Rendering

In /app/blogs/page.tsx, we call our API:

const posts = await getPostMetaData();
return <BlogClient posts={posts} />;

Enter fullscreen mode Exit fullscreen mode

Inside getPostMetaData():

const response = await fetch(`${BASE_URL}/api/blog/v2`);

Enter fullscreen mode Exit fullscreen mode

Why fetch from an API instead of reading .mdx files directly?

  • Keeps server-side data handling clean
  • Allows for revalidation with next: { revalidate: 60 }

🔍 View Individual Posts

In [slug]/page.tsx, we dynamically fetch the content:

const postContent = await fetchPostFromAPI(slug);

Enter fullscreen mode Exit fullscreen mode

This hits:

GET / api / blog / v2 / [slug];

Enter fullscreen mode Exit fullscreen mode

Which in turn fetches:

https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${BRANCH}/${slug}.mdx

Enter fullscreen mode Exit fullscreen mode

We parse the raw .mdx again using gray-matter, then render it with:

<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
  {postContent.content}
</Markdown>

Enter fullscreen mode Exit fullscreen mode

Boom. Fully dynamic blog. No build required. New blog post? Just git push to your content repo and you’re done. 🎉

📰 Bonus: Generating an RSS Feed

RSS is still relevant – especially if you want to syndicate to sites like dev.to or Feedly.

In /app/rss/route.ts, we:

  1. Call getPostMetaData() again
  2. Convert each post to <item> XML
  3. Wrap everything in a valid <rss> schema
<rss version="2.0">
  <channel>
    <title>Ullas Kunder Blog</title>
    <link>https://ullaskunder.com</link>
    <description>...</description>
    ${items.join("")}
  </channel>
</rss>

Enter fullscreen mode Exit fullscreen mode

Served with:

return new NextResponse(rss, {
  headers: {
    "Content-Type": "application/xml",
  },
});

Enter fullscreen mode Exit fullscreen mode

You now have an auto-updating RSS feed from your GitHub-powered blog. 🤘

Want to make sure it’s valid? Use this tool from W3C:

  • In development , use Validate by Direct Input
  • Once deployed, use Validate by URL

👉 https://validator.w3.org/feed/

Make sure your XML is well-formed — dev.to and other RSS consumers can be picky.


✍️ Publish to dev.to via RSS

Platforms like dev.to can automatically sync articles from your RSS feed.

Just head to your dev.to settings → Extensions → RSS Integration , and paste your RSS feed URL (e.g., https://yourdomain.com/rss).

Once dev.to pulls in your post, you'll see it listed in your dashboard under Posts :

But here’s the catch: 🚫 There’s no visible "Publish" button or any clear indication on that list view — which honestly sucks. You’d expect to be able to publish right from there, but no.

Instead, you have to click "Edit" on a post. Only inside the edit screen will you see the message:

Unpublished Post. This URL is public but secret, so share at your own discretion. > (Click to edit)

Click that link, and in the editor, you’ll see the synced metadata at the top:

---
title: Essential React Hooks: A Comprehensive Guide
published: false
date: 2023-07-19 00:00:00 UTC
tags: react, hooks, beginners
canonical_url: https://ullaskunder.tech/blogs/basic-of-react-hooks
---

Enter fullscreen mode Exit fullscreen mode

👉 All you need to do is change:

published: false to published: true

...then save. Boom — it’s live.

🛠️ Hopefully dev.to improves this flow — it would be far more intuitive to allow publishing directly from the post list.

🧠 What We’ve Achieved

  • ✅ Keep content and code separate
  • ✅ Avoid rebuilding the blog for every new post
  • ✅ Secure and version-controlled Markdown storage
  • ✅ Dynamic content loading with caching
  • ✅ Auto-generating RSS feed
  • ✅ Easy syndication to dev.to or elsewhere

🔐 Why a Private GitHub Repo?

Because the internet is... well, the internet.

  • Public repo = free-for-all
  • Random people might PR garbage
  • Or worse, spam bots might crawl it constantly

So, we keep it private and authenticate with a GitHub token (stored securely via environment variables like GITHUB_TOKEN).

🚀 Future Enhancements

  • ✍️ Add support for content previews (e.g., /drafts)
  • 🧠 Add AI summaries or highlights
  • 🔎 Integrate search indexing (Algolia, Meilisearch)
  • 💌 Generate email newsletters from RSS

💬 Wrapping Up

Using GitHub as your blog CMS might feel odd at first, but it makes total sense for dev-centric blogs:

  • Markdown is natural for us
  • Git is already part of our workflow
  • Next.js handles the rendering like a champ

Write locally. Commit. Push. Done. Let your blog do the heavy lifting, not your deploy pipeline.

Comments 1 total

Add comment