Logo

Github issues as a CMS for your Next.js blog

When I started creating the Blog for Maffin page, I wanted to keep the amount of code and infrastructure as low as possible. Doing some research I started seeing some blog posts about using Github as a CMS and got me curious. I decided I wanted to give it a try to storing my posts as Github issues and here are my thoughts after playing with it.

  • 🧰 Content editing: Github uses GFM (Github flavored markdown) which is very powerful and used by literally thousands out there included myself. Who hasn't opened an issue in github or commented something there? We can even upload images, videos, etc. which will be hosted in Github.
  • 📁 Metadata: Github issues have loads of metadata which is super useful for our blog posts. Things like author, labels (as tags), creation date, update date, comments, etc. All this can be retrieved using Github API and displayed in your website!
  • 🌐 API: Octokit is the official SDK for interacting with Github. Has good support and it's perfect for retrieving the issues from our code.
  • 🔍 Open source: All the tools related to it are open source. Your repo can either be private or public.

🌐 Retrieving an issue with Octokit

You can install Octokit with yarn add octokit. Then create a getPosts.ts file in your Next.js blog and add the following code:

src/app/api/getPosts.ts
import { DateTime } from 'luxon';  // yarn add luxon
import { Octokit } from 'octokit';

export type Post = {
  slug: string,
  date: DateTime,
  title: string,
  summary: string,
  tags: string[],
  content: string,
  author: {
    name?: string,
    avatar?: string,
  },
};

export async function getPosts(): Promise<Post[]> {
  // You can skip adding auth and it will still work
  // but you may get rate limited during development
  const octokit = new Octokit({
    auth: '<your token>',
  });
  const issues = await octokit.rest.issues.listForRepo({
    owner: 'maffin-io',
    repo: 'maffin-blog',
    creator: 'argaen',  // For now I'm the only author but can add others!
    headers: {
      accept: 'application/vnd.github+json',
    },
  })).data.filter((issue) => !issue.pull_request);

  return issues.map((issue) => ({
    slug: 'my-blogpost-slug',
    date: DateTime.fromISO(issue.created_at),
    title: issue.title,
    summary: 'My blog summary',
    tags: issue.labels.map((label) => label.name),
    content: issue.body || 'Post under construction',
    author: {
      name: issue.user?.name || issue.user?.login,
      avatar: issue.user?.avatar_url,
    },
  }));
}

Line 28 makes Octokit return the body of our issue in markdown syntax. There are other media types you could specify there like for example application/vnd.github.html+json which will return the body in a property called body_html and in HTML format with the same classes Github would render it. However, we are interested in markdown syntax as we will be doing our own formatting and parsing so we can customise some CSS and also some extra fields like slug and summary in lines 33 and 36.

A simple component that would render all your posts could look like this:

src/app/page.tsx
import { DateTime } from 'luxon';

import { getPosts } from '@/app/api/getPosts';

export default async function HomePage() {
  const posts = await getPosts();

  return (
    <>
      {posts.map((post) => {
        const {
          title,
          date,
          summary,
          tags,
          slug,
        } = post;

        return (
          <article key={slug}>
            <h1>{title}</h1>
            <span>{tags.join(',')}</span>
            <p>
              <time dateTime={date.toISODate()}>{date.toLocaleString(DateTime.DATE_MED)}</time>
            </p>
            <p>{summary}</p>
          </article>
        );
      })}
    </>
  );
}

You can go now to your repo and create one or two issues so they are picked up by the getPosts function. Once done, if you render your home page, you should see them appearing:

Screenshot 2023-04-26 at 10 38 37

To give style to Maffin Blog I used tailwindcss next starter blog repo. It's very easy to use and gives you a neat minimalistic style that can be extended depending on your needs.

If you create a similar page for rendering a specific blog post displaying the content, you'll see that currently it displays very ugly content as it's still in markdown syntax.

📚 Formatting markdown content with remark & rehype

Remark is a tool that allows you to process markdown files and transform them using plugins. Rehype is the same but for HTML content. It gets a bit confusing when you have to transform markdown to HTML as you have remark plugins doing the same as the rehype ones each with their respective content type. You choose which side you want to process things but here's my choice of plugins to nicely format markdown coming from Octokit API:

  • remark-parse: This one you'll need for sure as it's the one that parses markdown content.
  • remark-gfm: Supports GFM parsing so you can interpret things coming from it like footnotes, link literals, etc. I would recommend it since we are interpreting markdown coming from Github.
  • remark-rehype: Transforms remark tree to a rehype tree so we can use rehype plugins.
  • rehype-code-titles: Allows to add titles to markdown code syntax.
  • rehype-prism-plus: Formats your code with classes so it's easy to add syntax highlighting using any prism CSS themes.
  • rehype-raw: Reparses the tree again. We need this because when attaching files to Github, they are attached using HTML syntax <img> rather than markdown syntax. If you don't use this, the images will disappear.
  • rehype-stringify: This is the final step, the one that serialises the tree to HTML.

You can install all the plugins above with

yarn add remark remark-parse remark-gfm remark-rehype rehype-code-titles rehype-prism-plus rehype-raw rehype-stringify

Let's use this. Create a new lib/markdownToHtml.ts file with the following contents:

import { remark } from 'remark';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import rehypeCodeTitles from 'rehype-code-titles';
import rehypePrismPlus from 'rehype-prism-plus';
import rehypeRaw from 'rehype-raw';
import rehypeStringify from 'rehype-stringify';

export default async function markdownToHtml(markdown: string) {
  const result = await remark()
    .use(remarkParse)
    .use(remarkGfm) // Github flavored markdown
    .use(remarkRehype, { allowDangerousHtml: true })
    .use(rehypeCodeTitles)
    .use(rehypePrismPlus)
    // This is so we can parse <img> tags coming from Github markdown
    // keep it here! rehypeprismplus code lines don't work if they are after
    // this plugin
    .use(rehypeRaw)
    .use(rehypeStringify)
    .process(markdown)

  return result.toString();
}

