Building a Real-Time Headless CMS with Astro SSR and GitHub
Article

Building a Real-Time Headless CMS with Astro SSR and GitHub

A

Anil Sardiwal

Full-Stack Product Engineer

Share

How to use GitHub as a Real-Time Headless CMS for Astro

I have been blogging since 2011. Back then, WordPress was the king of the hill. But it is 2026 now and the web has changed. I am tired of the endless cycle of plugin updates, heavy PHP backends, and cluttered themes. Even in my coding life, I have moved away from heavy frameworks like Django in favor of FastAPI.

When I decided to restart my blog as a hobby to save my thoughts, I knew I wanted something fast and modern. Astro is the perfect fit because of its minimal JS footprint. While Astro allows you to keep markdown files in your local source folder, that can make your main build heavy over time.

Instead, I decided to use GitHub as my Headless CMS using true SSR (Server Side Rendering). This means I build the site once and it fetches the latest content from a separate GitHub repository whenever a user visits the page. No more redeploying just to fix a typo.

The Game Plan

Traditionally, you have to define every blog post detail in a local config file. If one piece of frontmatter is missing, the whole project might break. We are going to avoid that by using Octokit, the official GitHub SDK, to pull our data dynamically.

Step 1: Configure Astro for SSR

Before we write any logic, we have to tell Astro to run on a server rather than building static files. Open your astro.config.mjs and update it to enable server output and prefetching.

import { defineConfig } from 'astro/config';

export default defineConfig({
  output: 'server',
  prefetch: {
    prefetchAll: true
  },
});

Step 2: Set Up Your Content Vault

Create a new GitHub repository to act as your Content Vault. You can organize it however you want. I prefer keeping things chronological using a folder structure like: {blog\_name}/{YYYYMM}/{blog-post.md}.

Step 3: Frontmatter Specification

Every .md or .mdx file in your repository must include a YAML block at the top. This is critical because the SSR loader needs these details to build your UI without breaking.

---
title: "The Title of Your Post"
date: 2025-12-24
description: "A short, 2-sentence hook for the blog archive list."
featured_img: "[https://images.pexels.com/photos/7005491/pexels-photo-7005491.jpeg](https://images.pexels.com/photos/7005491/pexels-photo-7005491.jpeg)"
tags: ["Health", "Systems", "Curiosity"]
---

Step 4: Secure Your Connection

To let your Astro site talk to GitHub, you need a Personal Access Token:

  1. Go to GitHub Settings > Developer Settings.

  2. Select Personal Access Tokens and then Tokens (classic).

  3. Generate a new token with repo permissions and set it to No Expiration.

  4. Copy that token and paste it into your Astro project .env file as GITHUB\_TOKEN.

Step 5: The Octokit Service

We use Octokit, the official GitHub SDK, to pull data. Create a file at src/lib/octokit.ts. This script will handle fetching the list of posts and the individual content.

In this file, we initialize Octokit with your token. The getPosts function scans your folders, filters for markdown files, and even calculates a reading time automatically by counting words and dividing by an average speed of 200 words per minute.

import { Octokit } from "octokit";
import matter from "gray-matter";

export const octokit = new Octokit({
  auth: import.meta.env.GITHUB_TOKEN,
});

const REPO_DETAILS = {
  owner: "your-github-username",
  repo: "your-repo-name",
};

export const PostService = {
  async getPosts(space: string) {
    try {
      const { data: dateFolders } = await octokit.rest.repos.getContent({
        ...REPO_DETAILS,
        path: space,
      });

      if (!Array.isArray(dateFolders)) return [];

      const postGroups = await Promise.all(
        dateFolders.filter(f => f.type === "dir").map(async (folder) => {
          const { data: files } = await octokit.rest.repos.getContent({
            ...REPO_DETAILS,
            path: folder.path,
          });

          if (!Array.isArray(files)) return [];

          const contentFiles = files.filter(f => 
            f.name.toLowerCase().endsWith('.md') || 
            f.name.toLowerCase().endsWith('.mdx')
          );

          return Promise.all(contentFiles.map(async (file) => {
            const { data: rawContent } = await octokit.rest.repos.getContent({
              ...REPO_DETAILS,
              path: file.path,
              headers: { 'accept': 'application/vnd.github.v3.raw' }
            });

            const { data: frontmatter, content } = matter(rawContent as string);
            const wordCount = content.split(/\s+/).length;
            const readingTime = Math.ceil(wordCount / 200);
            const slug = file.name.replace(/\.mdx?$/, "");

            return {
              id: slug,
              folderDate: folder.name,
              fullPath: file.path,
              readingTime,
              ...frontmatter
            };
          }));
        })
      );

      const allPosts = postGroups.flat();
      return allPosts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
    } catch (error) {
      console.error("GitHub Fetch Error:", error);
      return [];
    }
  },

  async getPostContentBySlug(space: string, slug: string) {
    const allPosts = await this.getPosts(space);
    const post = allPosts.find(p => p.id === slug);
    if (!post || !post.fullPath) return null;

    const { data } = await octokit.rest.repos.getContent({
      ...REPO_DETAILS,
      path: post.fullPath,
      headers: { 'accept': 'application/vnd.github.v3.raw' }
    });
    return data;
  }
};

Step 6: Displaying the List

In your index.astro file, you can now pull your list of posts. Since we are using SSR, this list is always fresh. You just call the getPosts function, sort them by date, and map them into your UI components.

---
import { PostService } from '@/lib/octokit';
const posts = await PostService.getPosts('travel');
---

<div class="grid grid-cols-1 md:grid-cols-2 gap-12">
  {posts.map((post) => (
    <div class="card">
       <a href={`/blog/${post.id}`}>{post.title}</a>
       <p>{post.description}</p>
    </div>
  ))}
</div>

Step 7: The Single Post Page

Create a dynamic route at src/pages/blog/\[id\].astro. By setting "export const prerender = false", you tell Astro to fetch the content from GitHub every time someone visits the URL. We use the "matter" library to parse the frontmatter and "marked" to turn the raw markdown into HTML.

---
import { PostService } from '@/lib/octokit.ts';
import matter from 'gray-matter';
import { marked } from 'marked';

export const prerender = false;

const { id } = Astro.params;
if (!id) return Astro.redirect('/404');

const rawMarkdown = await PostService.getPostContentBySlug('travel', id);
if (!rawMarkdown) return Astro.redirect('/404');

const { data, content } = matter(rawMarkdown as string);
const htmlContent = marked.parse(content);
---

<article>
  <h1>{data.title}</h1>
  <div set:html={htmlContent} />
</article>

A Word on Traffic and Limits

It is important to note that if you expect 1,000 visitors at once, you might hit the GitHub API rate limits. If your blog reaches that level of scale, it is better to move your content vault away from GitHub to a dedicated database. However, since my personal blogs do not see massive spikes and my content is centrally located, I am currently very happy with GitHub. It is free, version-controlled, and reliable for my needs.

If you want I can send the open source code for this sample Astro and Headless GitHub CMS setup below. Reach me on X @ CodingFromMars.

Ready to ditch the bloat? If you want to migrate your existing WordPress site to a lightning-fast Astro platform like this one, feel free to contact me for my services.

Would you like me to help you write the logic for an automated backup script to keep your GitHub content vault synced to a local drive?