I recently deployed my fully decentralized content pipeline to kheai.com (you can check out the source code at github.com/kafechew/nostr-seo). After rigorously testing the architecture, I wanted to document exactly how this system works. We are not just building a blog; we are engineering an evergreen, censorship-resistant Web3-to-Web2 bridge.
⚡ Nostr-SEO: The Decentralized Content Bridge
Nostr-SEO is a high-performance, SEO-first bridge that transforms decentralized Nostr Long-form content (Kind 30023) into a lightning-fast, search-engine-optimized website.
Built on top of the refined Astro-Paper template, this project allows creators to use Nostr as a decentralized CMS while maintaining total ownership of their web presence and search engine rankings.
📖 The Problem & Solution
The Problem
Decentralized protocols like Nostr are amazing for censorship resistance, but their content is often "invisible" to traditional search engines. If you publish only on relays, you lose the benefits of SEO (Search Engine Optimization) and AEO (Answer Engine Optimization) for AI agents like Perplexity and ChatGPT.
The Solution
nostr-seo acts as a Static Site Generator (SSG) for the Nostr protocol.
- Fetch: It pulls your articles from decentralized relays at build-time.
- Process: It cleans the Markdown, renders complex Math (LaTeX) and Diagrams (Mermaid).
- Deploy: It…
In this tutorial, you will build an automated pipeline that fetches your long-form articles from decentralized Nostr clients (like Yakihonne or Primal), converts them into a blazing-fast static website using Astro, and hosts it for free on Vercel.
Zero prior Astro experience is required. If you follow these steps, you will go from an empty folder to a live, SEO-optimized prototype.
The Architecture Vision
Before writing code, you must understand the data flow. We are shifting from a "push" model (saving files to a server) to a "pull" model (compiling decentralized data at build time).
graph TD
A[Author on Yakihonne/Primal] -->|Publishes Kind 30023| B(Nostr Relays)
C[GitHub Actions Cron] -->|Triggers Build every 6h| D(Vercel Edge Network)
D -->|Fetches Data| B
D -->|Compiles HTML/CSS| E[Your Live Website]
E --> F[Human Readers]
E --> G[AI Agents via llm.txt]
The Tech Stack
| Component | Tool | Purpose |
|---|---|---|
| Network | Nostr Protocol | Decentralized content storage and syndication. |
| Framework | Astro (AstroPaper) | High-speed static site generation and routing. |
| Processor | Unified Ecosystem | Parsing Markdown, KaTeX (math), and Mermaid (diagrams). |
| Hosting | Vercel | Edge delivery and automated webhooks. |
Phase 0: The Digital Workbench
You need a few standard tools installed before we begin.
- Download and install the LTS (Long Term Support) version of Node.js from
nodejs.org. - Install Git from
git-scm.com. - Download Visual Studio Code, the industry-standard code editor.
- Create free accounts on GitHub and Vercel.
- Convert your Nostr
npubto a Hexadecimal key using a tool likedamus.io/key. Keep this Hex key safe; our code needs it to identify you on the network.
Phase 1: Local Setup & Installation
Open your terminal. We will use the AstroPaper template as our foundation.
Run the following command to download the template into a new folder named nostr-seo:
npm create astro@latest -- --template satnaing/astro-paper nostr-seo
Navigate into your new project:
cd nostr-seo
Now, we must install our communication tools (NDK for Nostr) and our processing engines (Unified for Markdown/Math). Run this command exactly as written:
npm install @nostr-dev-kit/ndk ws nostr-tools remark-math rehype-katex rehype-stringify remark-rehype remark-parse unified --legacy-peer-deps
Note: The --legacy-peer-deps flag prevents npm from crashing if the Astro template's default formatting tools have strict version conflicts with our new parser.
The MacOS/Linux WebAssembly Patch
If you are on an older OS, the build engine (esbuild) might fail. Open your package.json file and append these properties to force Node.js to bypass your core OS framework and use WebAssembly instead:
{
"devDependencies": {
"esbuild-wasm": "0.28.0"
},
"overrides": {
"esbuild": "npm:esbuild-wasm@0.28.0"
}
}
Open the project in your editor by typing code . in your terminal.
Phase 2: Core Engineering & Data Pipelines
Because we are fetching posts from Nostr relays rather than local .md files, we must create a custom processing pipeline to handle rich text, math equations, and metadata.
Step 1: The Markdown Processor
Create a new file at src/utils/parseMarkdown.ts. This utility converts raw Nostr strings into safe, styled HTML.
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkMath from "remark-math";
import remarkRehype from "remark-rehype";
import rehypeKatex from "rehype-katex";
import rehypeStringify from "rehype-stringify";
export async function parseNostrMarkdown(content: string) {
const result = await unified()
.use(remarkParse)
.use(remarkMath)
.use(remarkRehype)
.use(rehypeKatex)
.use(rehypeStringify)
.process(content);
return result.toString();
}
Step 2: The Event Formatter
Create a new file at src/utils/formatNostrEvent.ts. This extracts SEO descriptions and generates proper naddr links for cross-platform Web3 sharing.
import { nip19 } from "nostr-tools";
export function formatNostrEvent(event: any) {
const findTag = (key: string) => event.tags.find((t: any) => t[0] === key)?.[1];
const summary = findTag("summary");
const fallbackDesc = event.content
.replace(/!\[.*?\]\(.*?\)/g, "")
.replace(/\[(.*?)\]\(.*?\)/g, "$1")
.replace(/(?:__|[*#`\[\]()\-+!=])/g, "")
.substring(0, 160)
.trim() + "...";
const dTag = findTag("d") || "";
let naddr = "";
try {
naddr = nip19.naddrEncode({
kind: 30023,
pubkey: event.pubkey,
identifier: dTag,
});
} catch (e) {
console.error("Naddr error", e);
}
return {
id: naddr || event.id,
title: findTag("title") || "Untitled",
description: summary || fallbackDesc,
pubDatetime: new Date(event.created_at * 1000),
modDatetime: null,
author: findTag("author") || "Nostr User",
tags: event.tags.filter((t: any) => t[0] === "t").map((t: any) => t[1]),
ogImage: findTag("image"),
content: event.content,
naddr: naddr
};
}
Phase 3: Building the Interface
We will hijack AstroPaper's default routing to dynamically pull your Kind 30023 events at build time.
Step 1: The Homepage (index.astro)
Navigate to src/pages/index.astro. Delete everything and paste the code below. Replace the Hex string with your own public key.
---
import Layout from "@/layouts/Layout.astro";
import Header from "@/components/Header.astro";
import Footer from "@/components/Footer.astro";
import NDK, { type NDKEvent } from "@nostr-dev-kit/ndk";
import 'ws';
const ndk = new NDK({
explicitRelayUrls: [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net"
]
});
try {
await ndk.connect(3000);
} catch (err) {
console.error("Nostr Connection failed:", err);
}
// REMEMBER TO INSERT YOUR HEX KEY BELOW
const filter = {
kinds: [30023],
authors: ["YOUR_HEX_PUBLIC_KEY_HERE"]
};
let events: Set<NDKEvent> = new Set();
try {
const fetchPromise = ndk.fetchEvents(filter, { closeOnEose: true });
const timeoutPromise = new Promise<Set<NDKEvent>>((resolve) => {
setTimeout(() => resolve(new Set()), 4000);
});
events = await Promise.race([fetchPromise, timeoutPromise]);
} catch (err) {
console.error("Fetch error:", err);
}
const posts = Array.from(events)
.filter((e): e is NDKEvent & { created_at: number } => e.created_at !== undefined)
.sort((a, b) => b.created_at - a.created_at)
.map(e => {
const cleanContent = e.content.replace(/[#*`_~\[\]\(\)-]/g, "").replace(/\s+/g, " ").trim();
return {
id: e.id,
title: e.tags.find((t: string[]) => t[0] === 'title')?.[1] || "Untitled Post",
summary: e.tags.find((t: string[]) => t[0] === 'summary')?.[1] || (cleanContent.substring(0, 120) + "..."),
date: new Date(e.created_at * 1000).toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric'
})
};
});
---
<Layout title="My Decentralized Mind-Dump">
<Header />
<main id="main-content" class="app-layout">
<section id="hero" class="border-border border-b pt-8 pb-6">
<h1 class="my-4 inline-block text-4xl font-bold sm:my-8 sm:text-5xl">My Nostr Mind-Dump</h1>
<p>Pulled dynamically from the Nostr protocol at build time. Write on Web3, read everywhere.</p>
</section>
<section id="recent-posts" class="pt-12 pb-6">
<h2 class="text-2xl font-semibold tracking-wide mb-6">Recent Notes</h2>
<ul>
{posts.map(post => (
<li class="my-6">
<a href={`/nostr/${post.id}`} class="inline-block text-xl font-bold text-skin-accent decoration-dashed underline-offset-4 hover:underline">
{post.title}
</a>
<div class="text-sm opacity-80 mt-1">{post.date}</div>
<p class="mt-2 line-clamp-3">{post.summary}</p>
</li>
))}
</ul>
</section>
</main>
<Footer />
</Layout>
Step 2: The Article Reader Route
Create a new folder named nostr inside src/pages/. Inside that folder, create [id].astro. This generates dynamic paths for every article.
---
import Layout from "@/layouts/Layout.astro";
import Header from "@/components/Header.astro";
import Footer from "@/components/Footer.astro";
import NDK, { type NDKEvent } from "@nostr-dev-kit/ndk";
import 'ws';
import { parseNostrMarkdown } from "@/utils/parseMarkdown";
import { formatNostrEvent } from "@/utils/formatNostrEvent";
export async function getStaticPaths() {
const ndk = new NDK({ explicitRelayUrls: ["wss://relay.damus.io", "wss://nos.lol"] });
await ndk.connect(3000);
let events: Set<NDKEvent> = new Set();
// REMEMBER TO INSERT YOUR HEX KEY BELOW
const filter = { kinds: [30023], authors: ["YOUR_HEX_PUBLIC_KEY_HERE"] };
const fetchPromise = ndk.fetchEvents(filter, { closeOnEose: true });
const timeoutPromise = new Promise<Set<NDKEvent>>((resolve) => setTimeout(() => resolve(new Set()), 4000));
events = await Promise.race([fetchPromise, timeoutPromise]);
return Array.from(events)
.filter((e): e is NDKEvent & { created_at: number } => e.created_at !== undefined)
.map(e => ({
params: { id: e.id },
props: { event: e }
}));
}
interface Props { event: NDKEvent; }
const { event } = Astro.props;
const post = formatNostrEvent(event);
const htmlContent = await parseNostrMarkdown(post.content);
---
<Layout title={post.title}>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" crossorigin="anonymous" />
<Header />
<main id="main-content" class="mx-auto w-full max-w-3xl px-4 pb-12 mt-8">
<button class="focus-outline mb-6 flex hover:opacity-75" onclick="history.back()">
<span>← Go back</span>
</button>
<h1 class="text-3xl font-bold text-skin-accent">{post.title}</h1>
{post.naddr && (
<div class="mt-6 flex flex-wrap items-center gap-3 text-sm bg-skin-card/10 p-4 rounded-lg border border-skin-line/50">
<span class="font-bold">⚡ Nostr:</span>
<a href={`nostr:${post.naddr}`} class="text-skin-accent hover:underline">Open in App</a>
<a href={`https://yakihonne.com/article/${post.naddr}`} target="_blank" class="hover:text-skin-accent opacity-80">Yakihonne</a>
</div>
)}
<article class="prose max-w-none prose-img:border-0 mt-8" set:html={htmlContent} />
</main>
<Footer />
</Layout>
<script>
const renderMermaid = async () => {
const codeBlocks = document.querySelectorAll("pre code.language-mermaid");
if (codeBlocks.length === 0) return;
const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs");
mermaid.initialize({ startOnLoad: false, theme: "dark" });
for (const element of codeBlocks) {
const id = `mermaid-${Math.random().toString(36).substr(2, 9)}`;
const { svg } = await mermaid.render(id, element.textContent || "");
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-container my-8 flex justify-center overflow-x-auto';
wrapper.innerHTML = svg;
element.parentElement?.replaceWith(wrapper);
}
};
renderMermaid();
document.addEventListener("astro:after-swap", renderMermaid);
</script>
Step 3: Global Styling and Contrast Fixes
Add these definitions to src/styles/global.css to fix dark mode contrast, ensure math blocks scroll gracefully on mobile, and apply proper borders to user-uploaded images.
/* Improve contrast for Dark Mode */
html[data-theme="dark"] .prose {
--tw-prose-body: theme("colors.slate.200");
--tw-prose-headings: theme("colors.white");
--tw-prose-bold: theme("colors.white");
}
/* KaTeX Math Styling */
.katex-display {
@apply my-8 overflow-x-auto py-4 px-2 rounded-lg;
background-color: rgba(255, 255, 255, 0.03);
}
.katex {
white-space: nowrap;
}
/* Nostr Image Attachments */
article img {
@apply rounded-xl border shadow-md mx-auto my-8;
border-color: var(--color-line);
}
Start your local server by running npm run dev and opening http://localhost:4321. Your site should now dynamically render your Nostr notes.
Phase 4: AI-Native SEO (llm.txt)
As AI agents like Perplexity and SearchGPT replace traditional search engines, we must provide a semantic layer.
Create a file named llm.txt inside your public/ folder. Write a direct, markdown-formatted summary of your site's core philosophy and architecture. When an AI scrapes yourwebsite.com/llm.txt, it immediately contextualizes your niche with zero token waste.
Phase 5: Deployment & The Zero-Maintenance Pipeline
It is time to put your site on the public internet and automate the build process.
- Push your code to a new public GitHub repository.
- Log into Vercel, click Add New Project, import your repository, and click Deploy. Vercel will grant you a live URL.
The Automation Webhook
Right now, your site is static. If you publish a new article on Nostr, your site won't update until Vercel builds again.
- In your Vercel Dashboard, navigate to Settings -> Git -> Deploy Hooks.
- Create a hook named
Nostr-Syncon themainbranch and copy the generated URL. - In your GitHub repository, go to the Actions tab and create a new workflow named
nostr-sync.yml. - Paste the following configuration, replacing the URL with your unique Vercel hook:
name: Nostr Auto-Sync
on:
schedule:
- cron: '0 */6 * * *'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Trigger Vercel Build
run: curl -X POST "https://api.vercel.com/v1/integrations/deploy/YOUR_UNIQUE_VERCEL_HOOK_URL"
Commit the changes.
You have successfully engineered an evergreen architecture. You write natively on Nostr, retaining absolute ownership of your cryptographic keys. Every 6 hours, GitHub Actions silently signals Vercel. Vercel scrapes the relays, compiles your latest thoughts into high-speed HTML, and serves it globally. Write once, syndicate everywhere.
Top comments (0)