2480 words
12 minutes

Building an Obsidian-Style Knowledge Graph in 3D

2026-03-03
đź’« Neutron Star

In this tutorial, I’ll walk you through how I built this blog - a personal knowledge base that combines Obsidian-style wiki-links with an interactive 3D knowledge graph. If you’re an Obsidian user who wants to publish your notes as a blog while keeping your linking workflow, this guide is for you.

The Vision#

As an Obsidian user, I love how my notes are interconnected through wiki-links. I wanted a blog that:

  • Uses the same [[wiki-link]] syntax I use in Obsidian
  • Displays an interactive 3D graph of my posts (like Obsidian’s graph view)
  • Has a clean, minimal design with light/dark mode
  • Supports backlinks (showing which posts link to the current one)
  • Features a “sun” node representing the author at the center
  • Doesn’t require React or complex frameworks

The Stack#

After researching, I chose:

  • Astro 5 - Fast, static site generator with excellent markdown support
  • Fuwari - A beautiful, minimal Astro theme with built-in dark mode
  • 3d-force-graph - WebGL-based 3D force-directed graph library
  • Three.js - 3D graphics library (dependency of 3d-force-graph)
  • remark-wiki-link - Plugin to parse [[wiki-links]] in markdown

Prerequisites#

Make sure you have:

  • Node.js 22+ (LTS recommended)
  • pnpm as your package manager
Terminal window
# Install pnpm if you haven't
npm install -g pnpm

Step 1: Setting Up Fuwari#

Fuwari is an elegant Astro theme that provides:

  • Light/dark mode toggle with system preference detection
  • Markdown/MDX support with syntax highlighting
  • Responsive design
  • Search functionality (Pagefind)
  • Clean, minimal aesthetic

To get started:

Terminal window
# Clone the Fuwari template
npx degit saicaca/fuwari my-blog
cd my-blog
pnpm install

Step 2: Installing Dependencies#

For the 3D graph, you’ll need to install the required packages:

Terminal window
pnpm add 3d-force-graph three
pnpm add -D @types/three

The @types/three package provides TypeScript definitions for Three.js, which is essential for proper type checking.

Obsidian uses [[double brackets]] for internal links. We need to tell Astro how to handle these.

Install the remark plugin:

Terminal window
pnpm add remark-wiki-link

Update your astro.config.mjs:

import remarkWikiLink from "remark-wiki-link";
export default defineConfig({
// ... other config
markdown: {
remarkPlugins: [
// ... other plugins
[
remarkWikiLink,
{
hrefTemplate: permalink => `/posts/${permalink}/`,
aliasDivider: "|",
},
],
],
},
});

Now you can write links in your posts like:

Check out [[my-other-post]] for more details.
Or use [[my-other-post|custom text]] for alias links.

Step 4: Building the Graph Data Endpoint#

We need a JSON endpoint that provides the graph data (nodes and links) based on our posts. The graph features a “sun” node representing the author at the center, with posts orbiting around it like stars.

Create src/pages/graph.json.ts:

