Originally published on my personal blog, https://zimnicki.co/blog/github-cms.
Look at this little blog post. Probably just a markdown file in the website’s repository, or an integration with a writing platform. What if I told you that this is indeed a markdown file, but it actually lives in a completely unrelated and, unlike the website, private repository.
This post is purely for educational purposes, I don’t recommend anyone replicates my setup without first fully understanding all possible security, privacy and legal concerns.
Backstory
Since around 2020, all of my websites had some sort of simple blog. A place for me to post my unfiltered thoughts, ideas and learnings. I’d often treat those as almost my private Twitter feed, at one point even changing the blog into my an integration that used Twitters API to display my posts there.
It worked nice, but I always disliked having to go from my writing tool Ulysses to a content management system, upload it, format it and publish to my website. On the contrary I didn’t really vibe with the idea of having my markdown files in the same repository as my website itself for some reason.
As a result of this friction, there were moments where I simply stopped writing those blog posts, I’ve just felt too unmotivated.
This Website
When I started building this new website, mainly to separate my professional work from some of the more personal stuff, it occurred to me.
What if I could just drag-and-drop my blog posts into the browser
and they’d appear on my website?
Under a lot of other circumstances, I’d have simply used a hosted solution like Ghost, but I really wanted to embrace the file-over-app philosophy, especially
after the recent controversies surrounding certain software products suddenly ceasing support.
Having remembered this video of someone stealing storage from Discord to avoid paying for Google Drive, I thought to myself, what if I could do the same for my blog posts. Since I didn’t use discord, I decided to use the next closest thing — GitHub.
I can already drag and drop files into GitHub, so what’s stopping my website from retrieving them? Turns out, not much.
How am I doing it then?
Since my website uses React (specifically, Next) and Typescript under the hood, I started with initializing the official GitHub API, called OctoKit.
import { Octokit } from "octokit";
export const octokit = new Octokit({
auth: process.env.GITHUB_PUBLIC_PERSONAL_TOKEN,
});
The GITHUB_PUBLIC_PERSONAL_TOKEN
you’re seeing here is a fine-grade access token with read-only access to the repository I will be using to store my content.
The next step was to organize it, to make it’s easier to recursively search for markdown/images and other files, so I started by creating a directory blog
in the content repository and pushed it to GitHub. This allows me to request the contents of that directory with a simple call, something like the example below.
const response = await octokit.request(
`GET /repos/${username}/${repo}/contents/blog/`, {
headers: {
"X-GitHub-Api-Version": "2022-11-28",
},
});
So we have the raw contents of the directory, but that’s still far from a content management system, so let’s keep going. The next step is to get an array of all the blog posts within the directory.
export async function handleAllPosts(): Promise<BlogPost[]> {
return octokit
.request(`GET /repos/${username}/${repo}/contents/blog/`)
.then((response) =>
Promise.all(
response.data.map(
(post: { name: string; download_url: string }, index: number) =>
axios
.get(post.download_url)
.then(({ data: content }) => {
const title = content.split("\n")[0].replace("#", "").trim();
return { id: index + 1, filename: post.name, title };
})
.catch((error) => {
console.error(error);
return null;
}),
),
),
)
.then((posts) => posts)
.catch((error) => {
console.error(error);
return [];
});
}
Since the way I write my content always includes a title in the first line of the file, I also fetch it as part of the same function. This gives us a nice little array of posts.
[
{
"id": 1,
"filename": "github-cms.md",
"title": "Using GitHub as a Content Management System"
},
{
"id": 2,
"filename": "secret-post.md",
"title": "Super Secret Post"
}
]
Now that that’s handled, we need to display those blog posts somehow. Next provides us with Dynamic Routes, which makes things pretty straightforward.
In my application, I’ve now created a /blog/[post]/
route and a page.tsx
inside of it. When navigating from our post, we simply pass the filename (up to you to omit the extension) as the slug and use it to get the full content of a post.
The process to get the actual content of the .md file is pretty close to what we did for the array previously and looks something like below.
export async function handlePost(post: string): Promise<BlogPostContent> {
return octokit
.request(`GET /repos/${username}/${repo}/contents/blog/${post}.md`)
.then((response) =>
axios
.get(response.data.download_url)
.then(({ data: content }) => {
const title = content.split("\n")[0].replace("#", "").trim();
return { title, content };
})
.catch((error) => {
console.error(error);
return null;
}),
)
.then((post) => post)
.catch((error) => {
console.error(error);
return null;
});
}
We call this function from the page.tsx
. In my particular case I use the MDXRemote from next-mdx-remote/rsc
, but there are many ways to actually render the content on the page.
export default async function Post({
params,
}: {
params: Promise<{ post: string }>;
}) {
const post = (await params).post;
const { title, content } = await handlePost(post);
return (
<div>
<MDXRemote source={content} />
</div>
);
}
My Current Workflow
I guess the most pressing question is, did going through all this improve my workflow? Well, if you’re reading this post, that would mean that I was happy enough with it to publish it, so, yes it did.
It will probably take me some time before I migrate my older posts from other platforms and the depths of my hard-drive though. If you’re wondering how my current workflow looks like, there are two depending on what I’m writing.
The first looks like this. I write what I want to write in Ulysses, export to markdown, then drag and drop into the content repository through the web interface.
My second workflow involves using Obsidian to edit a markdown file that already lives in a local origin of the remote repository and then triggering a script that adds, commits and pushes it to the remote origin.
You can also easily use the same approach for things other than markdown, like images, video and possibly more, but performance is not guaranteed by any means.
Going Forward
Do I recommend you replicate this for your website? No. I really can’t stress this enough, this is an experiment that was mostly done out of personal frustration and for fun. There can absolutely be unforeseen consequences if you don’t know what you’re doing, eg. not scoping the personal access token could easily let someone take over your GitHub account.
Also, GitHub has limits on their APIs, so if your website has more visitors than mine it’s very likely you’ll run into issues quickly.
That said, this was a fun experiment. Thanks for reading all the way to the end.
Top comments (1)
Good!