---
title: "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."
tags: ["obsidian", "cms", "markdown", "github", "astro", "nextjs", "content-management", "web-development"]
created: 2024-03-11T10:21:00Z
updated: 2026-07-16T14:56:28Z
cid: bafkreif3jxsichpyzzidn4j6xlkzhyak7o2rrxkefp77wtzztmvmim7cky
html: https://iammatthias.com/posts/1710177704945-revisiting-obsidian-as-a-cms-again
---

# Revisiting Obsidian as a CMS, again

The technical details of this setup are in [Obsidian as a CMS](1670659200001-obsidian-as-a-cms.md) and [Revisiting Obsidian as a CMS](1699332127006-revisiting-obsidian-as-a-cms.md). The thought process that got me here is its own thing.

I wanted a backend for my personal site that was as close to free as possible. Airtable burned through the free tier fast, Google Sheets felt like dragging a desk through a doorway. What I actually needed was a flat-file way to publish markdown with metadata, plus the ability to write on the go (Git clients on iOS make that painful).

Obsidian was already my knowledge base. Folder-based templates, private Git backup. With the right YAML frontmatter, it covered all the meta and feature-flag fields I cared about. The only gap was getting the markdown out of the repo and into a front-end framework.

## Inner workings

The GitHub GraphQL API can fetch a single file or a whole directory in one query. YAML gets parsed with [gray-matter](https://github.com/jonschlinkert/gray-matter), then the markdown renders. In Next.js I used [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote). I'm on Astro now to ship less JS, with [astro-remote](https://github.com/natemoo-re/astro-remote), which uses [marked](https://marked.js.org/) and sanitizes output with [ultrahtml](https://github.com/natemoo-re/ultrahtml). Component overrides work the same as MDX.

### Fetching posts

```typescript
async function fetchFromGitHubGraphQL(query: string, variables: any) {
  const response = await fetch("https://api.github.com/graphql", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${github}`,
    },
    body: JSON.stringify({ query, variables }),
  });

  if (!response.ok) {
    console.error("HTTP Error:", response.status);
    return response;
  }

  return response.json();
}
```

`fetchFromGitHubGraphQL` used to be load-bearing across several callers. Now it sits behind one function, `getObsidianEntries`.

```typescript
export async function getObsidianEntries(path: string, slug?: string) {
  const expression = slug ? `HEAD:content/${path}/${slug}.md` : `HEAD:content/${path}`;

  const {
    data: {
      repository: { object },
    },
  } = await fetchFromGitHubGraphQL(
    `
      query fetchEntries($owner: String!, $name: String!, $expression: String!) {
        repository(owner: $owner, name: $name) {
          object(expression: $expression) {
            ... on Tree {
              entries {
                name
                object {
                  ... on Blob {
                    text
                  }
                }
              }
            }
            ... on Blob {
              text
            }
          }
        }
      }
    `,
    {
      owner: `GITHUB_USERNAME`,
      name: `REPO_NAME`,
      expression,
    }
  );

  if (slug) {
    if (!object || !object.text) {
      console.error("No data returned from the GraphQL query for the single entry.");
      return null;
    }
    return parseMarkdownContent(object.text, path);
  }

  if (!object || !object.entries) {
    console.error("No data returned from the GraphQL query for multiple entries.");
    return [];
  }

  const parsedEntries = await Promise.all(
    object.entries.map((entry: { object: { text: any } }) => {
      const content = entry.object.text;
      return parseMarkdownContent(content, path);
    })
  );

  parseAndMergeTags(parsedEntries);

  return parsedEntries;
}
```

### File structure

```
.
├── README.md
├── content
│   ├── art
│   │   └── txt.md
│   ├── notes
│   │   └── txt.md
│   ├── posts
│   │   └── txt.md
│   └── recipes
│       └── txt.md
└── templates
    └── base_template.md
```

The folder layout doubles as routing on the front end. `getObsidianEntries` takes a path, with an optional slug. Slug matches the filename, so `path + slug.md` returns one entry, and `path` alone returns the whole directory. That one trick made it easy to spin up new content types.

```typescript
---
import { Markdown } from "astro-remote";

const { path, slug } = Astro.params;
const entry = await getObsidianEntries(path, slug);
const { body, frontmatter } = entry;
---

<article>
  <Markdown
    components={{ img: Image, p: Paragraph }}
    sanitize={{
    dropElements: ["head", "style"],
    allowCustomElements: true,
    }}
  >
    {body}
  </Markdown>
</article>
```

```typescript
---
import { getObsidianEntries } from "@lib/github";

const { path } = Astro.params;
entries = entries.sort((a, b) => new Date(b.frontmatter.created).getTime() - new Date(a.frontmatter.created).getTime());
---

<>
  {
    entries.map((entry) => (
      <li>
        <p>
          <a href={`/${path}/${entry.frontmatter.slug}`}>{entry.frontmatter.title}</a>
        </p>
      </li>
    ))
  }
</>
```

### Frontmatter

A `base_template` populates each new file. It prompts for a title, formats a URL-safe slug, and stamps creation and modified dates.

```yaml
---
<%*
let title = await tp.system.prompt("Please enter a value");
let slug = tp.file.creation_date("x") + " " + title;
let formatted_slug = slug.trim().replace(/\W+/g, '-').toLowerCase();
await tp.file.rename(`${formatted_slug}`);
%>
title: <%* tR += title; %>
slug: <%* tR += formatted_slug; %>
published: false
created: <% tp.file.creation_date("YYYY-MM-DD HH:mm") %>
updated: <% tp.file.last_modified_date("YYYY-MM-DD HH:mm") %>
tags:
  -
---
```

Tags are the rough edge. Right now I aggregate them into a flat file on Cloudflare R2, which is hacky and unreliable. A hashing scheme to keep tags in sync with published content is on the list.

### Images

The old setup hashed each image with md5, dumped it to `assets`, and a GitHub Action pushed it to R2 on push. The [S3 Image Uploader](https://github.com/jvsteiner/s3-image-uploader) plugin replaced that: it hashes the filename and uploads from the Obsidian editor directly. My PR adding concurrent uploads landed in [`0.2.10`](https://github.com/jvsteiner/s3-image-uploader/releases/tag/0.2.10).

Markdown wraps `<img>` in `<p>`, which I don't want. Astro-Remote makes it easy to unwrap by checking the rendered slot:

```
---
let slots = await Astro.slots.render("default");
let slotsString = slots.toString();
---

{
  slotsString.includes("img src") ? (
    <slot />
  ) : (
    <p>
      <slot />
    </p>
  )
}
```

The whole thing is cheap, fast to write in, and mine.

---

## Related

- [Revisiting Obsidian as a CMS](https://iammatthias.com/posts/1699332127006-revisiting-obsidian-as-a-cms.md): A home-built publishing system using Obsidian for writing, GitHub for storage, and Next.js for rendering.
- [Obsidian as a CMS](https://iammatthias.com/posts/1670659200001-obsidian-as-a-cms.md): Obsidian as a CMS using structured frontmatter, GitHub sync, Cloudflare R2 for assets, and Next.js for rendering.
- [Farfield](https://iammatthias.com/posts/1779066375000-farfield.md): Five small Go services, single binaries, content-addressed records, running on my homelab. The new backend for the site.
- [Obsidian AI Tagger](https://iammatthias.com/open-source/1744172769110-obsidian-ai-tagger.md): An Obsidian plugin that generates consistent tags and writes them directly to a note's frontmatter.
- [Obsidian AI Excerpt Generator](https://iammatthias.com/open-source/1744172798323-obsidian-ai-excerpt-generator.md): An Obsidian plugin that generates concise excerpts and writes them directly to a note's frontmatter.
