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
# Install pnpm if you haven'tnpm install -g pnpmStep 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:
# Clone the Fuwari templatenpx degit saicaca/fuwari my-blogcd my-blogpnpm installStep 2: Installing Dependencies
For the 3D graph, you’ll need to install the required packages:
pnpm add 3d-force-graph threepnpm add -D @types/threeThe @types/three package provides TypeScript definitions for Three.js, which is essential for proper type checking.
Step 3: Adding Wiki-Link Support
Obsidian uses [[double brackets]] for internal links. We need to tell Astro how to handle these.
Install the remark plugin:
pnpm add remark-wiki-linkUpdate 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!
Step 7: Adding Backlinks
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.tsexport function pathsEqual(path1: string, path2: string): boolean { // ...}
export function url(path: string): string { // ...}
// In content-utils.tsexport 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 buildStep 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};Writing Posts with Wiki-Links
Now write your posts in src/content/posts/:
---title: My Second Postpublished: 2026-03-01description: Another interconnected post.tags: [Notes]category: Tutorialdraft: 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 postwhen viewing those linked posts.Deployment
Since this is a static site, deployment is straightforward:
- Push to GitHub
- Import project in Vercel (or Netlify, Cloudflare Pages)
- Set build command:
pnpm build - Set output directory:
dist - 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! 🚀