Obsidian as a CMS

This post was published 2022/12/10 & last updated 2023/11/03


I migrated back to Obsidian because the Notion API is garbage.

We're so back.

I migrated to Notion to reduce overhead. Using Obsidian as a CMS was a fun experiment, but it required too much custom work to be practical.

This concept picked up a bit of traction when I mentioned it on Farcaster. There are a few moving pieces that come together to provide a CMS of sorts that can be edited from anywhere.

It all revolves around an Obsidian template the presets some structured YAML frontmatter. A Zettelkasten inspired tag is used for the file names (millisecond time), along with the created timestamp and the initial post title.

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

Using the Zettelkasten tag as the title allows most content to exist as a "journal" entry. If something needs a more descriptive name, that can be set instead.

Obsidian is configured to automatically push / pull content from a private Github repo ~1 minute after editing has stopped. This allows content to be continuously backed up and versioned, while a published flag in the YAML determines whether or not the entry should be displayed publically.

When content is pushed to the repo a Github Workflow runs that scrapes the assets folder and uploads content to a Cloudflare R2 bucket. It's similar to Amazon S3, but has a very generous free tier which is perfect for my current use case.

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

Once content is in the Github repo, and any assets have been pushed to Cloudflare, we can turn our attention to NextJS!

We'll be using 2 basic queries to get our content. Up first: getObsidianEntries.

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 {
                object {
                  ... on Blob {
      variables: {
        owner: `GITHUB_USERNAME`,
        name: `REPO_NAME`,
        first: 100,
    next: {
      revalidate: 1 * 30,
  }).then((res) => res.json());

  return entries;

You'll need an access token set up with Github in order to access the GraphQL endpoint, and your private repositories. You can read more about that here: Authenticating with GraphQL

You then need to drill down in the repository object, filtered for the contain on your main branch (note the expression: "HEAD:Content/" filter). You can then pass in some variables to define your username, and the specific repo you're looking to query. We're making our GraphQL call using fetch(), and leveraging Next13's revalidation flag to make sure content stays fresh.

Once you have your entries, you can manipulate the content however you'd like. I am parsing the YAML frontmatter using grey-matter, but rendering the content using react-remark. This lets me manipulate the YAML with a very terse syntax, while leveraging the broad Unified system for Remark.

My second query, which is used to build the individual post pages, is called getObsidianEntry:

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;

I'm not overly happy with this piece. My preference would be to requery the GraphQL endpoint and only return 1 specific entry, but I have not been able to get a filter to work for that. In the meantime, I am simply filtering out the appropriate entry using the page slug as a key (the Zettelkasten tag).

So there you have it. Obsidian as a CMS. If you're interested in setting up something similar, I hope these notes can help serve as bread-crumbs to guide you along the way.