{"kind":"doc","doc":{"title":"Obsidian as a CMS","section":{"slug":"posts","name":"Posts"},"rkey":"1670659200001-obsidian-as-a-cms","cid":"bafkreie24fzdijtqebcnyo7voq6kadp7lkpqvs5ind5yugnbkuij74howe","renderKey":"bafkreie24fzdijtqebcnyo7voq6kadp7lkpqvs5ind5yugnbkuij74howe@2022-12-10T08:55:00Z@2026-07-16T14:56:28Z@Posts|","href":"/posts/1670659200001-obsidian-as-a-cms","publishedAt":"2022-12-10T08:55:00Z","updatedAt":"2026-07-16T14:56:28Z","tags":["obsidian","github","cms","nextjs","cloudflared","graphql","decentralization"],"description":"Obsidian as a CMS using structured frontmatter, GitHub sync, Cloudflare R2 for assets, and Next.js for rendering.","readingMinutes":2,"markdown":"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) => 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) => 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.","media":{},"series":{},"related":[{"title":"Revisiting Obsidian as a CMS","href":"/posts/1699332127006-revisiting-obsidian-as-a-cms","section":"posts","description":"A home-built publishing system using Obsidian for writing, GitHub for storage, and Next.js for rendering.","publishedAt":"2023-11-06T08:57:00Z"},{"title":"Revisiting Obsidian as a CMS, again","href":"/posts/1710177704945-revisiting-obsidian-as-a-cms-again","section":"posts","description":"Obsidian, GitHub's GraphQL API, and Astro form a free, flexible publishing system for Markdown content.","publishedAt":"2024-03-11T10:21:00Z"},{"title":"Obsidian Plugins","href":"/experiments/1789490903000-obsidian-plugins","section":"experiments","description":"An overview of three Obsidian plugins the author built: an image uploader that sends embedded images to Pinata and rewrites references as ipfs:// URIs, plus AI-powered generators for note excerpts and tags.","publishedAt":"2026-09-20T05:10:12Z"},{"title":"Farfield","href":"/posts/1779066375000-farfield","section":"posts","description":"Five small Go services, single binaries, content-addressed records, running on my homelab. The new backend for the site.","publishedAt":"2026-05-18T01:06:15Z"},{"title":"orbz dot com","href":"/posts/1736537615834-orbz-dot-com","section":"posts","description":"orbz.fun reads Orbiter's onchain site registry and turns it into a StumbleUpon-style discovery tool.","publishedAt":"2025-01-10T11:33:00Z"},{"title":"Pure Internet: Bluesky","href":"/posts/1732585567703-pure-internet-bluesky","section":"posts","description":"Static-site hosting on Bluesky's AT Protocol using content-addressed blob storage.","publishedAt":"2024-11-25T17:46:00Z"}],"prev":{"title":"Case study: Aspiration's CRM platform","href":"/posts/1625156340000-case-study-aspiration"},"next":{"title":"Case Study: Building Tornado's Growth Engine","href":"/posts/1675271943000-case-study-tornado"}}}