Obsidian as a CMS
2 min read
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.