NIP-51

Lists

draft application

NIP-51 enables users to create categorized, labeled lists (bookmarks, mutes, pins, blocks) using replaceable events, supporting both public and encrypted lists.

Author
fiatjaf, arcbtc, mikedilger, arthurfranca
Last Updated
15 February 2024
Official Spec
View on GitHub →

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

  1. Content Curation: Save interesting posts for later reading
  2. User Management: Mute spammers, block harassers (synced everywhere)
  3. Featured Content: Pin important posts to your profile
  4. Interest Groups: Organize follows by topic
  5. Reading Lists: Collect articles, threads for later
  6. 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 Typed TagKindItem TypeDescription
Mutemute30000pHidden users
Pinpin30001ePinned posts
Bookmarkbookmark30001eSaved posts
Blockblock30000pBlocked users
Followsfollows30000pFollowed users
Communitiescommunities30001aJoined communities
Relaysrelays30000relayPreferred 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

ClientBookmarksMutesPinsEncrypted 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.


  • 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:


Last updated: February 2024 Official specification: GitHub

Client Support

This NIP is supported by the following clients:

damus amethyst primal nostrudel snort
View all clients →

Related NIPs

NIP-01 NIP-02 NIP-04 NIP-10
← Browse All NIPs