NIP-25

Reactions

final social features

NIP-25 enables reactions to events (likes, emoji, custom responses) using kind 7 events with e and p tags referencing the reacted-to content.

Author
fiatjaf
Last Updated
15 January 2024
Official Spec
View on GitHub →

NIP-25: Reactions

Status: Final Author: fiatjaf Category: Social Features


Overview

NIP-25 defines how Nostr users express reactions to events — the protocol equivalent of likes, emoji reactions, upvotes, and other feedback mechanisms.

Reaction Types:

  • Likes (👍, ❤️, +)
  • Emoji reactions (🔥, 😂, 🎉, etc.)
  • Custom reactions (any emoji or text)
  • Downvotes (👎, -, though controversial)

Reactions use kind 7 events with e and p tags to reference the content being reacted to.


Why Reactions Matter

Engagement Without Words

Reactions provide low-friction engagement:

  • Faster than writing a reply
  • Express sentiment quickly
  • Show support without interrupting conversation
  • Create social signals (popularity, agreement)

Social Dynamics

On traditional platforms:

  • Likes drive algorithmic visibility
  • Upvotes determine content ranking
  • Reactions influence recommendations

On Nostr with NIP-25:

  • Reactions are visible to all
  • Clients decide how to interpret them
  • No central algorithm controls visibility
  • Users own their reaction data

How It Works

Reaction Event Structure

Reactions use kind 7 events:

{
  "kind": 7,
  "created_at": 1673347337,
  "content": "+",
  "tags": [
    ["e", "reacted_event_id"],
    ["p", "reacted_event_author"],
    ["k", "1"]
  ],
  "pubkey": "reactor_public_key",
  "id": "...",
  "sig": "..."
}

Field Breakdown:

  • kind: Always 7 for reactions
  • content: The reaction itself (emoji, +, -, etc.)
  • tags:
    • e tag: Event ID being reacted to
    • p tag: Author of the reacted event
    • k tag (optional): Kind of the reacted event

Reaction Types

1. Like / Upvote

The standard positive reaction:

{
  "kind": 7,
  "content": "+",
  "tags": [
    ["e", "event_id"],
    ["p", "author_pubkey"]
  ],
  ...
}

Content values for “like”:

  • "+" (most common)
  • "❤️" (heart emoji)
  • "👍" (thumbs up)

Interpretation: Positive feedback, agreement, support.


2. Emoji Reactions

Express specific emotions:

{
  "kind": 7,
  "content": "🔥",  // Fire emoji
  "tags": [
    ["e", "event_id"],
    ["p", "author_pubkey"]
  ],
  ...
}

Popular emoji reactions:

  • 🔥 (fire) - “This is awesome!”
  • 😂 (laugh) - “This is funny!”
  • 🎉 (party) - “Congratulations!”
  • 💯 (100) - “Absolutely!”
  • 🤔 (thinking) - “Interesting…”
  • ⚡ (zap) - Often paired with Lightning zaps

3. Downvote / Dislike

Negative feedback (controversial):

{
  "kind": 7,
  "content": "-",
  "tags": [
    ["e", "event_id"],
    ["p", "author_pubkey"]
  ],
  ...
}

Content values for “dislike”:

  • "-" (most common)
  • "👎" (thumbs down)

⚠️ Controversy: Downvotes are debated in the Nostr community:

  • Pro: Useful for spam/quality signaling
  • Con: Can create negativity, brigading
  • Clients: Many don’t display downvotes

4. Custom Reactions

Any text or emoji:

{
  "kind": 7,
  "content": "gm ☕",  // Good morning with coffee
  "tags": [
    ["e", "event_id"],
    ["p", "author_pubkey"]
  ],
  ...
}

Examples:

  • "🚀🚀🚀" (multiple rockets)
  • "nostr:note1..." (quoting another event)
  • "WAGMI" (text reaction)

Creating Reactions

Example: Like a Post

import { getPublicKey, signEvent } from 'nostr-tools';

