5 min read

Using GitHub Issues as a Blog CMS Without Breaking SEO

How I use GitHub Issues as a lightweight blog CMS while keeping Astro static generation, automatic deployments, responsive images, and SEO working as expected.

Share
Cover for Using GitHub Issues as a Blog CMS Without Breaking SEO

Using GitHub Issues as a Blog CMS Without Breaking SEO

I wanted to add a blog to my portfolio without introducing a database, an admin dashboard, or a traditional CMS.

The solution was simpler than expected: use GitHub Issues as the content source, let Astro turn those Issues into static pages, and automatically redeploy the site whenever a published article changes.

The architecture looks like this:

GitHub Issue
    ↓
GitHub REST API
    ↓
Astro build
    ↓
Markdown parsing
    ↓
Image optimization
    ↓
Static HTML
    ↓
Vercel

GitHub becomes the editor, Astro becomes the renderer, and the deployed website stays completely static.

Why GitHub Issues?

GitHub Issues already provide most of what I need from a lightweight CMS:

A blog post is simply an open Issue with the published label.

For example:

Issue title:
Using GitHub Issues as a Blog CMS

State:
Open

Label:
published

During the Astro build, the site requests only open Issues containing that label.

const response = await fetch(
  `https://api.github.com/repos/${owner}/${repo}/issues?state=open&labels=published`,
  {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/vnd.github+json",
    },
  }
);

The GitHub token exists only during the build and is stored as an environment variable.

It is never exposed to the browser.


Turning an Issue Into a Blog Post

GitHub gives us the information we need directly from the Issue:

{
  number,
  title,
  body,
  labels,
  created_at,
  updated_at
}

That becomes an internal blog model:

interface BlogPost {
  id: number;
  title: string;
  slug: string;
  body: string;
  description: string;
  publishedAt: string;
  updatedAt: string;
  tags: string[];
  coverImage?: string;
}

The Issue title becomes the page title and slug.

Using GitHub Issues as a Blog CMS

↓

using-github-issues-as-a-blog-cms

↓

/blog/using-github-issues-as-a-blog-cms

The Issue body becomes the article content.

The Issue creation date becomes the publication date, while updated_at becomes the last modified date.


Adding Metadata Without Making the Issue Ugly

At the beginning of each Issue, I use a small HTML comment:

<!--
description: How GitHub Issues can work as a lightweight CMS for an Astro blog.
cover: https://github.com/user-attachments/assets/example
-->

Because it is inside an HTML comment, it does not clutter the rendered Issue.

The build extracts these values before rendering the Markdown.

The important detail is that the cover field contains the stable GitHub attachment URL, not an HTML <img> element.

Use:

https://github.com/user-attachments/assets/...

instead of:

<img src="..." />

This keeps the metadata easy to parse.


Automatic Publishing

I did not want to manually open Vercel every time I published or edited a post.

So the Issue repository also contains a GitHub Action.

The workflow listens for Issue events such as:

opened
labeled
edited
reopened
closed
unlabeled

The important publishing rule is:

Issue is OPEN
+
Issue has "published"
=
should exist on the blog

When this condition becomes true, GitHub Actions sends a POST request to a Vercel Deploy Hook.

Create/open Issue
      ↓
add "published"
      ↓
GitHub Action
      ↓
POST Vercel Deploy Hook
      ↓
Vercel starts new build
      ↓
Astro fetches Issues again
      ↓
new article becomes available

A simplified workflow looks like this:

name: Auto Post new Blog trigger on every new github issue

on:
  issues:
    types:
      - labeled
      - edited
      - reopened
      - closed
      - unlabeled

concurrency:
    group: blog-post
    cancel-in-progress: true
jobs:
    Deploy-Production:
        runs-on: ubuntu-latest
        if: |
            (
                github.event.issue.state == 'open' &&
                contains(github.event.issue.labels.*.name, 'published') &&
                (github.event.action == 'labeled' || github.event.action == 'edited' || github.event.action == 'reopened')
            )
            ||
            (
                github.event.action == 'closed' && contains(github.event.issue.labels.*.name, 'published')
            )
            ||
            (
                github.event.action == 'unlabeled' && github.event.label.name == 'published'
            )
        steps:
            - name: Trigger Vercel deployment
              env: 
                DEPLOY_HOOK: ${{ secrets.DEPLOY_HOOK }}
              run: curl --fail-with-body --silent --show-error --request POST "$DEPLOY_HOOK"

The curl command is intentionally simple.

It only sends a POST request to the secret Vercel Deploy Hook URL.

GitHub Action
      ↓
   POST
      ↓
   Vercel
      ↓
new deployment

The concurrency section prevents multiple unnecessary builds from piling up.

If I edit the same article several times quickly:

edit #1
edit #2
edit #3

GitHub may start multiple workflow runs.

With:

concurrency:
  group: blog-post
  cancel-in-progress: true

the old deployment trigger is cancelled and the newest change wins.


The Most Important SEO Decision

The GitHub API is never called from the visitor's browser.

I wanted to avoid this architecture:

Browser
   ↓
Empty HTML
   ↓
JavaScript
   ↓
GitHub API
   ↓
Markdown
   ↓
Render article

Instead, Astro does everything during the build:

GitHub Issue
    ↓
Astro build
    ↓
Generate complete HTML
    ↓
Deploy

When someone opens:

/blog/using-github-issues-as-a-blog-cms

the HTML already contains:

<h1>Using GitHub Issues as a Blog CMS</h1>

<p>
  I wanted to add a blog to my portfolio without introducing a database...
</p>

The search engine does not have to execute JavaScript to discover the article.

That gives the blog the same basic SEO characteristics as a normal statically generated website.


Static Routes With Astro

Astro generates each blog route during the build using getStaticPaths().

export async function getStaticPaths() {
  const posts = await getBlogPosts();

  return posts.map((post) => ({
    params: {
      slug: post.slug,
    },
    props: {
      post,
    },
  }));
}

If three Issues are published:

How I Built Amadil
Understanding Nginx Load Balancing
Using GitHub Issues as a Blog CMS

Astro generates:

/blog/how-i-built-amadil
/blog/understanding-nginx-load-balancing
/blog/using-github-issues-as-a-blog-cms

These are normal static pages.


SEO Metadata

Each article generates its own metadata.

<title>
  Using GitHub Issues as a Blog CMS Without Breaking SEO
</title>

<meta
  name="description"
  content="How I use GitHub Issues as a lightweight blog CMS while keeping Astro static generation and SEO working as expected."
/>

<link
  rel="canonical"
  href="https://haguezoum.site/blog/using-github-issues-as-a-blog-cms"
/>

The page also includes Open Graph metadata:

<meta property="og:type" content="article" />
<meta property="og:title" content="Using GitHub Issues as a Blog CMS" />
<meta property="og:description" content="..." />
<meta property="og:url" content="..." />
<meta property="og:image" content="..." />

This also improves how the post appears when shared on platforms such as LinkedIn, Discord, Slack, and messaging apps.

Appreciate that you are reaching here and reading the my first blog Thank you ❤️

Hassan Aguezoum