home menu

Obsidian as a CMS

Obsidian as CMS: Git-based system using timestamps, GitHub sync, Cloudflare for assets, and NextJS for display, edit anywhere with automatic.

I shared a concept on Farcaster that picked up some interest, so here are the details. It's a decentralized, git-based "CMS" that lets me edit content from anywhere.

The Obsidian template has structured YAML frontmatter. File names follow a Zettelkasten-inspired millisecond timestamp, paired with a created timestamp and the initial post title.

---
title: "1671418753342"
created: "1671418753342"
longform: false
published: false
---

The timestamp-as-title means most posts live as journal entries. Descriptive titles are optional.

Obsidian syncs to a private GitHub repo on every save. A published flag in the frontmatter gates public display.

On push, a GitHub Action uploads anything in the assets folder to a Cloudflare R2 bucket (S3-compatible, generous free tier).

name: Cloudflare
on:
  push:
    branches:
      - main
  workflow_dispatch: null
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: R2 Directory Upload
        uses: willfurstenau/r2-dir-upload@main
        with:
          accountid: "${{ secrets.CF_ACCOUNT_ID }}"
          accesskeyid: "${{ secrets.CF_ACCESS_KEY }}"
          secretaccesskey: "${{ secrets.CF_SECRET_KEY }}"
          bucket: iam-bucket
          source: "${{ github.workspace }}/Assets"
          destination: /

NextJS handles the frontend. Two queries do the work: getObsidianEntries and getObsidianEntry.

getObsidianEntries pulls every entry from the repo:

export default async function getObsidianEntries() {
  const token = process.env.NEXT_PUBLIC_GITHUB;

  const {
    data: {
      repository: {
        object: { entries },
      },
    },
  } = await fetch(`https://api.github.com/graphql`, {
    method: `POST`,
    headers: {
      "Content-Type": `application/json`,
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({
      query: `
      query fetchEntries($owner: String!, $name: String!) {
        repository(owner: $owner, name: $name) {
          object(expression: "HEAD:Content/") {
            ... on Tree {
              entries {
                name
                object {
                  ... on Blob {
                    text
                  }
                }
              }
            }
          }
        }
      }  
            `,
      variables: {
        owner: `GITHUB_USERNAME`,
        name: `REPO_NAME`,
        first: 100,
      },
    }),
    next: {
      revalidate: 1 * 30,
    },
  }).then((res) => res.json());

  return entries;
}

Private repos need an access token (see Authenticating with GraphQL). The query targets the Content directory on main. Next13's revalidation flag keeps things fresh.

I parse frontmatter with gray-matter and render Markdown with react-remark, part of the Unified/Remark ecosystem.

getObsidianEntry powers individual post pages:

export default async function getObsidianEntry(slug: any) {
  const paths = await getObsidianEntries();

  const _paths = await Promise.all(paths);

  const entry = _paths.find((entry: any) => entry.slug === slug);

  return entry;
}

A direct GraphQL filter for a single entry would be cleaner, but I haven't gotten it to work yet. For now, this filters by slug (Zettelkasten tag) against the full set.

That's the whole setup.

collection
posts
rkey
1670659200001-obsidian-as-a-cms
record cid
bafkreib7zpy7436gj4ybgnqyv3r5veglw3qgyu65i33qydrpsscczojpjq
record
https://content.farfield.systems/api/entries/1670659200001-obsidian-as-a-cms
created
updated