function createLike(eventToLike, privateKey) {
  const reaction = {
    kind: 7,
    created_at: Math.floor(Date.now() / 1000),
    content: "+",  // Like
    tags: [
      ["e", eventToLike.id],
      ["p", eventToLike.pubkey],
      ["k", String(eventToLike.kind)]  // Optional
    ],
    pubkey: getPublicKey(privateKey)
  };

  return signEvent(reaction, privateKey);
}

// Usage
const likeEvent = createLike(post, privateKey);
relay.publish(likeEvent);

Example: Emoji Reaction

function createEmojiReaction(event, emoji, privateKey) {
  const reaction = {
    kind: 7,
    created_at: Math.floor(Date.now() / 1000),
    content: emoji,  // 🔥, 😂, 🎉, etc.
    tags: [
      ["e", event.id],
      ["p", event.pubkey]
    ],
    pubkey: getPublicKey(privateKey)
  };

  return signEvent(reaction, privateKey);
}

// Usage
const fireReaction = createEmojiReaction(post, "🔥", privateKey);
relay.publish(fireReaction);

Fetching Reactions

Get Reactions for an Event

async function getReactions(eventId, relays) {
  const filters = {
    kinds: [7],
    "#e": [eventId]
  };

  const reactions = await Promise.all(
    relays.map(relay => relay.list([filters]))
  );

  return reactions.flat();
}

// Usage
const reactions = await getReactions(postId, myRelays);
console.log(`${reactions.length} reactions`);

Count Reactions by Type

function countReactionTypes(reactions) {
  const counts = {};

  reactions.forEach(reaction => {
    const content = reaction.content || "+";
    counts[content] = (counts[content] || 0) + 1;
  });

  return counts;
}

// Usage
const reactions = await getReactions(postId, myRelays);
const counts = countReactionTypes(reactions);

// { "+": 42, "🔥": 15, "😂": 8, ... }

Display Reaction Counts

function ReactionDisplay({ event, reactions }) {
  const counts = countReactionTypes(reactions);
  const total = reactions.length;

  return (
    <div className="reactions">
      {Object.entries(counts).map(([emoji, count]) => (
        <button key={emoji} className="reaction-button">
          <span className="emoji">{emoji}</span>
          <span className="count">{count}</span>
        </button>
      ))}

      <span className="total">{total} reactions</span>
    </div>
  );
}

Handling Duplicates

Users may react multiple times. Clients should handle this:

Option 1: Latest Reaction Wins

function deduplicateReactions(reactions) {
  const latest = {};

  reactions.forEach(reaction => {
    const key = reaction.pubkey;

    if (!latest[key] || reaction.created_at > latest[key].created_at) {
      latest[key] = reaction;
    }
  });

  return Object.values(latest);
}

Behavior: User’s most recent reaction replaces previous ones.


Option 2: All Unique Reactions

function getUniqueReactions(reactions) {
  const unique = {};

  reactions.forEach(reaction => {
    const key = `${reaction.pubkey}:${reaction.content}`;

    if (!unique[key] || reaction.created_at > unique[key].created_at) {
      unique[key] = reaction;
    }
  });

  return Object.values(unique);
}

Behavior: User can have multiple different reactions, but only one of each type.


Option 3: Allow All

Show every reaction, even duplicates.

Behavior: Spammy, but shows raw data.


UI Patterns

React Button

function ReactButton({ event, userPubkey, privateKey }) {
  const [reacted, setReacted] = useState(false);

  async function handleReact() {
    if (reacted) {
      // Remove reaction (publish deletion event - NIP-09)
      // Or just unpublish and let relay handle
      setReacted(false);
    } else {
      // Publish reaction
      const reaction = createLike(event, privateKey);
      await relay.publish(reaction);
      setReacted(true);
    }
  }

  return (
    <button
      onClick={handleReact}
      className={reacted ? "reacted" : ""}
    >
      {reacted ? "❤️ Liked" : "🤍 Like"}
    </button>
  );
}

