@nostr-dev-kit/svelte
Modern, performant, and beautiful Svelte 5 integration for NDK
A ground-up reimagining of NDK for Svelte 5, built with runes, designed for beauty and performance.
🔴 Critical API Note for AI Assistants
The $subscribe() method takes a CALLBACK FUNCTION, not direct config:
// ✅ CORRECT - callback function returning config
const notes = ndk.$subscribe(() => ({
filters: [{ kinds: [1], limit: 50 }]
}));
// ❌ WRONG - direct config (this API doesn't exist)
const notes = ndk.$subscribe({ kinds: [1], limit: 50 });
const notes = ndk.$subscribe([{ kinds: [1] }]);This enables reactive filters and conditional subscriptions. See examples in svelte/templates/basic and svelte/examples/feed-viewer for correct usage.
Philosophy
svelte embraces Svelte 5's reactive primitives to create a library that feels native, performs beautifully, and makes building Nostr apps a joy.
- Runes-first: Reactive classes using
$state,$derived, and$effect - Namespaced stores: All stores under single NDK instance
- Zero compromises: No backwards compatibility, pure Svelte 5
- Performance-focused: Buffered updates, smart deduplication
- TypeScript-native: Full type safety and inference
- Aesthetic APIs: Beautiful code that reads like poetry
Status
Current Version: 0.1.0 Status: Beta - Core features implemented and tested
✅ Implemented Features
- Core Subscriptions: Reactive EventSubscription with automatic cleanup
- Namespaced Stores: Sessions, WoT, wallet, payments, and pool
- Wallet Integration: Full support for Cashu, NWC, and WebLN wallets
- Payment Tracking: Reactive payment/transaction tracking with pending state management
- Nutzap Monitoring: Automatic nutzap detection and redemption
- Test Coverage: 119 tests passing across all core functionality
🚧 Coming Soon
- Component library (UserAvatar, UserName, etc.)
- Additional examples
- Performance optimizations
- Documentation site
Installation
pnpm add @nostr-dev-kit/svelteSetup
Initialize NDK in your app:
// lib/ndk.ts
import { NDKSvelte } from '@nostr-dev-kit/svelte';
import NDKCacheDexie from '@nostr-dev-kit/cache-dexie';
export const ndk = new NDKSvelte({
explicitRelayUrls: [
'wss://relay.damus.io',
'wss://relay.nostr.band',
],
cacheAdapter: new NDKCacheDexie({ dbName: 'my-app' })
});
ndk.connect();Session Persistence: Sessions are automatically persisted to localStorage by default. Users stay logged in across page reloads.
Core Concepts
1. Reactive Subscriptions
The heart of svelte is the $subscribe() method - a reactive, self-managing subscription.
IMPORTANT: $subscribe() takes a callback function that returns your config. This enables reactive filters and conditional subscriptions.
<script lang="ts">
import { ndk } from '$lib/ndk';
import { NDKKind } from '@nostr-dev-kit/ndk';
// Create a reactive subscription - note the callback function () => ({ ... })
const notes = ndk.$subscribe(() => ({
filters: [{ kinds: [NDKKind.Text], limit: 50 }]
}));
// Properties are $state runes that automatically trigger reactivity
// when accessed in Svelte templates or $effect blocks
$inspect(notes.events); // Array of events (reactive)
$inspect(notes.eosed); // EOSE flag (reactive)
$inspect(notes.count); // Event count (derived)
</script>
{#each notes.events as note}
<article>{note.content}</article>
{/each}
{#if notes.isEmpty}
<span>No notes yet</span>
{/if}Automatic reactivity: The events, eosed, error, status, and refCount properties are $state runes that automatically trigger reactivity when events arrive and the UI accesses them.
Automatic cleanup: The subscription stops when the component unmounts. No manual cleanup needed.
2. Namespaced Stores
All stores are namespaced under the NDK instance:
<script lang="ts">
import { ndk } from '$lib/ndk';
// Session management
const currentUser = ndk.$sessions.current;
await ndk.$sessions.login(signer);
ndk.$sessions.logout();
// Web of Trust
await ndk.$wot.load();
const score = ndk.$wot.getScore(pubkey);
// Wallet
ndk.$wallet.set(myWallet);
const balance = ndk.$wallet.balance;
// Payments
const amount = ndk.$payments.getZapAmount(event);
// Pool
const connected = ndk.pool.connectedCount;
</script>Subscription API
⚠️ Documentation Update Notice: Many examples in this README show outdated syntax (without callback functions). The correct API requires a callback:
ndk.$subscribe(() => ({ filters: [...] })). See the updated examples above and in the template projects for correct usage.
Basic Usage
<script lang="ts">
import { ndk } from '$lib/ndk';
const sub = ndk.$subscribe(() => ({
filters: [{ kinds: [1], authors: [pubkey], limit: 100 }]
}));
// The subscription has reactive properties
sub.events // T[] - sorted by created_at desc
sub.eosed // boolean
sub.count // number (derived)
sub.isEmpty // boolean (derived)
</script>With Options
<script lang="ts">
const highlights = ndk.$subscribe(
{ kinds: [9802], limit: 50 },
{
// Buffer events for performance (default: 30ms)
bufferMs: 30,
// Convert events to specific class
eventClass: NDKHighlight,
// Relay set
relaySet: myRelaySet,
// Callbacks
onEvent: (event) => console.log('New event', event),
onEose: () => console.log('EOSE reached'),
}
);
</script>Manual Control
<script lang="ts">
const sub = ndk.$subscribe([filters], { autoStart: false });
// Manual control
sub.start();
sub.stop();
sub.restart();
sub.clear(); // Clear events and restart
// Change filters
sub.changeFilters([newFilters]);
// Add/remove individual events
sub.add(event);
sub.remove(eventId);
</script>Session Management
Built-in multi-user session support with automatic persistence:
<script lang="ts">
import { ndk } from '$lib/ndk';
import { NDKNip07Signer } from '@nostr-dev-kit/ndk';
// Current session (reactive) - wrap getters in $derived() to make them reactive
const current = $derived(ndk.$currentSession);
const currentUser = $derived(ndk.$currentUser);
const profile = ndk.$fetchProfile(() => currentUser?.pubkey);
const follows = $derived(ndk.$sessions.follows);
async function login() {
const signer = new NDKNip07Signer();
await ndk.$sessions.login(signer);
}
function logout() {
ndk.$sessions.logout();
}
</script>
{#if current}
<div>
<p>Logged in as {profile?.name || 'Anonymous'}</p>
<p>Following {follows.size} accounts</p>
<button onclick={logout}>Logout</button>
</div>
{:else}
<button onclick={login}>Login</button>
{/if}Session API
// Reactive getters
ndk.$sessions.current // NDKSession | undefined
ndk.$sessions.currentUser // NDKUser | undefined
ndk.$sessions.follows // Set<Hexpubkey>
ndk.$sessions.mutes // Map<string, string>
ndk.$sessions.mutedWords // Set<string>
ndk.$sessions.blockedRelays // Set<string>
ndk.$sessions.relayList // Map<string, { read: boolean; write: boolean }>
ndk.$sessions.all // NDKSession[]
// Methods
await ndk.$sessions.login(signer, options?) // options: { setActive?: boolean }
await ndk.$sessions.add(signer)
await ndk.$sessions.switch(pubkey)
ndk.$sessions.logout(pubkey?)
ndk.$sessions.logoutAll()
ndk.$sessions.get(pubkey)
ndk.$sessions.getSessionEvent(kind)
ndk.$sessions.walletEvent // Get NIP-60 wallet event
// Login options
{
setActive?: boolean // Set as active session (default: true)
}
// Note: What to fetch (follows, mutes, wallet, etc.) is configured once
// at the NDKSvelte level, not per-login. All sessions fetch the same data.Automatic Wallet Loading
Sessions automatically fetch and load wallets (configured at the NDKSvelte level):
- Fetches the user's NIP-60 wallet event (kind 17375)
- Instantiates an
NDKCashuWalletfrom the wallet event - Starts wallet monitoring
- Sets it on
ndk.$walletso you can immediately accessndk.$wallet.balance
<script lang="ts">
import { ndk } from '$lib/ndk';
// Login - wallet loads automatically
await ndk.$sessions.login(signer);
// Wallet is now ready to use
const balance = $derived(ndk.$wallet.balance);
</script>
<p>Balance: {balance} sats</p>When you switch sessions or logout, the wallet updates automatically based on the active session's wallet.
Web of Trust
Powerful WoT filtering and ranking:
<script lang="ts">
import { ndk } from '$lib/ndk';
import { onMount } from 'svelte';
onMount(async () => {
if (ndk.$sessions.current) {
// Load WoT data
await ndk.$wot.load({ maxDepth: 2 });
// Enable automatic filtering on all subscriptions
ndk.$wot.enableAutoFilter({
maxDepth: 2,
minScore: 0.5,
includeUnknown: false
});
}
});
// Subscriptions automatically filter by WoT when enabled
const notes = ndk.$subscribe({ kinds: [1], limit: 100 });
</script>WoT API
// Load WoT data
await ndk.$wot.load({ maxDepth?: number, maxFollows?: number, timeout?: number })
// Enable/disable automatic filtering
ndk.$wot.enableAutoFilter(options?)
ndk.$wot.disableAutoFilter()
// Query WoT
ndk.$wot.getScore(pubkey) // number (0-1)
ndk.$wot.getDistance(pubkey) // number | null
ndk.$wot.includes(pubkey, options?) // boolean
ndk.$wot.shouldFilterEvent(event) // boolean
ndk.$wot.rankEvents(events, options?) // T[]
// State
ndk.$wot.loaded // boolean
ndk.$wot.autoFilterEnabled // booleanMute Management
Mute management is handled directly by NDK core:
<script lang="ts">
import { ndk } from '$lib/ndk';
// Mute lists are automatically loaded when user logs in
// Check if muted
const isMuted = ndk.mutedIds.has(pubkey);
const isWordMuted = ndk.muteFilter(event);
// Mute/unmute
ndk.mutedIds.set(pubkey, "p");
ndk.mutedIds.delete(pubkey);
ndk.mutedWords.add("spam");
ndk.mutedWords.delete("spam");
</script>Performance Note: Word filtering only runs on content kinds (1, 30023, 4, 1059, 30009, 1311, and kinds 1000-9999). Non-content events are not checked for muted words for performance.
Wallet Integration
Seamless integration with ndk-wallet:
<script lang="ts">
import { ndk } from '$lib/ndk';
import { NDKCashuWallet } from '@nostr-dev-kit/ndk-wallet';
// Create and set wallet
const cashuWallet = new NDKCashuWallet(ndk);
await cashuWallet.init();
ndk.$wallet.set(cashuWallet);
// Reactive wallet state
const balance = $derived(ndk.$wallet.balance);
const connected = $derived(!!ndk.$wallet.wallet);
</script>
<p>Balance: {balance} sats</p>Wallet API
// Save wallet configuration (creates or updates)
// Publishes both the wallet config (kind 17375) and mint list (kind 10019) for nutzap reception
await ndk.$wallet.save({
mints: ['https://mint.example.com'],
relays: ['wss://relay.example.com']
})
// Set/clear wallet
ndk.$wallet.set(wallet)
ndk.$wallet.clear()
await ndk.$wallet.refreshBalance()
// State
ndk.$wallet.balance // number
ndk.$wallet.mints // string[] - configured mint URLs
ndk.$wallet.mintBalances // Mint[] - mints with balances (including 0 balance)
ndk.$wallet.relays // string[]Payment Tracking
Real-time payment tracking with automatic pending-to-confirmed transitions:
<script lang="ts">
import { ndk } from '$lib/ndk';
// Reactive payment state
const history = $derived(ndk.$payments.history);
const pending = $derived(ndk.$payments.pending);
const byTarget = $derived(ndk.$payments.byTarget);
// Check zap status
const amount = ndk.$payments.getZapAmount(event);
const isZapped = ndk.$payments.isZapped(event);
</script>
{#if isZapped}
<span>⚡ Zapped {amount} sats</span>
{/if}Payments API
// Query payments
ndk.$payments.getZapAmount(target) // number
ndk.$payments.isZapped(target) // boolean
// State
ndk.$payments.history // Transaction[]
ndk.$payments.pending // PendingPayment[]
ndk.$payments.byTarget // Map<string, Transaction[]>Relay Pool Monitoring
Monitor relay connections:
<script lang="ts">
import { ndk } from '$lib/ndk';
const connected = $derived(ndk.pool.connectedCount);
const connecting = $derived(ndk.pool.connectingCount);
const relays = $derived(ndk.pool.getConnectedRelays());
</script>
<p>Connected: {connected} | Connecting: {connecting}</p>
<ul>
{#each relays as relay}
<li>{relay.url}</li>
{/each}
</ul>Pool API
// Query relays
ndk.pool.getRelay(url) // RelayInfo | undefined
ndk.pool.getConnectedRelays() // RelayInfo[]
// State
ndk.pool.relays // Map<string, RelayInfo>
ndk.pool.connectedCount // number
ndk.pool.connectingCount // numberAdvanced Patterns
Derived Subscriptions
Create derived reactive state from subscriptions:
<script lang="ts">
const notes = ndk.$subscribe({ kinds: [1], authors: [pubkey] });
// Derived state using $derived
const recentNotes = $derived(
notes.events.slice(0, 10)
);
const notesByDay = $derived(
notes.events.reduce((acc, note) => {
const day = new Date(note.created_at! * 1000).toDateString();
(acc[day] ??= []).push(note);
return acc;
}, {} as Record<string, NDKEvent[]>)
);
</script>Effect Hooks
Run side effects when subscription state changes:
<script lang="ts">
const notes = ndk.$subscribe({ kinds: [1] });
// Run effect when new events arrive
$effect(() => {
if (notes.events.length > 0) {
console.log('New events:', notes.events.length);
playNotificationSound();
}
});
// Run once when EOSE is reached
$effect(() => {
if (notes.eosed) {
console.log('Initial load complete');
}
});
</script>Proper Use of EOSE
The eosed flag is for performance optimization and analytics, not loading states:
<script lang="ts">
const notes = ndk.$subscribe({ kinds: [1] });
// ✅ Good: Trigger pagination after initial load
$effect(() => {
if (notes.eosed && notes.count < 10) {
notes.fetchMore(20);
}
});
// ✅ Good: Performance analytics
$effect(() => {
if (notes.eosed) {
console.log(`Loaded ${notes.count} events`);
}
});
// ❌ Bad: Blocking UI
// {#if !notes.eosed}<Spinner />{/if}
</script>
<!-- Just render events as they stream in -->
{#each notes.events as note}
<Note {note} />
{/each}Performance
Buffered Updates
By default, events are buffered for 30ms to batch DOM updates:
<script lang="ts">
// High-frequency updates (default)
const sub1 = ndk.$subscribe(filters, {
bufferMs: 30 // Batch updates every 30ms
});
// Real-time updates (no buffering)
const sub2 = ndk.$subscribe(filters, {
bufferMs: false // Update immediately
});
</script>Smart Deduplication
Events are automatically deduplicated using NDK's deduplication keys:
<script lang="ts">
// Duplicate events are automatically filtered
const sub = ndk.$subscribe([
{ kinds: [1], authors: [pubkey] },
{ kinds: [1], '#p': [pubkey] }
]);
// Only unique events appear in sub.events
// Replaceable events are automatically replaced with newer versions
</script>Type Safety
Full TypeScript support with smart type inference:
import { ndk } from '$lib/ndk';
import { NDKHighlight } from '@nostr-dev-kit/ndk';
// Type is inferred as EventSubscription<NDKHighlight>
const highlights = ndk.$subscribe<NDKHighlight>(
{ kinds: [9802] },
{ eventClass: NDKHighlight }
);
// highlights.events is NDKHighlight[]
highlights.events[0].highlightedContent; // Type-safeMigration from ndk-svelte
<!-- Old (ndk-svelte) -->
<script lang="ts">
import { onDestroy } from 'svelte';
const store = $ndk.storeSubscribe({ kinds: [1] });
onDestroy(() => {
store.unsubscribe();
});
</script>
{#each $store as event}
{event.content}
{/each}
<!-- New (svelte) -->
<script lang="ts">
const sub = ndk.$subscribe({ kinds: [1] });
// No manual cleanup needed
</script>
{#each sub.events as event}
{event.content}
{/each}Architecture
Class Hierarchy
NDKSvelte (extends NDK)
├── $subscribe() → Subscription<T>
├── $sessions → ReactiveSessionsStore
├── $wot → ReactiveWoTStore
├── $wallet → ReactiveWalletStore
├── $payments → ReactivePaymentsStore
└── $pool → ReactivePoolStore
Subscription<T>
├── events: T[] (reactive)
├── eosed: boolean (reactive)
├── count: number (derived)
├── isEmpty: boolean (derived)
├── start(), stop(), restart()
└── changeFilters(), clear()Reactive Fetching
Fetch Single Event
<script lang="ts">
import { ndk } from '$lib/ndk';
// By bech32 ID (most common)
const eventId = $derived($page.params.id);
const event = ndk.$fetchEvent(() => eventId); // "note1..." or "nevent1..."
// By filter
const latestNote = ndk.$fetchEvent(() => ({
kinds: [1],
authors: [pubkey],
limit: 1
}));
// Conditional fetch
const event = ndk.$fetchEvent(() => {
if (!shouldFetch || !eventId) return undefined;
return eventId;
});
</script>
{#if event}
<article>{event.content}</article>
{/if}Fetch Multiple Events
<script lang="ts">
import { ndk } from '$lib/ndk';
const pubkey = $state('hex...');
const notes = ndk.$fetchEvents(() => ({
kinds: [1],
authors: [pubkey],
limit: 20
}));
// Multiple filters
const events = ndk.$fetchEvents(() => [
{ kinds: [1], authors: [pubkey1], limit: 10 },
{ kinds: [1], authors: [pubkey2], limit: 10 }
]);
</script>
{#each notes as note}
<article>{note.content}</article>
{/each}Examples
See the examples directory for complete working examples:
- Basic Feed - Simple note feed with profiles ✅
- Nutsack - NIP-60 Cashu wallet with payment tracking ✅
- Fetch Event Demo - Event fetching patterns ✅
Coming Soon
- Multi-user App - Account switching and management
- Real-time Chat - Messaging with DMs
- Advanced Patterns - Complex reactive patterns
API Reference
NDKSvelte
class NDKSvelte extends NDK {
// Reactive stores ($ prefix indicates reactive state)
$sessions: ReactiveSessionsStore;
$wot: ReactiveWoTStore;
$wallet: ReactiveWalletStore;
$payments: ReactivePaymentsStore;
$pool: ReactivePoolStore;
// Reactive subscription
$subscribe<T extends NDKEvent>(
config: () => SubscribeConfig | undefined
): Subscription<T>;
// Reactive fetching
$fetchUser(identifier: () => string | undefined): NDKUser | undefined;
$fetchProfile(pubkey: () => string | undefined): NDKUserProfile | undefined;
$fetchEvent(idOrFilter: () => string | NDKFilter | NDKFilter[] | undefined): NDKEvent | undefined;
$fetchEvents(filters: () => NDKFilter | NDKFilter[] | undefined): NDKEvent[];
}Subscription
class Subscription<T extends NDKEvent> {
// Reactive $state properties
events: T[];
eosed: boolean;
error?: Error;
status: ConnectionStatus;
refCount: number;
// Derived getters
count: number;
isEmpty: boolean;
// Filter property
filters: NDKFilter[];
// Methods
start(): void;
stop(): void;
restart(): void;
clear(): void;
changeFilters(filters: NDKFilter[]): void;
fetchMore(limit: number): Promise<void>;
add(event: T): void;
remove(eventId: string): void;
ref(): number;
unref(): number;
}SubscriptionOptions
interface SubscriptionOptions {
bufferMs?: number | false;
skipDeleted?: boolean;
eventClass?: typeof NDKEvent;
relaySet?: NDKRelaySet;
autoStart?: boolean;
onEvent?: (event: NDKEvent, relay?: NDKRelay) => void;
onEose?: () => void;
}Troubleshooting
Reactivity Not Working?
Make sure you're accessing properties in Svelte templates or reactive contexts:
<script lang="ts">
const sub = ndk.$subscribe({ kinds: [1] });
// ✅ Good - accessed in template, automatically reactive
</script>
{#each sub.events as event}
<div>{event.content}</div>
{/each}
<!-- ✅ Good - accessed in effect -->
<script lang="ts">
$effect(() => {
console.log('Events updated:', sub.events.length);
});
</script>Key Points About Svelte 5 Reactivity
$statevariables are reactive, but you must access them in reactive contexts for Svelte to track changes- Simply reading a value in regular JavaScript doesn't create a reactive dependency
- Always access subscription properties in templates,
$effect, or$derivedto ensure reactivity - Arrays are mutated in place - The subscription internally uses
.length = 0and.push()to mutate the events array rather than replacing it
Philosophy & Design Decisions
Why Runes?
Svelte 5's runes provide fine-grained reactivity that's perfect for real-time data. Instead of stores everywhere, we use reactive classes that feel natural in Svelte 5.
Why Not Backwards Compatible?
Breaking free from legacy patterns lets us build something truly modern. svelte is designed for new projects and future-looking apps.
Why Namespaced Stores?
Namespacing stores under the NDK instance prevents global pollution and makes the API clearer. Everything related to NDK is accessible through a single import.
Why Beautiful APIs?
Code is read more than written. Beautiful, intuitive APIs make building Nostr apps a joy, not a chore.
Contributing
We're building the best Nostr library for Svelte. Join us!
License
MIT
Credits
Built with ❤️ by the Nostr Dev Kit team.