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-Sync on the main branch 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.