Lists
NIP-51 enables users to create categorized, labeled lists (bookmarks, mutes, pins, blocks) using replaceable events, supporting both public and encrypted lists.
NIP-51: Lists
Status: Draft Authors: fiatjaf, arcbtc, mikedilger, arthurfranca Category: Application
Overview
NIP-51 defines how Nostr users can create and manage categorized, labeled lists for organizing content, people, and preferences across the network.
Core Concept:
- Users create lists for bookmarks, mutes, pins, follows, and more
- Lists use replaceable events (kind 30000-30001)
- Lists can be public or encrypted
- Cross-client synchronization of preferences
List Types:
- ✅ Mute lists: Hide content from specific users
- ✅ Pin lists: Featured content on profiles
- ✅ Bookmark lists: Save posts for later
- ✅ Communities: Grouped interests
- ✅ Public/Encrypted: Control visibility
- ✅ Categorized: Organize with labels
Status: Draft (widely adopted across major clients)
Why Lists Matter
Organization & Personalization
Without lists:
- No way to save posts across clients
- Muted users don’t sync between apps
- Pinned content client-specific
- Manual reorganization everywhere
With lists:
- Synchronized preferences across all clients
- Organized content collections
- Persistent user choices
- Cross-platform consistency
Use Cases
- Content Curation: Save interesting posts for later reading
- User Management: Mute spammers, block harassers (synced everywhere)
- Featured Content: Pin important posts to your profile
- Interest Groups: Organize follows by topic
- Reading Lists: Collect articles, threads for later
- Community Building: Curated lists of recommended accounts
How It Works
List Event Structure
NIP-51 uses parameterized replaceable events (kind 30000-30001):
{
"kind": 30000, // Categorized people lists
"content": "", // Optional encrypted content
"tags": [
["d", "mute"], // List identifier (mute, pin, bookmark)
["p", "<pubkey>", "<relay-url>", "<petname>"],
["p", "<pubkey2>"]
],
"created_at": 1234567890,
"pubkey": "<user-pubkey>"
}
Event Kinds:
- 30000: Categorized people lists (follows, mutes, blocks)
- 30001: Categorized bookmark sets (saved posts, articles)
List Types
1. Mute List (d tag: “mute”)
Hide content from specific users:
import { getPublicKey, signEvent } from 'nostr-tools';
function createMuteList(mutedPubkeys, privateKey) {
const muteList = {
kind: 30000,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: [
["d", "mute"], // List identifier
...mutedPubkeys.map(pubkey => ["p", pubkey])
],
pubkey: getPublicKey(privateKey)
};
return signEvent(muteList, privateKey);
}
// Usage
const mutedUsers = [
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194"
];
const muteList = createMuteList(mutedUsers, myPrivateKey);
await pool.publish(muteList, myRelays);
Client Behavior: Filter out events from muted pubkeys in timeline.
2. Pin List (d tag: “pin”)
Feature important content on your profile:
function createPinList(eventIds, privateKey) {
const pinList = {
kind: 30001, // Bookmark set (for events)
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: [
["d", "pin"], // Pinned posts
...eventIds.map(id => ["e", id])
],
pubkey: getPublicKey(privateKey)
};
return signEvent(pinList, privateKey);
}
// Usage: Pin 3 important posts
const pinnedPosts = [
"d0f3e07a5c8f5d8e3e2c5f8e3e2c5f8e3e2c5f8e3e2c5f8e3e2c5f8e3e2c5f8e",
"a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2",
"9876543210fedcba98765432109876543210fedcba98765432109876543210fe"
];
const pinList = createPinList(pinnedPosts, myPrivateKey);
await pool.publish(pinList, myRelays);
3. Bookmark List (d tag: “bookmark”)
Save posts for later reading:
function createBookmarkList(eventIds, privateKey) {
const bookmarkList = {
kind: 30001,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: [
["d", "bookmark"],
...eventIds.map(id => ["e", id])
],
pubkey: getPublicKey(privateKey)
};
return signEvent(bookmarkList, privateKey);
}
// Add a post to bookmarks
async function addBookmark(eventId, privateKey, pool, relays) {
// Fetch existing bookmarks
const existingList = await pool.get(relays, {
kinds: [30001],
authors: [getPublicKey(privateKey)],
"#d": ["bookmark"]
});
const bookmarkedEvents = existingList
? existingList.tags.filter(t => t[0] === "e").map(t => t[1])
: [];
// Add new bookmark (avoid duplicates)
if (!bookmarkedEvents.includes(eventId)) {
bookmarkedEvents.push(eventId);
}
const updatedList = createBookmarkList(bookmarkedEvents, privateKey);
await pool.publish(updatedList, relays);
return updatedList;
}
4. Categorized Follow Lists
Organize follows by topic:
function createCategorizedFollowList(category, pubkeys, privateKey) {
const followList = {
kind: 30000,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: [
["d", category], // e.g., "bitcoin", "nostr-devs", "artists"
...pubkeys.map(pubkey => ["p", pubkey])
],
pubkey: getPublicKey(privateKey)
};
return signEvent(followList, privateKey);
}
// Usage: Create topic-based follow lists
const bitcoinDevs = createCategorizedFollowList("bitcoin-devs", [
"pubkey1...", "pubkey2...", "pubkey3..."
], myPrivateKey);
const artists = createCategorizedFollowList("artists", [
"pubkey4...", "pubkey5...", "pubkey6..."
], myPrivateKey);
await pool.publish(bitcoinDevs, myRelays);
await pool.publish(artists, myRelays);
5. Block List (d tag: “block”)
Completely block users:
function createBlockList(blockedPubkeys, privateKey) {
const blockList = {
kind: 30000,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: [
["d", "block"],
...blockedPubkeys.map(pubkey => ["p", pubkey])
],
pubkey: getPublicKey(privateKey)
};
return signEvent(blockList, privateKey);
}
Difference from Mute:
- Mute: Hide posts, still visible if you look
- Block: Complete removal, prevent interactions
Public vs Encrypted Lists
Public Lists
Default behavior - lists are publicly visible:
const publicBookmarks = {
kind: 30001,
content: "", // Empty content
tags: [
["d", "bookmark"],
["e", "event-id-1"],
["e", "event-id-2"]
],
pubkey: myPubkey
};
Use for: Bookmarks, pins, curated recommendations
Encrypted Lists
Hide list contents from everyone except yourself:
import { nip04 } from 'nostr-tools';
async function createEncryptedMuteList(mutedPubkeys, privateKey) {
const publicKey = getPublicKey(privateKey);
// Create JSON of list items
const listContent = JSON.stringify(
mutedPubkeys.map(pubkey => ["p", pubkey])
);
// Encrypt content using NIP-04
const encryptedContent = await nip04.encrypt(
privateKey,
publicKey,
listContent
);
const encryptedList = {
kind: 30000,
created_at: Math.floor(Date.now() / 1000),
content: encryptedContent, // Encrypted tags
tags: [
["d", "mute"]
// No pubkey tags in public tags array
],
pubkey: publicKey
};
return signEvent(encryptedList, privateKey);
}
// Decrypt when fetching
async function decryptMuteList(encryptedEvent, privateKey) {
const decryptedContent = await nip04.decrypt(
privateKey,
encryptedEvent.pubkey,
encryptedEvent.content
);
const listItems = JSON.parse(decryptedContent);
return listItems.map(item => item[1]); // Extract pubkeys
}
Use for: Mutes, blocks, private bookmarks
Fetching Lists
Get User’s Lists
async function getUserLists(userPubkey, pool, relays) {
const lists = await pool.list(relays, [
{
kinds: [30000, 30001],
authors: [userPubkey]
}
]);
// Organize by list type
const organized = {};
for (const list of lists) {
const dTag = list.tags.find(t => t[0] === "d");
if (dTag) {
const listName = dTag[1];
organized[listName] = list;
}
}
return organized;
}
// Usage
const myLists = await getUserLists(myPubkey, pool, myRelays);
console.log("Bookmarks:", myLists.bookmark);
console.log("Mutes:", myLists.mute);
console.log("Pins:", myLists.pin);
Get Specific List
async function getListByType(userPubkey, listType, pool, relays) {
const lists = await pool.list(relays, [
{
kinds: [30000, 30001],
authors: [userPubkey],
"#d": [listType]
}
]);
return lists[0] || null;
}
// Usage
const bookmarks = await getListByType(myPubkey, "bookmark", pool, myRelays);
const bookmarkedEventIds = bookmarks.tags
.filter(t => t[0] === "e")
.map(t => t[1]);
List Management Patterns
Adding Items to List
async function addToList(listType, itemType, itemId, privateKey, pool, relays) {
const pubkey = getPublicKey(privateKey);
// Fetch existing list
const existingList = await getListByType(pubkey, listType, pool, relays);
// Extract existing items
const existingItems = existingList
? existingList.tags.filter(t => t[0] === itemType).map(t => t[1])
: [];
// Add new item (prevent duplicates)
if (!existingItems.includes(itemId)) {
existingItems.push(itemId);
}
// Create updated list
const kind = itemType === "p" ? 30000 : 30001;
const updatedList = {
kind: kind,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: [
["d", listType],
...existingItems.map(id => [itemType, id])
],
pubkey: pubkey
};
const signedList = signEvent(updatedList, privateKey);
await pool.publish(signedList, relays);
return signedList;
}
// Usage examples
await addToList("bookmark", "e", eventId, myPrivateKey, pool, myRelays);
await addToList("mute", "p", spammerPubkey, myPrivateKey, pool, myRelays);
Removing Items from List
async function removeFromList(listType, itemType, itemId, privateKey, pool, relays) {
const pubkey = getPublicKey(privateKey);
const existingList = await getListByType(pubkey, listType, pool, relays);
if (!existingList) return null;
// Filter out the item to remove
const updatedItems = existingList.tags
.filter(t => t[0] === itemType && t[1] !== itemId)
.map(t => t[1]);
const kind = itemType === "p" ? 30000 : 30001;
const updatedList = {
kind: kind,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: [
["d", listType],
...updatedItems.map(id => [itemType, id])
],
pubkey: pubkey
};
const signedList = signEvent(updatedList, privateKey);
await pool.publish(signedList, relays);
return signedList;
}
// Usage
await removeFromList("bookmark", "e", eventId, myPrivateKey, pool, myRelays);
Checking if Item is in List
function isInList(list, itemType, itemId) {
if (!list) return false;
return list.tags.some(tag =>
tag[0] === itemType && tag[1] === itemId
);
}
// Usage
const bookmarks = await getListByType(myPubkey, "bookmark", pool, myRelays);
const isBookmarked = isInList(bookmarks, "e", someEventId);
if (isBookmarked) {
console.log("This post is bookmarked!");
}
Advanced Patterns
Multi-Client Sync
Lists automatically sync across clients:
class ListManager {
constructor(privateKey, pool, relays) {
this.privateKey = privateKey;
this.pubkey = getPublicKey(privateKey);
this.pool = pool;
this.relays = relays;
this.cache = new Map();
}
async subscribeToLists() {
// Real-time subscription to list updates
const sub = this.pool.sub(this.relays, [
{
kinds: [30000, 30001],
authors: [this.pubkey]
}
]);
sub.on('event', (event) => {
const dTag = event.tags.find(t => t[0] === "d");
if (dTag) {
this.cache.set(dTag[1], event);
this.onListUpdate(dTag[1], event);
}
});
return sub;
}
onListUpdate(listType, listEvent) {
// Callback for UI updates
console.log(`List "${listType}" updated:`, listEvent);
}
async getList(listType) {
// Check cache first
if (this.cache.has(listType)) {
return this.cache.get(listType);
}
// Fetch from relays
const list = await getListByType(this.pubkey, listType, this.pool, this.relays);
if (list) {
this.cache.set(listType, list);
}
return list;
}
}
// Usage
const manager = new ListManager(myPrivateKey, pool, myRelays);
await manager.subscribeToLists();
// Lists automatically update across all clients
Relay-Specific Tags
Add relay hints for better discoverability:
function createBookmarkWithRelays(events, privateKey) {
const bookmarkList = {
kind: 30001,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: [
["d", "bookmark"],
...events.map(({ eventId, relayUrl }) =>
["e", eventId, relayUrl || ""]
)
],
pubkey: getPublicKey(privateKey)
};
return signEvent(bookmarkList, privateKey);
}
// Usage
const bookmarksWithRelays = createBookmarkWithRelays([
{ eventId: "abc123...", relayUrl: "wss://relay.damus.io" },
{ eventId: "def456...", relayUrl: "wss://relay.primal.net" }
], myPrivateKey);
Petnames in Lists
Add human-readable labels:
function createFollowListWithPetnames(follows, privateKey) {
const followList = {
kind: 30000,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: [
["d", "follows"],
...follows.map(({ pubkey, relay, petname }) =>
["p", pubkey, relay || "", petname || ""]
)
],
pubkey: getPublicKey(privateKey)
};
return signEvent(followList, privateKey);
}
// Usage
const followsWithNames = createFollowListWithPetnames([
{
pubkey: "32e1827...",
relay: "wss://relay.damus.io",
petname: "jack"
},
{
pubkey: "7fa56f5...",
relay: "wss://relay.primal.net",
petname: "fiatjaf"
}
], myPrivateKey);
UI Patterns
Bookmark Button Component
function BookmarkButton({ eventId, privateKey, pool, relays }) {
const [isBookmarked, setIsBookmarked] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
checkBookmarkStatus();
}, [eventId]);
async function checkBookmarkStatus() {
const pubkey = getPublicKey(privateKey);
const bookmarks = await getListByType(pubkey, "bookmark", pool, relays);
setIsBookmarked(isInList(bookmarks, "e", eventId));
}
async function toggleBookmark() {
setLoading(true);
if (isBookmarked) {
await removeFromList("bookmark", "e", eventId, privateKey, pool, relays);
setIsBookmarked(false);
} else {
await addToList("bookmark", "e", eventId, privateKey, pool, relays);
setIsBookmarked(true);
}
setLoading(false);
}
return (
<button
onClick={toggleBookmark}
disabled={loading}
className={isBookmarked ? "bookmarked" : ""}
>
{isBookmarked ? "🔖 Saved" : "Bookmark"}
</button>
);
}
Mute Button with Confirmation
function MuteUserButton({ userPubkey, privateKey, pool, relays }) {
const [isMuted, setIsMuted] = useState(false);
useEffect(() => {
checkMuteStatus();
}, [userPubkey]);
async function checkMuteStatus() {
const pubkey = getPublicKey(privateKey);
const mutes = await getListByType(pubkey, "mute", pool, relays);
setIsMuted(isInList(mutes, "p", userPubkey));
}
async function toggleMute() {
const confirmed = window.confirm(
isMuted ? "Unmute this user?" : "Mute this user?"
);
if (!confirmed) return;
if (isMuted) {
await removeFromList("mute", "p", userPubkey, privateKey, pool, relays);
setIsMuted(false);
} else {
await addToList("mute", "p", userPubkey, privateKey, pool, relays);
setIsMuted(true);
}
}
return (
<button onClick={toggleMute} className="mute-btn">
{isMuted ? "🔇 Unmute" : "🔇 Mute"}
</button>
);
}
Security Considerations
Privacy Protection
Encrypted Lists for Sensitive Data:
// Always encrypt mutes and blocks
const encryptedMutes = await createEncryptedMuteList(
mutedPubkeys,
privateKey
);
// Public bookmarks are fine
const publicBookmarks = createBookmarkList(eventIds, privateKey);
Why:
- Mute lists reveal who you dislike (potential social consequences)
- Block lists could be used to harass you
- Bookmarks are generally safe to be public
Validation
Always validate list events:
function validateListEvent(event) {
// Check event structure
if (!event.kind || ![30000, 30001].includes(event.kind)) {
return false;
}
// Check for d tag
const dTag = event.tags.find(t => t[0] === "d");
if (!dTag || !dTag[1]) {
return false;
}
// Verify signature
if (!verifySignature(event)) {
return false;
}
return true;
}
Rate Limiting
Prevent list spam:
const listUpdateLimits = new Map();
function canUpdateList(pubkey, listType) {
const key = `${pubkey}:${listType}`;
const lastUpdate = listUpdateLimits.get(key) || 0;
const now = Date.now();
// Allow updates every 60 seconds
if (now - lastUpdate < 60000) {
return false;
}
listUpdateLimits.set(key, now);
return true;
}
Common List Identifiers
Standard d tag values:
| List Type | d Tag | Kind | Item Type | Description |
|---|---|---|---|---|
| Mute | mute | 30000 | p | Hidden users |
| Pin | pin | 30001 | e | Pinned posts |
| Bookmark | bookmark | 30001 | e | Saved posts |
| Block | block | 30000 | p | Blocked users |
| Follows | follows | 30000 | p | Followed users |
| Communities | communities | 30001 | a | Joined communities |
| Relays | relays | 30000 | relay | Preferred relays |
Client Support
Clients with Lists
- Damus - Full bookmark and mute support
- Amethyst - Bookmarks, mutes, pins, categorized follows
- Primal - Bookmarks and mute lists
- Nostrudel - Advanced list management
- Snort - Basic bookmark support
Feature Coverage
| Client | Bookmarks | Mutes | Pins | Encrypted Lists |
|---|---|---|---|---|
| Amethyst | ✅ | ✅ | ✅ | ✅ |
| Damus | ✅ | ✅ | ✅ | ⚠️ |
| Primal | ✅ | ✅ | ❌ | ❌ |
| Nostrudel | ✅ | ✅ | ✅ | ✅ |
| Snort | ✅ | ⚠️ | ❌ | ❌ |
Check our Client Directory for details.
Common Questions
Why replaceable events (kind 30000/30001)?
Replaceable events ensure only the latest version of a list exists, preventing duplicates and making it easy to update lists without creating spam.
Can I have multiple lists of the same type?
Not with the same d tag. Each d tag creates a unique list. Use different d values for multiple lists (e.g., “bookmark-bitcoin”, “bookmark-art”).
Do all clients support NIP-51?
No. Check client documentation. Major clients (Damus, Amethyst) have strong support.
Should I encrypt my bookmark lists?
Not necessary unless bookmarks reveal sensitive info. Encryption adds complexity. Encrypt mutes/blocks for privacy.
How do I migrate lists between clients?
Lists automatically sync via relays. Just log in with your private key, and clients will fetch your lists.
Can I share lists with others?
Yes, public lists can be referenced by their event ID. Others can view or import your lists.
Related NIPs
- NIP-01 - Basic protocol (replaceable events)
- NIP-02 - Contact list (predecessor to NIP-51)
- NIP-04 - Encrypted Direct Messages (for encrypted lists)
- NIP-10 - Reply conventions (for bookmarked threads)
Technical Specification
For the complete technical specification, see NIP-51 on GitHub.
Summary
NIP-51 enables organized, synchronized lists on Nostr:
✅ Categorized lists using d tags
✅ Replaceable events (kind 30000, 30001)
✅ Public or encrypted content
✅ Cross-client sync via relays
✅ Standard list types: bookmarks, mutes, pins, blocks
List structure:
{
"kind": 30001,
"tags": [
["d", "bookmark"],
["e", "event-id-1"],
["e", "event-id-2"]
]
}
Status: Draft - widely adopted by major clients.
Best practice: Use encrypted lists for mutes/blocks, public lists for bookmarks/pins.
Next Steps:
- Learn about long-form content in NIP-23
- Explore relay preferences in NIP-65
- Understand reactions in NIP-25
- Browse all NIPs in our reference
Last updated: February 2024 Official specification: GitHub
Client Support
This NIP is supported by the following clients: