Reactions
NIP-25 enables reactions to events (likes, emoji, custom responses) using kind 7 events with e and p tags referencing the reacted-to content.
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
7for reactions - content: The reaction itself (emoji, +, -, etc.)
- tags:
etag: Event ID being reacted toptag: Author of the reacted eventktag (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
- React authentically: Don’t spam reactions
- Use appropriate emoji: Match the context
- Avoid downvote brigading: Don’t coordinate mass downvotes
- Change reactions: Update if you change your mind
For Developers (Clients)
- Deduplicate: Show only latest reaction per user
- Aggregate counts: Display totals prominently
- Show who reacted: Transparency about reactions
- Fast feedback: Optimistic UI updates
- Handle deletions: Respect removed reactions (NIP-09)
- Rate limit: Prevent reaction spam
- Cache reactions: Avoid repeated relay queries
For Developers (UI/UX)
- Visual hierarchy: Emphasize popular reactions
- Emoji picker: Make it easy to react with variety
- Animation: Celebrate reactions (hearts floating, etc.)
- Accessibility: Keyboard navigation for reactions
- 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.
Related NIPs
- 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:
- Learn about public chat in NIP-28
- Explore replies in NIP-10
- Understand event deletion in NIP-09
- Browse all NIPs in our reference
Last updated: January 2024 Official specification: GitHub
Client Support
This NIP is supported by the following clients: