Back
Obsidian

From Hashnode to Obsidian: Turning Obsidian Into the Source of Truth for My Blog

May 23, 2026·8 min read·19 views
From Hashnode to Obsidian: Turning Obsidian Into the Source of Truth for My Blog

On May 13, 2026, Hashnode announced that their GraphQL API endpoints (read and write) would be moving to a paid offering. The reason: abuse of their free API by scammers, scrapers, and bots. As someone whose main reason for writing was to put concepts into my own words, document the projects I built, and share my research, this news stung. I have been publishing on Hashnode since 2021.

For the foreseeable future, I will keep paying for the read API to continue showing my blogs on my personal website, but I decided to find an alternative to write and store my articles. I looked at Notion first, but it felt like overkill. I just want to pour thoughts onto a page. I do not need databases or kanban boards. So I explored lighter options: Google Keep, Apple Notes, and eventually landed on Obsidian: Markdown, lightweight, feature-rich, and Git-friendly.

Note: This is my first article written and published entirely through Obsidian.


The Obsidian Experiment

I searched for how to use Obsidian as the source of truth for my articles, and these are the steps I took:

  1. Defined the content pipeline. I created a dedicated folder for notes that will be published, Blogs - Published/, and set up conventions: each file gets frontmatter with title, date, slug, tags, and optionally cover image and description at the top.
---
title: Your Article Title
description: A short description (used for SEO and previews)
date: 2026-05-22
slug: your-article-slug
tags: [tag1, tag2]
image: /images/cover.jpg
---
  1. Rendering choice. Two options: build-time (most common and performant, content is pre-rendered at deploy) or runtime (fetched on demand per request). I went with build-time via @nuxt/content since my portfolio is statically generated.
  2. Portfolio stack. My portfolio is built in Nuxt 4 with Tailwind CSS. I needed markdown support with easy GitHub integration and landed on @nuxt/content, Nuxt's official content module with built-in markdown parsing, syntax highlighting, and type-safe queries.
  3. GitHub as the bridge. I installed the Obsidian Git community plugin and configured it to sync my vault to a private GitHub repository. This gives me automatic version-controlled cloud sync from inside Obsidian, and a URL my portfolio can pull from at build time.
  4. Separation of concerns. I keep the vault set up for personal note-taking (daily notes, ideas, fleeting notes) while only the Blogs - Published/ folder is consumed by the site. Promoting a note to the published set is as simple as moving it to that folder.

I piloted the experiment by using this very blog post as the test case:

  • Started with one folder and one post on the site.
  • Then added a listing page and individual post pages.
  • Then layered in metadata, images, and syntax highlighting.

Note: I am not migrating my existing Hashnode articles just yet.


How I Did It in Nuxt

Step 1: Install and configure @nuxt/content

yarn add @nuxt/content

Add the module to nuxt.config.ts:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@nuxt/content"],
  content: {
    build: {
      markdown: {
        highlight: {
          theme: {
            default: "vitesse-light", // light mode
            dark: "vitesse-dark",     // dark mode
          },
        },
      },
    },
  },
});

Step 2: Configure the blog collection

Create content.config.ts at the project root. The natural first instinct is to point it directly at your GitHub repository:

import { defineContentConfig, defineCollection } from '@nuxt/content'

export default defineContentConfig({
  collections: {
    blog: defineCollection({
      type: 'page',
      source: {
        repository: {
          // Replace with real URL or use process.env for security e.g. process.env.OBSIDIAN_VAULT_REPO_URL.
          // Also, best to create a specific fine-grained contents personal access token for this.
          url: 'https://<token>@github.com/yourusername/your-obsidian-vault.git',
          branch: 'main',
        },
        include: 'Blogs - Published/**',
      },
    }),
  },
})

This works, but with a painful catch. @nuxt/content uses isomorphic-git and always performs a full shallow clone of the repository, regardless of the include glob. If your Obsidian vault is large (attachments, PDFs, daily notes), the entire thing gets cloned on every build and every nuxt dev restart. The include filter only controls which files get indexed, not what gets downloaded.

Step 3: The sync script: GitHub Contents API instead of git clone

The solution is to bypass @nuxt/content's repository source entirely and write a small Node.js script that fetches only the markdown files using the GitHub Contents API:

// scripts/sync-blog.mjs
import { mkdir, writeFile, rm, readFile } from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const OUT_DIR = join(ROOT, "content", "blog");

// ... (env loading, token extraction from OBSIDIAN_VAULT_REPO_URL)

// Fetch directory listing, download each .md file, write to content/blog/
const items = await githubRequest("Blogs - Published");
const mdFiles = items.filter((i) => i.type === "file" && i.name.endsWith(".md"));
await Promise.all(mdFiles.map(item => downloadMarkdown(item.path, join(OUT_DIR, item.name), imageIndex)));

The content.config.ts then reads from the local folder instead:

export default defineContentConfig({
  collections: {
    blog: defineCollection({
      type: "page",
      source: "blog/**", // local content/blog/ directory
      schema: z.object({
        slug: z.string().optional(),
        date: z.string().optional(),
        tags: z.array(z.string()).optional(),
        image: z.string().optional(),
        readingTime: z.number().optional(),
      }),
    }),
  },
});

Wire it into your package.json scripts. Note that dev no longer includes the sync: Nuxt Content's file watcher already picks up changes to content/blog/*.md live without a server restart. Run yarn sync separately whenever you want to pull new posts from GitHub, and the dev server reloads automatically.

{
  "scripts": {
    "dev": "nuxt dev",
    "sync": "node scripts/sync-blog.mjs",
    "build": "node scripts/sync-blog.mjs && nuxt build",
    "generate": "node scripts/sync-blog.mjs && nuxt generate"
  }
}

Step 4: Blog listing and post pages

Query the collection in your blog listing component:

// composables/useBlogPosts.ts
export function useNativePosts() {
  return useAsyncData("obsidian-posts", () =>
    queryCollection("blog").order("date", "DESC").all(),
  );
}

Create app/pages/blog/[slug].vue for individual post pages:

<script setup lang="ts">
const route = useRoute();
const slug = route.params.slug as string;

const { data: post } = await useAsyncData(`blog-${slug}`, async () => {
  const bySlug = await queryCollection("blog").where("slug", "=", slug).first();
  if (bySlug) return bySlug;
  return queryCollection("blog").where("stem", "LIKE", `%/${slug}`).first();
});

if (!post.value) throw createError({ statusCode: 404 });
</script>

<template>
  <article class="prose prose-zinc dark:prose-invert max-w-none">
    <ContentRenderer :value="post" />
  </article>
</template>

Step 5: View counts and reading time

To show how many people have read each post, add a Nitro server route backed by unstorage:

yarn add unstorage
// server/api/views/[slug].ts
export default defineEventHandler(async (event) => {
  const slug = getRouterParam(event, "slug")!;
  const storage = useStorage("views");
  const key = `view:${slug}`;

  if (event.method === "POST") {
    const current = (await storage.getItem<number>(key)) ?? 0;
    await storage.setItem(key, current + 1);
    return { views: current + 1 };
  }

  return { views: (await storage.getItem<number>(key)) ?? 0 };
});

Configure the storage driver in nuxt.config.ts. The filesystem driver works locally; swap to Vercel KV in production so view counts survive redeployments:

nitro: {
  storage: {
    views: process.env.VERCEL
      ? { driver: "vercel-kv" }               // persistent in production
      : { driver: "fs", base: "./.data/views" }, // local dev
  },
},

A composable handles the client side. It fetches the current count on page load (SSR-compatible so the number is in the initial HTML), then records the visit inside onMounted. Using sessionStorage prevents a page refresh from inflating the counter:

// composables/usePostViews.ts
export function usePostViews(slug: string) {
  const { data, refresh } = useFetch<{ views: number }>(`/api/views/${slug}`);
  const views = computed(() => data.value?.views ?? 0);

  async function recordView() {
    if (sessionStorage.getItem(`viewed:${slug}`)) return;
    sessionStorage.setItem(`viewed:${slug}`, "1");
    await $fetch(`/api/views/${slug}`, { method: "POST" });
    await refresh();
  }

  return { views, recordView };
}

Reading time is calculated at sync time rather than at render time. That is important: post.body in @nuxt/content is a parsed AST object, not a plain string. Calling .toString() on it gives [object Object], which produces garbage word counts. The sync script strips code blocks and markdown symbols from the body, counts words at 200 wpm, and injects the result into the frontmatter:

function estimateReadingTime(body) {
  const text = body
    .replace(/```[\s\S]*?```/g, "")
    .replace(/`[^`]+`/g, "")
    .replace(/!\[[^\]]*\]\([^)]+\)/g, "")
    .replace(/[#*_>~|[\]]/g, "")
    .trim();
  return Math.max(1, Math.ceil(text.split(/\s+/).filter(Boolean).length / 200));
}

For the blog listing card, a separate GET /api/views endpoint returns all slug counts at once. The listing component fetches it without blocking the initial render, so posts appear immediately and view counts fill in reactively:

const { data: viewCounts } = useFetch<Record<string, number>>("/api/views");

const normalizedObsidian = computed(() =>
  (nativePosts.value ?? []).map((post) => {
    const normalized = normalizeObsidianPost(post);
    return { ...normalized, views: viewCounts.value?.[normalized.slug] };
  }),
);

Step 6: Deploying to Vercel and keeping posts in sync automatically

The build script already handles the sync: when Vercel builds your portfolio, it runs node scripts/sync-blog.mjs && nuxt build, which fetches all posts from GitHub before building.

The remaining problem is triggering a new Vercel build whenever you publish a post. Since the Obsidian vault lives in a separate repository, Vercel does not watch it. The solution is a Vercel Deploy Hook wired to a GitHub Action in the vault repo.

Set it up once:

  1. Vercel dashboard, your project, Settings, Git, Deploy Hooks. Create a hook named "Obsidian sync" on branch main. Copy the URL.
  2. In your obsidian-vault repo, Settings, Secrets, Actions. Add a secret VERCEL_DEPLOY_HOOK with the URL from step 1.
  3. Add a workflow file at .github/workflows/sync-portfolio.yml in your obsidian-vault repo:
name: Sync portfolio on blog publish

on:
  push:
    paths:
      - "Blogs - Published/**"

jobs:
  trigger-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Trigger Vercel rebuild
        run: curl -s -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"

After that the flow is fully automatic: write a post in Obsidian, push via the Obsidian Git plugin, the GitHub Action fires, Vercel rebuilds, the sync script fetches the new post, the site goes live. No manual steps.

For persistent view counts across deployments, set up a Vercel KV store: Vercel dashboard, Storage, Create, KV (powered by Upstash). Connect it to your project and Vercel automatically injects the required environment variables. The nuxt.config.ts already switches to the vercel-kv driver when process.env.VERCEL is set.


Long-term Maintainability: The Parts Nobody Warns You About

The steps above get you a working blog. What follows are the rough edges you will hit in production and how to handle them.

1. Obsidian image syntax is not standard markdown

Obsidian uses wiki-link syntax for image embeds:

![](/api/blog-image?path=Blogs%20-%20Published%2Fimages%2F01-obsidian-blog%2Fobsidian-blog-cover.png)

This means: embed the file named obsidian-blog-cover.png, displayed at 527px wide. Standard markdown parsers, including the remark-based one inside @nuxt/content, do not understand this syntax. They either skip it or produce broken output.

The fix is a two-step transform in the sync script, applied to each file's raw content before writing it locally:

// Step 1: Obsidian wiki-link to standard markdown
// ![](/api/blog-image?path=Blogs%20-%20Published%2Fimages%2Fimage.png) becomes ![](/api/blog-image?path=Blogs%20-%20Published%2Fimages%2Fimage.png)
// ![alt](/api/blog-image?path=Blogs%20-%20Published%2Fimages%2Fimage.png) becomes ![alt](/api/blog-image?path=Blogs%20-%20Published%2Fimages%2Fimage.png)
function transformObsidianImages(markdown) {
  return markdown.replace(
    /!\[\[([^\]|]+?)(?:\|([^\]|]*))?(?:\|[^\]]*)?\]\]/g,
    (_, file, maybeAlt) => {
      if (!IMAGE_EXT.test(file.trim())) return ""; // drop non-image embeds
      const alt = maybeAlt && !/^\d+$/.test(maybeAlt.trim()) ? maybeAlt.trim() : "";
      return `![${alt}](/api/blog-image?path=Blogs%20-%20Published%2Fimages%2F%24%7Bfile.trim()})`;
    }
  );
}

// Step 2: relative image path to absolute proxy URL
// ![](/api/blog-image?path=Blogs%20-%20Published%2Fimages%2Fcover.png) becomes ![](/api/blog-image?path=Blogs%20-%20Published%2Fimages%2Fcover.png)
function rewriteImageUrls(markdown, imageIndex) {
  return markdown.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
    if (/^(https?:)?\/\//.test(src) || src.startsWith("/")) return _;
    const clean = src.replace(/^\.\//, "");
    const fullPath = !clean.includes("/")
      ? imageIndex.get(clean) ?? `Blogs - Published/images/${clean}`
      : clean.startsWith("images/") ? `Blogs - Published/${clean}` : `Blogs - Published/images/${clean}`;
    return `![${alt}](/api/blog-image?path=${encodeURIComponent(fullPath)})`;
  });
}

If your images are organised into per-post subfolders:

Blogs - Published/
  images/
    obsidian-blog/
      cover.png  <- actual location

Obsidian's wiki-link still only stores ![](/api/blog-image?path=Blogs%20-%20Published%2Fimages%2Fcover.png) with no path. The sync script has no way to know which subfolder the file lives in from the filename alone.

The fix is to fetch the full git tree once at the start of the sync (in parallel with the post listing) and build a filename-to-full-repo-path index:

async function buildImageIndex() {
  const { tree } = await fetch(
    `https://api.github.com/repos/${OWNER}/${REPO}/git/trees/${BRANCH}?recursive=1`,
    { headers: HEADERS }
  ).then(r => r.json());

  const index = new Map();
  for (const item of tree) {
    if (item.type === "blob" && item.path.startsWith("Blogs - Published/images/") && IMAGE_EXT.test(item.path)) {
      const filename = item.path.split("/").pop();
      if (!index.has(filename)) index.set(filename, item.path);
    }
  }
  return index;
}

Then pass imageIndex into rewriteImageUrls and look up bare filenames:

const fullPath = imageIndex.get(bareFilename) ?? `Blogs - Published/images/${bareFilename}`;

Both the tree fetch and the post listing run in parallel via Promise.all, so there is no extra round-trip cost.

3. Private repo images need a server-side proxy

raw.githubusercontent.com only serves public repos without authentication. For a private vault, every image needs to be fetched server-side with your GitHub token and streamed back to the browser. A Nitro server route handles this:

// server/api/blog-image.get.ts
export default defineEventHandler(async (event) => {
  const { path } = getQuery(event) as { path?: string };
  const pat = extractTokenFromEnv();

  const response = await fetch(
    `https://api.github.com/repos/${OWNER}/${REPO}/contents/${encodedPath}?ref=main`,
    {
      headers: {
        Authorization: `Bearer ${pat}`,
        Accept: "application/vnd.github.v3.raw", // returns raw binary, not JSON
        "X-GitHub-Api-Version": "2022-11-28",
      },
    }
  );

  // GitHub returns application/octet-stream, so derive the MIME type from the extension
  const mimeMap = { png: "image/png", jpg: "image/jpeg", webp: "image/webp" };
  setHeader(event, "Content-Type", mimeMap[ext] ?? "image/png");
  setHeader(event, "Cache-Control", "public, max-age=86400");
  return Buffer.from(await response.arrayBuffer());
});

Two things to watch: use Accept: application/vnd.github.v3.raw rather than application/vnd.github+json, and always derive the Content-Type from the file extension. GitHub's API returns application/octet-stream regardless of the image type, and browsers will not render an <img> with that MIME type.

4. Frontmatter must start at byte 0

@nuxt/content's MDC parser requires the opening --- of the YAML frontmatter to be on the very first line of the file, with no whitespace or blank lines before it. Obsidian sometimes writes a leading newline before the ---, and some text editors add a BOM. When that happens, the frontmatter is silently ignored and @nuxt/content generates the title field by transforming the filename, turning My Blog Post - Something.md into My Blog Post Something (dash stripped, each word title-cased).

Fix: call .trimStart() on the raw file content before writing it to disk.

const transformed = rewriteImageUrls(transformObsidianImages(raw.trimStart()), imageIndex);

5. Embedded --- in the article body confuses the parser

If you show a frontmatter example inside your article (as this post does), the --- delimiters in the body can trick some parsers into treating that section as a second frontmatter block. Wrap examples in fenced code blocks (```markdown) in Obsidian to prevent this.

6. Syntax highlighting needs manual CSS for class-based dark mode

@nuxt/content uses Shiki for syntax highlighting and supports dual themes. The configuration is simple:

// nuxt.config.ts
content: {
  build: {
    markdown: {
      highlight: {
        theme: { default: "vitesse-light", dark: "vitesse-dark" },
      },
    },
  },
},

However, Shiki outputs theme colors as CSS variables in inline styles (--shiki-default, --shiki-dark, etc.) and does not inject the CSS selectors that activate them. When using class-based dark mode (via @nuxtjs/color-mode with classSuffix: ""), you need to add those selectors yourself:

/* app/assets/css/main.css */
.shiki { background-color: var(--shiki-default-bg) !important; }
.shiki span {
  color: var(--shiki-default) !important;
  font-style: var(--shiki-default-font-style) !important;
  font-weight: var(--shiki-default-font-weight) !important;
}
.dark .shiki { background-color: var(--shiki-dark-bg) !important; }
.dark .shiki span {
  color: var(--shiki-dark) !important;
  font-style: var(--shiki-dark-font-style) !important;
  font-weight: var(--shiki-dark-font-weight) !important;
}

7. Nuxt layouts: blog posts need a scrollable container

If your portfolio home page uses h-screen overflow-hidden to create a fixed-height viewport layout (common for single-page portfolio designs), all child pages rendered via <NuxtPage> inherit that constraint, including your blog post pages, which silently clips content below the fold.

The fix is Nuxt layouts. Move the h-screen overflow-hidden wrapper into a dedicated home layout, create a plain scrollable default layout for everything else, and declare definePageMeta({ layout: 'home' }) only on your index page.

app/layouts/
  home.vue     <- h-screen overflow-hidden (portfolio home page)
  default.vue  <- plain <slot /> (blog posts, other pages)

@nuxt/content injects <a> anchor links inside every heading for section linking. Tailwind Typography's prose class applies text-decoration: underline to all anchors inside .prose, which makes every heading appear underlined.

/* app/assets/css/main.css */
.prose :is(h1, h2, h3, h4, h5, h6) a {
  text-decoration: none;
  color: inherit;
  font-weight: inherit;
}

Quick Momentum Tip

Start with a very small dedicated folder in your Obsidian vault, something like published/ or Blogs - Published/. Move two or three existing notes there with proper frontmatter and get them showing on your site. That small working example will reveal the real next steps faster than any amount of planning. Then layer in images, syntax highlighting, and the rest one piece at a time.


Closing Thoughts

This setup is not for everyone. If you want something running in an afternoon, reach for Ghost, Hashnode, or even a plain GitHub Pages site. But if you already live in Obsidian, want full ownership of your content, and enjoy the occasional yak shave, this pipeline is genuinely satisfying to use. You write a note, push a commit, and your site rebuilds. No editor UI, no platform dependency, no surprises.

The rough edges documented above took time to find. Hopefully this saves you some of that.