In line 14 you see allowDangerousHtml: true. This is so we include the <img> tags from Github when importing files so later rehype-raw can parse those. Testing all this functionality can be a bit tricky, check the tests I used during development.

Now, you can use this function to parse your content in getPosts.ts file:

src/app/api/getPosts.ts
[...]
import markdownToHtml from '@/lib/markdownToHtml';

[...]

export async function getPosts(): Promise<Post[]> {
  const octokit = new Octokit({
    auth: '<your token>',
  });
  const issues = await octokit.rest.issues.listForRepo({
    owner: 'maffin-io',
    repo: 'maffin-blog',
    creator: 'argaen',
    headers: {
      accept: 'application/vnd.github+json',
    },
  })).data.filter((issue) => !issue.pull_request);


  return Promise.all(issues.map(async(issue) => {
    if(!issue.body) {
      throw new Error(`Missing body in issue ${issue.title}`);
    }

    const htmlContent = await markdownToHtml(issue.body);
    return {
      slug: toSlug(issue.title),
      date: DateTime.fromISO(issue.created_at),
      title: issue.title,
      summary: 'Blog post summary TODO',
      tags: issue.labels.map((label) => label.name),
      content: issue.body || 'Post without content',
      author: {
        name: issue.user?.name || issue.user?.login,
        avatar: issue.user?.avatar_url,
      },
    };
  }));
}

If you print the content of a blog post now, you'll see a bunch of un-styled HTML in your browser. The important thing to check to make sure everything works is that HTML should have some classes. For example a piece of code with title:

<div class="rehype-code-title">src/app/api/getPosts.ts</div>
<pre class="language-js">
  <code class="language-js code-highlight">
    <span class="code-line line-number" line="1">
      <span class="token keyword module">import</span> 
      <span class="token imports">
        <span class="token punctuation">{</span>

[...]

For code highlighting to appear, remember in your Github issue to annotate your code blog with the language. For example ```js

Having HTML with classes now, you can easily customise them with Tailwindcss. If you are curious on how I did it, here's the commit that added this code to Maffin Blog.

🍒 Extra metadata for your blog posts

In the previous sections, we hardcoded some of the fields for our blog posts like slug and summary. We could dynamically extract this data from the current content but sometimes you want to customise those so you have more control. In order to customise this data, we are going to use frontmatter.

Because Github issues interpret the yaml syntax separators:

---  <- This is interpreted by Github as an horizontal line
slug: hello
---

we are going to use toml syntax. The extra metadata we want to add is slug, summary and reading_time.

+++
slug = "github-issues-as-nextjs-blog-cms"
summary = "My custom summary"
reading_time = "20 min"
+++

In order to install we will install the needed plugins with yarn add remark-frontmatter remark-extract-frontmatter toml and use them as follows (in the src/lib/markdownToHtml.ts file we created before):

src/lib/markdownToHtml.ts
import toml from 'toml';
import { remark } from 'remark';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import remarkFrontMatter from 'remark-frontmatter';
import remarkExtractFrontMatter from 'remark-extract-frontmatter';
import rehypeCodeTitles from 'rehype-code-titles';
import rehypePrismPlus from 'rehype-prism-plus';
import rehypeRaw from 'rehype-raw';
import rehypeStringify from 'rehype-stringify';

export default async function markdownToHtml(markdown: string): Promise<{ content: string, metadata: any }> {
  const result = await remark()
    .use(remarkParse)
    .use(remarkFrontMatter, ['toml'])
    .use(remarkExtractFrontMatter, { toml: toml.parse })
    .use(remarkGfm) // Github flavored markdown
    .use(remarkRehype, { allowDangerousHtml: true })
    .use(rehypeCodeTitles)
    .use(rehypePrismPlus)
    // This is so we can parse <img> tags coming from Github markdown
    // keep it here! rehypeprismplus code lines don't work if they are after
    // this plugin
    .use(rehypeRaw)
    .use(rehypeStringify)
    .process(markdown)

  return {
    content: result.toString(),
    metadata: result.data,
  }
}

Note in lines 16-17 we specify toml as the parsing method

Instead of returning just the content we are now returning both content and metadata so you can use it in getPosts.ts as follows:

src/app/api/getPosts.tsx
  return Promise.all(issues.map(async(issue) => {
    if(!issue.body) {
      throw new Error(`Missing body in issue ${issue.title}`);
    }

    const { content: htmlContent, metadata } = await markdownToHtml(issue.body);
    return {
      slug: metadata.slug,
      date: DateTime.fromISO(issue.created_at),
      title: issue.title,
      summary: metadata.summary,
      reading_time: metadata.reading_time,
      tags: issue.labels.map((label) => label.name),
      content: htmlContent,
      author: {
        name: issue.user?.name || issue.user?.login,
        avatar: issue.user?.avatar_url,
      },
    };
  }));

💚 Conclusion

As you can see Github issues gives us powerful features to use as a CMS. There are a few things I would like to implement and will probably write in the future like:

  • 💬 Adding comments and reactions to the posts (and reflecting them into the issues)
  • 🏷️ Better categorisation of the blogposts. Currently I'm using labels but I want some main category for the blog posts given I'll be writing not only about engineering but also travel and finance (related to Maffin product).
  • 🐛 I may a published label to filter on the getPosts function so we can have draft blog posts that can be rendered during development to detect errors.

Feel free to navigate the code freely in here. It refers to the commit I used to ship the changes described here and you can find unit tests for the functionality added too.

That's it! If you enjoyed the post feel free to react to it in the Github tracker (for now).