{"collection":"posts","slug":"1670659200001-obsidian-as-a-cms","cid":"bafkreie24fzdijtqebcnyo7voq6kadp7lkpqvs5ind5yugnbkuij74howe","title":"Obsidian as a CMS","excerpt":"Obsidian as a CMS using structured frontmatter, GitHub sync, Cloudflare R2 for assets, and Next.js for rendering.","body":"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.\n\nThe Obsidian template has structured YAML frontmatter. File names follow a Zettelkasten-inspired millisecond timestamp, paired with a `created` timestamp and the initial post title.\n\n```yaml\n---\ntitle: \"1671418753342\"\ncreated: \"1671418753342\"\nlongform: false\npublished: false\n---\n```\n\nThe timestamp-as-title means most posts live as journal entries. Descriptive titles are optional.\n\nObsidian syncs to a private GitHub repo on every save. A `published` flag in the frontmatter gates public display.\n\nOn push, a GitHub Action uploads anything in the assets folder to a Cloudflare R2 bucket (S3-compatible, generous free tier).\n\n```yaml\nname: Cloudflare\non:\n  push:\n    branches:\n      - main\n  workflow_dispatch: null\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - name: R2 Directory Upload\n        uses: willfurstenau/r2-dir-upload@main\n        with:\n          accountid: \"${{ secrets.CF_ACCOUNT_ID }}\"\n          accesskeyid: \"${{ secrets.CF_ACCESS_KEY }}\"\n          secretaccesskey: \"${{ secrets.CF_SECRET_KEY }}\"\n          bucket: iam-bucket\n          source: \"${{ github.workspace }}/Assets\"\n          destination: /\n```\n\nNextJS handles the frontend. Two queries do the work: `getObsidianEntries` and `getObsidianEntry`.\n\n`getObsidianEntries` pulls every entry from the repo:\n\n```tsx\nexport default async function getObsidianEntries() {\n  const token = process.env.NEXT_PUBLIC_GITHUB;\n\n  const {\n    data: {\n      repository: {\n        object: { entries },\n      },\n    },\n  } = await fetch(`https://api.github.com/graphql`, {\n    method: `POST`,\n    headers: {\n      \"Content-Type\": `application/json`,\n      Authorization: `Bearer ${token}`,\n    },\n    body: JSON.stringify({\n      query: `\n      query fetchEntries($owner: String!, $name: String!) {\n        repository(owner: $owner, name: $name) {\n          object(expression: \"HEAD:Content/\") {\n            ... on Tree {\n              entries {\n                name\n                object {\n                  ... on Blob {\n                    text\n                  }\n                }\n              }\n            }\n          }\n        }\n      }  \n            `,\n      variables: {\n        owner: `GITHUB_USERNAME`,\n        name: `REPO_NAME`,\n        first: 100,\n      },\n    }),\n    next: {\n      revalidate: 1 * 30,\n    },\n  }).then((res) =\u003e res.json());\n\n  return entries;\n}\n```\n\nPrivate repos need an access token (see [Authenticating with GraphQL](https://docs.github.com/en/graphql/guides/forming-calls-with-graphql#authenticating-with-graphql)). The query targets the `Content` directory on `main`. Next13's revalidation flag keeps things fresh.\n\nI parse frontmatter with `gray-matter` and render Markdown with `react-remark`, part of the Unified/Remark ecosystem.\n\n`getObsidianEntry` powers individual post pages:\n\n```tsx\nexport default async function getObsidianEntry(slug: any) {\n  const paths = await getObsidianEntries();\n\n  const _paths = await Promise.all(paths);\n\n  const entry = _paths.find((entry: any) =\u003e entry.slug === slug);\n\n  return entry;\n}\n```\n\nA 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.\n\nThat's the whole setup.","tags":["obsidian","github","cms","nextjs","cloudflared","graphql","decentralization"],"published":true,"createdAt":"2022-12-10T08:55:00Z","updatedAt":"2026-07-16T14:56:28Z"}