Emoji Picker

function EmojiReactionPicker({ event, privateKey }) {
  const emojis = ["👍", "❤️", "🔥", "😂", "🎉", "💯"];

  async function handleEmojiClick(emoji) {
    const reaction = createEmojiReaction(event, emoji, privateKey);
    await relay.publish(reaction);
  }

  return (
    <div className="emoji-picker">
      {emojis.map(emoji => (
        <button
          key={emoji}
          onClick={() => handleEmojiClick(emoji)}
          className="emoji-button"
        >
          {emoji}
        </button>
      ))}
    </div>
  );
}

Reaction List Display

function ReactionList({ reactions }) {
  const grouped = groupReactionsByType(reactions);

  return (
    <div className="reaction-list">
      {Object.entries(grouped).map(([emoji, users]) => (
        <div key={emoji} className="reaction-group">
          <span className="emoji">{emoji}</span>
          <span className="count">{users.length}</span>
          <div className="users">
            {users.map(user => (
              <Avatar key={user.pubkey} user={user} />
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

Unreacting (Removing Reactions)

Option 1: Publish Deletion Event

Use NIP-09 to delete your reaction:

async function unreact(reactionEventId, privateKey) {
  const deletion = {
    kind: 5,
    created_at: Math.floor(Date.now() / 1000),
    tags: [
      ["e", reactionEventId]
    ],
    content: "Removing my reaction",
    pubkey: getPublicKey(privateKey)
  };

  const signed = signEvent(deletion, privateKey);
  await relay.publish(signed);
}

Option 2: Toggle Reaction

Publish a new reaction with empty/null content:

const unreaction = {
  kind: 7,
  content: "",  // Empty content = removal
  tags: [
    ["e", originalEventId],
    ["p", originalAuthor]
  ],
  ...
};

Note: Not all clients support this. Deletion (NIP-09) is more reliable.


Best Practices

For Users

  1. React authentically: Don’t spam reactions
  2. Use appropriate emoji: Match the context
  3. Avoid downvote brigading: Don’t coordinate mass downvotes
  4. Change reactions: Update if you change your mind

For Developers (Clients)

  1. Deduplicate: Show only latest reaction per user
  2. Aggregate counts: Display totals prominently
  3. Show who reacted: Transparency about reactions
  4. Fast feedback: Optimistic UI updates
  5. Handle deletions: Respect removed reactions (NIP-09)
  6. Rate limit: Prevent reaction spam
  7. Cache reactions: Avoid repeated relay queries

For Developers (UI/UX)

  1. Visual hierarchy: Emphasize popular reactions
  2. Emoji picker: Make it easy to react with variety
  3. Animation: Celebrate reactions (hearts floating, etc.)
  4. Accessibility: Keyboard navigation for reactions
  5. Discovery: Show “most reacted” content

Advanced Topics

Reaction Notifications

Notify users when their content is reacted to:

// Subscribe to reactions on your events
const filters = {
  kinds: [7],
  "#p": [myPubkey]  // Reactions mentioning me
};

relay.sub([filters], {
  onEvent: (reaction) => {
    const eventId = reaction.tags.find(t => t[0] === "e")[1];
    showNotification(`Someone reacted to your post: ${reaction.content}`);
  }
});

Reaction-Based Feeds

Sort content by reaction count:

async function getPopularPosts(filters, relays) {
  // Fetch posts
  const posts = await fetchPosts(filters, relays);

  // Fetch all reactions for these posts
  const postIds = posts.map(p => p.id);
  const reactions = await fetchReactionsForEvents(postIds, relays);

  // Count reactions per post
  const counts = {};
  reactions.forEach(r => {
    const eventId = r.tags.find(t => t[0] === "e")[1];
    counts[eventId] = (counts[eventId] || 0) + 1;
  });

  // Sort by reaction count
  posts.sort((a, b) => (counts[b.id] || 0) - (counts[a.id] || 0));

  return posts;
}

Weighted Reactions

Give different reactions different weights:

const reactionWeights = {
  "+": 1,
  "❤️": 2,
  "🔥": 3,
  "💯": 5,
  "-": -1
};

function calculateScore(reactions) {
  return reactions.reduce((score, reaction) => {
    const weight = reactionWeights[reaction.content] || 1;
    return score + weight;
  }, 0);
}

Reaction Analytics

Track reaction patterns:

function analyzeReactions(reactions) {
  const analysis = {
    total: reactions.length,
    unique: new Set(reactions.map(r => r.pubkey)).size,
    types: countReactionTypes(reactions),
    timeline: groupByTimeInterval(reactions, '1h')
  };

  return analysis;
}

// { total: 142, unique: 98, types: { "+": 80, "🔥": 42, ... } }

Security Considerations

Reaction Spam

Attackers may spam reactions:

// Same user, 1000 reactions in 1 second
[
  { kind: 7, pubkey: "attacker", ... },
  { kind: 7, pubkey: "attacker", ... },
  ... (1000 times)
]

Mitigation:

  • Deduplicate by pubkey
  • Rate limit reaction display
  • Flag suspicious patterns
  • Trust web-of-trust for reaction validity

Fake Popularity

Attacker creates many keypairs to inflate reaction counts:

Mitigation:

  • Weight reactions by web-of-trust
  • Highlight reactions from followed users
  • Show “Reactions from your network: X”
  • Downweight unfamiliar pubkeys

Reaction Privacy

Reactions are public:

  • Anyone can see who reacted
  • Reactions are stored on relays forever
  • Can be analyzed to infer interests

Privacy considerations:

  • Users should be aware reactions are public
  • Consider pseudonymous accounts for sensitive topics

Client Support

Full Reaction Support

  • Damus - Emoji reactions with picker
  • Primal - Likes and emoji, aggregated counts
  • Amethyst - Comprehensive reaction system
  • Snort - Quick reactions with visual feedback
  • Iris - Like button with count display
  • Nostrudel - Advanced reaction analytics

Reaction Display

  • Most clients: Show like counts
  • Many clients: Support emoji reactions
  • Some clients: Ignore downvotes (policy decision)

Check our Client Directory for specifics.


Common Questions

Can I remove my reaction?

Yes, publish a deletion event (NIP-09) referencing your reaction event ID. Not all relays/clients honor deletions.

Do downvotes work?

Technically yes, but many clients don’t display them. The Nostr community debates whether downvotes are beneficial.

Can I react to any event type?

Yes, you can react to any event (kind 0 profiles, kind 3 contact lists, etc.), though it’s most common for text notes (kind 1).

Are reactions anonymous?

No, reactions include your pubkey. They’re public and traceable to you.

Can I see who reacted?

Yes, reaction events include the reactor’s pubkey. Most clients show who reacted.


  • NIP-01 - Basic protocol (event structure)
  • NIP-10 - Reply conventions (e and p tags)
  • NIP-09 - Event deletion (removing reactions)

Technical Specification

For the complete technical specification, see NIP-25 on GitHub.


Summary

NIP-25 enables social feedback on Nostr:

Kind 7 events for reactions ✅ Content field contains emoji/symbol (+, ❤️, 🔥) ✅ E tag references reacted event ✅ P tag notifies event author

Common reactions:

  • "+" or "❤️" - Like/upvote
  • "🔥" - Fire/awesome
  • "😂" - Laugh
  • "-" - Downvote (controversial)

Best practice: Deduplicate reactions by user, display aggregated counts, make reacting easy and fun.


Next Steps:


Last updated: January 2024 Official specification: GitHub

Client Support

This NIP is supported by the following clients:

damus primal amethyst snort iris coracle nostrudel satellite nos nostur plebstr current yakihonne
View all clients →

Related NIPs

NIP-01 NIP-10
← Browse All NIPs