import { getCollection } from "astro:content";
import { profileConfig } from "@/config";
interface GraphNode {
id: string;
name: string;
val: number;
isSun?: boolean;
}
interface GraphLink {
source: string;
target: string;
}
interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
export async function GET(): Promise<Response> {
const posts = await getCollection("posts", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
const nodes: GraphNode[] = [];
const links: GraphLink[] = [];
// Add the "sun" node - representing the author
nodes.push({
id: "__sun__",
name: profileConfig.name,
val: 3,
isSun: true,
});
const slugSet = new Set(posts.map(p => p.slug));
posts.forEach(post => {
nodes.push({
id: post.slug,
name: post.data.title,
val: 1,
});
// Parse wiki-links from post body
const wikiLinkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
const content = post.body || "";
let match: RegExpExecArray | null = wikiLinkRegex.exec(content);
while (match !== null) {
const linkedSlug = match[1].trim();
if (slugSet.has(linkedSlug)) {
links.push({
source: post.slug,
target: linkedSlug,
});
}
match = wikiLinkRegex.exec(content);
}
});
const graphData: GraphData = { nodes, links };
return new Response(JSON.stringify(graphData), {
headers: {
"Content-Type": "application/json",
},
});
}

This endpoint:

  • Fetches all published posts
  • Creates a “sun” node for the author (from profile config)
  • Creates a node for each post
  • Parses wiki-links and creates edges between linked posts
  • Returns JSON that the 3D graph can consume

Step 5: Creating the 3D Graph Component#

The graph component is more complex than a basic example - it implements:

  • A starfield theme with twinkling stars
  • A glowing sun node for the author
  • Smooth camera animation on load
  • Proper TypeScript types for everything
  • Memory cleanup for SPA navigation (Swup)

Create src/components/Graph.astro:

---
import { profileConfig } from "@/config";
import { url } from "@/utils/url-utils";
import ImageWrapper from "./misc/ImageWrapper.astro";
interface Props {
fullscreen?: boolean;
animationDuration?: number;
}
const { fullscreen = false, animationDuration = 3000 } = Astro.props;
const profile = profileConfig;
---
<div
id="graph-container"
data-animation-duration={animationDuration}
class:list={[
"rounded-xl overflow-hidden",
fullscreen ? "fixed inset-0 z-40" : "w-full h-full",
]}
>
</div>
<!-- Tooltip template for the sun/author node -->
<div id="sun-tooltip-template" class="hidden">
<div class="card-base p-3 max-w-[200px]">
<a
aria-label="Go to About Page"
href={url("/about/")}
class="block relative mx-auto mb-3 max-w-[8rem]"
>
<ImageWrapper
src={profile.avatar || ""}
alt="Profile Image"
class="mx-auto w-full rounded-xl"
/>
</a>
<div class="px-1">
<div
class="font-bold text-sm text-center mb-1 dark:text-neutral-50"
>
{profile.name}
</div>
<div
class="h-0.5 w-4 bg-[var(--primary)] mx-auto rounded-full mb-2"
>
</div>
<div class="text-center text-xs text-neutral-400 mb-2">
{profile.quotes?.[0] || ""}
</div>
</div>
</div>
</div>
<script>
// Define TypeScript interfaces for type safety
interface GraphNode {
id: string;
name: string;
val?: number;
isSun?: boolean;
fx?: number;
fy?: number;
fz?: number;
}
interface GraphData {
nodes: GraphNode[];
links: { source: string; target: string }[];
}
interface GraphInstance {
_destructor: () => void;
cameraPosition: (pos: { x: number; y: number; z: number }) => void;
d3Force: (name: string) => { strength: (s: number) => void };
onNodeHover: (fn: (node: GraphNode | null) => void) => void;
onNodeClick: (fn: (node: GraphNode) => void) => void;
graphData: (data: GraphData) => void;
nodeLabel: (label: string) => void;
backgroundColor: (color: string) => void;
nodeThreeObject: (fn: (node: GraphNode) => object) => void;
linkColor: (fn: () => string) => void;
linkWidth: (w: number) => void;
linkDirectionalParticles: (n: number) => void;
}
interface WindowWithGraph extends Window {
__graphInstance?: GraphInstance;
__graphMouseHandler?: (e: MouseEvent) => void;
}
async function initGraph() {
// Cleanup previous instance if exists (for SPA navigation)
const win = window as WindowWithGraph;
if (win.__graphInstance) {
win.__graphInstance._destructor();
delete win.__graphInstance;
}
if (win.__graphMouseHandler) {
window.removeEventListener("mousemove", win.__graphMouseHandler);
delete win.__graphMouseHandler;
}
document.querySelectorAll("#graph-tooltip").forEach(el => el.remove());
const container = document.getElementById("graph-container");
if (!container) return;
const animationDuration = parseInt(
container.dataset.animationDuration || "3000",
10
);
const response = await fetch("/graph.json");
const graphData: GraphData = await response.json();
if (!graphData.nodes || graphData.nodes.length === 0) {
container.innerHTML =
'<div class="flex items-center justify-center h-full text-gray-500">No posts yet. Add some markdown files to see the graph.</div>';
return;
}
const isDark = document.documentElement.classList.contains("dark");
const bgColor = isDark ? "rgba(0,0,0,0.05)" : "rgba(255,255,255,0.02)";
const elem = document.createElement("div");
elem.style.width = "100%";
elem.style.height = "100%";
container.appendChild(elem);
const ForceGraph3D = (await import("3d-force-graph")).default;
const THREE = await import("three");
// Star colors for twinkling effect
const starColors = [
"#ffffff",
"#f0f0ff",
"#fff8e7",
"#e8f4ff",
"#fff0f5",
];
// Generate star glow texture
function createStarTexture(color: string, size = 64) {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const gradient = ctx.createRadialGradient(
size / 2,
size / 2,
0,
size / 2,
size / 2,
size / 2
);
gradient.addColorStop(0, color);
gradient.addColorStop(0.2, color);
gradient.addColorStop(
0.5,
color.replace(")", ", 0.5)").replace("rgb", "rgba")
);
gradient.addColorStop(1, "rgba(0, 0, 0, 0)");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
return new THREE.CanvasTexture(canvas);
}
// Generate sun texture
function createSunTexture() {
const size = 128;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const gradient = ctx.createRadialGradient(
size / 2,
size / 2,
0,
size / 2,
size / 2,
size / 2
);
gradient.addColorStop(0, "#ffffff");
gradient.addColorStop(0.2, "#fff5e6");
gradient.addColorStop(0.5, "#ffe4c4");
gradient.addColorStop(1, "rgba(255, 165, 0, 0)");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
return new THREE.CanvasTexture(canvas);
}
const starTextures = starColors.map(color => createStarTexture(color));
const sunTexture = createSunTexture();
// Create the 3D graph
// @ts-expect-error - 3d-force-graph doesn't have proper types
const graph = ForceGraph3D()(elem)
.graphData(graphData)
.nodeLabel("")
.backgroundColor(bgColor)
.nodeThreeObject((node: GraphNode) => {
// Sun node (author)
if (node.isSun) {
const material = new THREE.SpriteMaterial({
map: sunTexture,
transparent: true,
opacity: 1,
depthWrite: false,
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(50, 50, 1);
// Animate sun glow
const originalOnBeforeRender =
sprite.onBeforeRender.bind(sprite);
sprite.onBeforeRender = function (
renderer,
scene,
camera,
geometry,
material,
group
) {
const time = Date.now() * 0.001;
material.opacity = 0.85 + 0.15 * Math.sin(time * 0.5);
originalOnBeforeRender?.call(
this,
renderer,
scene,
camera,
geometry,
material,
group
);
};
return sprite;
}
// Regular post nodes (stars)
const colorIndex = Math.floor(
Math.random() * starColors.length
);
const baseScale = 8 + Math.random() * 12;
const scale = (node.val || 1) * baseScale;
const twinklePhase = Math.random() * Math.PI * 2;
const twinkleSpeed = 1 + Math.random() * 2;
const material = new THREE.SpriteMaterial({
map: starTextures[colorIndex],
transparent: true,
opacity: 0.9,
depthWrite: false,
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(scale, scale, 1);
// Animate twinkling
const originalOnBeforeRender =
sprite.onBeforeRender.bind(sprite);
sprite.onBeforeRender = function (
renderer,
scene,
camera,
geometry,
material,
group
) {
const time = Date.now() * 0.001;
material.opacity =
0.6 +
0.4 * Math.sin(time * twinkleSpeed + twinklePhase);
originalOnBeforeRender?.call(
this,
renderer,
scene,
camera,
geometry,
material,
group
);
};
return sprite;
})
.linkColor(() =>
isDark ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)"
)
.linkWidth(1)
.linkDirectionalParticles(0)
.onNodeClick((node: GraphNode) => {
if (node.isSun) {
window.location.href = "/about/";
} else {
window.location.href = `/posts/${node.id}/`;
}
});
// Tooltip handling
let tooltip: HTMLDivElement | null = null;
let lastMouseX = 0;
let lastMouseY = 0;
win.__graphInstance = graph;
const mouseHandler = (e: MouseEvent) => {
lastMouseX = e.clientX;
lastMouseY = e.clientY;
};
win.__graphMouseHandler = mouseHandler;
window.addEventListener("mousemove", mouseHandler);
graph.onNodeHover((node: GraphNode | null) => {
if (tooltip) {
tooltip.remove();
tooltip = null;
}
if (node) {
const template = document.getElementById(
"sun-tooltip-template"
) as HTMLDivElement;
if (node.isSun && template) {
tooltip = template.cloneNode(true) as HTMLDivElement;
tooltip.id = "graph-tooltip";
tooltip.classList.remove("hidden");
} else {
tooltip = document.createElement("div");
tooltip.id = "graph-tooltip";
tooltip.textContent = node.name;
}
tooltip.style.cssText = `
position: fixed;
background: ${isDark ? "#333" : "#fff"};
color: ${isDark ? "#fff" : "#000"};
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
pointer-events: none;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
top: ${lastMouseY + 10}px;
left: ${lastMouseX + 10}px;
`;
document.body.appendChild(tooltip);
}
});
// Configure physics
graph.d3Force("charge").strength(-80);
graph.d3Force("center").strength(0.15);
// Fix sun at center
const sunNode = graphData.nodes.find((n: GraphNode) => n.isSun);
if (sunNode) {
sunNode.fx = 0;
sunNode.fy = 0;
sunNode.fz = 0;
}
// Dynamic camera distance based on node count
const nodeCount = graphData.nodes.length;
const restingDistance = 40 * Math.sqrt(nodeCount) * 2 + 20;
const initialDistance = restingDistance * 3;
let currentDistance = initialDistance;
graph.cameraPosition({
x: initialDistance,
y: initialDistance,
z: initialDistance,
});
// Animate camera to resting position
function animateToDistance(targetDistance: number, duration: number) {
const startTime = Date.now();
const startDistance = currentDistance;
function tick() {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
currentDistance =
startDistance + (targetDistance - startDistance) * eased;
graph.cameraPosition({
x: currentDistance,
y: currentDistance,
z: currentDistance,
});
if (progress < 1) requestAnimationFrame(tick);
}
tick();
}
animateToDistance(restingDistance, animationDuration);
// Handle window resize
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
window.addEventListener("resize", () => {
if (resizeTimeout) clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
const newDistance = 40 * Math.sqrt(nodeCount) * 2 + 20;
animateToDistance(newDistance, animationDuration / 2);
}, 250);
});
}
initGraph();
</script>

This component:

  • Fetches graph data from our endpoint
  • Renders twinkling stars as post nodes
  • Shows a glowing sun node for the author
  • Supports click-to-navigate to posts
  • Shows tooltips on hover (custom for sun, simple for posts)
  • Adapts colors to light/dark mode
  • Handles SPA navigation cleanup (for Swup)
  • Animates camera on load

Step 6: Integrating Graph into the Banner#

I modified the Fuwari theme to show the graph in the banner area (where normally a hero image would be).

Update src/layouts/MainGridLayout.astro:

---
import Graph from "@components/Graph.astro";
// ... other imports
---
<!-- Replace the ImageWrapper with Graph -->{
siteConfig.banner.enable && (
<div
id="banner-wrapper"
class="absolute z-10 w-full transition duration-700 overflow-hidden"
>
<Graph />
</div>
)
}

Set the banner enabled in src/config.ts:

banner: {
enable: true, // Changed from false
// ...
},

Now the graph appears on all pages when banner.enable is true!

Backlinks show which posts link TO the current post - super useful for knowledge management.

Create src/components/Backlinks.astro:

---
import { getCollection } from "astro:content";
import { getPostUrlBySlug } from "@utils/url-utils";
interface Props {
slug: string;
}
const { slug } = Astro.props;
async function getBacklinks(targetSlug: string) {
const posts = await getCollection("posts", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
const wikiLinkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
const backlinks = posts.filter(post => {
const content = post.body || "";
const matches = content.match(wikiLinkRegex);
if (!matches) return false;
return matches.some(match => {
const linkedSlug = match
.replace(/\[\[|\]\]/g, "")
.split("|")[0]
.trim();
return linkedSlug === targetSlug;
});
});
return backlinks;
}
const backlinks = await getBacklinks(slug);
---
{
backlinks.length > 0 && (
<div class="mt-8 pt-6 border-t border-[var(--line-divider)]">
<h3 class="text-lg font-semibold mb-4 text-[var(--text)]">
Backlinks
</h3>
<ul class="space-y-2">
{backlinks.map(post => (
<li>
<a
href={getPostUrlBySlug(post.slug)}
class="text-[var(--primary)] hover:underline"
>
{post.data.title}
</a>
</li>
))}
</ul>
</div>
)
}

Add it to your post layout (src/pages/posts/[...slug].astro).

Step 8: TypeScript Configuration#

For strict type checking with isolatedDeclarations, ensure your functions have explicit return types. Key fixes needed:

// In url-utils.ts
export function pathsEqual(path1: string, path2: string): boolean {
// ...
}
export function url(path: string): string {
// ...
}
// In content-utils.ts
export async function getSortedPosts(): Promise<CollectionEntry<"posts">[]> {
// ...
}
// In config.ts (content collection)
const postsCollection: ReturnType<typeof defineCollection> = defineCollection({
// ...
});

Step 9: GitHub Actions CI/CD#

Set up continuous integration to catch issues early. Create .github/workflows/build.yml:

name: Build and Check
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
name: Astro Check
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.14.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Astro Check
run: pnpm check
build:
runs-on: ubuntu-latest
name: Astro Build
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.14.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Build
run: pnpm build

Step 10: Profile Configuration#

Configure your profile in src/config.ts for the sun node to display correctly:

export const profileConfig = {
avatar: "/avatar.png",
name: "Your Name",
quotes: ["Your quote or tagline here"],
// ... other config
};

Now write your posts in src/content/posts/:

---
title: My Second Post
published: 2026-03-01
description: Another interconnected post.
tags: [Notes]
category: Tutorial
draft: false
---
This post links to [[my-first-post]].
I can also link to other posts like [[hello-world-story-of-my-first-website]].
The backlinks component will automatically show this post
when viewing those linked posts.

Deployment#

Since this is a static site, deployment is straightforward:

  1. Push to GitHub
  2. Import project in Vercel (or Netlify, Cloudflare Pages)
  3. Set build command: pnpm build
  4. Set output directory: dist
  5. Deploy!

The build automatically runs astro build && pagefind for search indexing.

What’s Next?#

Some ideas for expansion:

  • Local graph view - A dedicated page showing a larger, more interactive graph
  • Graph filtering - Filter by tags or categories
  • Obsidian sync - Automate syncing from your Obsidian vault
  • Custom node styling - Color nodes by category or tag
  • Search integration - Click nodes in graph to filter search results

Conclusion#

Building an Obsidian-style blog with a 3D knowledge graph is a powerful way to publish your interconnected notes. The combination of Astro, Fuwari, and 3d-force-graph provides an excellent foundation for a personal knowledge base that feels native to the way you already write.

The best part? You write in Obsidian using your familiar workflow, and your blog automatically reflects the rich connections between your ideas - visualized as a beautiful starfield with you at the center.

Happy writing! 🚀

Building an Obsidian-Style Knowledge Graph in 3D
https://scribblingsofaseeker.com/blog/obsidian-style-blog-with-3d-graph/
Author
Ganesh Umashankar
Published at
2026-03-03
License
CC BY-NC-SA 4.0