NIP-27

Text Note References

draft application

NIP-27 enables inline nostr: references (mentions, note embeds) in text content, allowing clients to render interactive previews and links.

Author
arthurfranca, mikedilger
Last Updated
5 December 2023
Official Spec
View on GitHub →

NIP-27: Text Note References

Status: Draft Authors: arthurfranca, mikedilger Category: Application


Overview

NIP-27 defines how to embed nostr: references inline in text content, enabling rich mentions, note embeds, and interactive content within posts.

Core Concept:

  • Include nostr: URIs directly in event content
  • Clients detect and render as interactive elements
  • Create mentions, quote tweets, embedded notes
  • Maintain human-readable text format

Reference Types:

  • nostr:npub1... - User mentions (@user)
  • nostr:note1... - Note embeds (quote tweets)
  • nostr:nevent1... - Event embeds with context
  • nostr:nprofile1... - Profile mentions with relays
  • nostr:naddr1... - Article/content embeds

Status: Draft (widely adopted)


Why Text References Matter

Rich Content

Without NIP-27:

  • Plain text only
  • No mentions or embeds
  • Links to external content break context
  • Poor user experience

With NIP-27:

  • Interactive mentions (@username)
  • Embedded notes (quote tweets)
  • Rich previews of referenced content
  • Context preserved inline

Use Cases

  1. Mentions: Tag users in conversations
  2. Quote Tweets: Embed others’ posts with commentary
  3. Thread References: Link to previous posts
  4. Profile Shares: Recommend users inline
  5. Article Embeds: Quote long-form content
  6. Cross-Post: Reference content from other platforms

How It Works

Basic Syntax

Include nostr: URIs anywhere in text content:

Check out this post by nostr:npub1a2b3c...!

He said: nostr:note1x9y8z7w...

Great analysis 👆

Client Rendering:

  1. Parse content for nostr: patterns
  2. Decode bech32 entities
  3. Fetch referenced data
  4. Render as interactive elements

Creating References

User Mentions

import { nip19 } from 'nostr-tools';

function createMention(pubkey, displayName) {
  const npub = nip19.npubEncode(pubkey);
  return `@${displayName} nostr:${npub}`;
}

// Usage
const content = `
Hey ${createMention(bobPubkey, "Bob")}!
Have you seen this post?
`;

// Result: "Hey @Bob nostr:npub1... Have you seen this post?"

Client Displays: “@Bob” as clickable link to Bob’s profile.


Note Embeds (Quote Tweets)

function createNoteEmbed(eventId, relays = []) {
  const entity = relays.length > 0
    ? nip19.neventEncode({ id: eventId, relays })
    : nip19.noteEncode(eventId);

  return `nostr:${entity}`;
}

// Usage
const content = `
This is spot on:

${createNoteEmbed(originalEventId, ["wss://relay.damus.io"])}

Couldn't agree more!
`;

Client Displays: Embedded note card with content preview.


Profile Recommendations

function recommendProfile(pubkey, relays, reason) {
  const nprofile = nip19.nprofileEncode({ pubkey, relays });

  return `
Check out ${reason}: nostr:${nprofile}

They post great content about Bitcoin!
  `;
}

// Usage
const content = recommendProfile(
  "3bf0c63fcb...",
  ["wss://relay.damus.io"],
  "this developer"
);

Parsing References

Extract References from Text

function extractNostrReferences(content) {
  const regex = /nostr:(npub|note|nprofile|nevent|naddr)1[a-z0-9]+/g;
  const matches = content.match(regex);

  if (!matches) return [];

  return matches.map(match => {
    const bech32 = match.replace('nostr:', '');

    try {
      const decoded = nip19.decode(bech32);
      return {
        raw: match,
        type: decoded.type,
        data: decoded.data
      };
    } catch {
      return null;
    }
  }).filter(Boolean);
}

// Usage
const content = "Hey nostr:npub1abc...! Check this out: nostr:note1xyz...";
const references = extractNostrReferences(content);

references.forEach(ref => {
  console.log(ref.type);   // "profile" or "note"
  console.log(ref.data);   // Decoded data (pubkey, eventId, etc.)
});

Parse and Fetch

async function parseAndFetchReferences(content, pool, relays) {
  const references = extractNostrReferences(content);

  // Fetch referenced data
  const fetched = await Promise.all(
    references.map(async ref => {
      if (ref.type === 'profile') {
        const profile = await fetchProfile(ref.data, pool, relays);
        return { ...ref, profile };
      } else if (ref.type === 'note') {
        const event = await fetchEvent(ref.data, pool, relays);
        return { ...ref, event };
      }
      return ref;
    })
  );

  return fetched;
}

Rendering References

Mention Component

function MentionLink({ pubkey }) {
  const [profile, setProfile] = useState(null);

  useEffect(() => {
    fetchProfile(pubkey).then(setProfile);
  }, [pubkey]);

  const displayName = profile?.name || `${pubkey.slice(0, 8)}...`;

  return (
    <a
      href={`/profile/${pubkey}`}
      className="mention"
      onClick={(e) => {
        e.preventDefault();
        navigateToProfile(pubkey);
      }}
    >
      @{displayName}
    </a>
  );
}

Embedded Note Component

function EmbeddedNote({ eventId, relays }) {
  const [event, setEvent] = useState(null);
  const [author, setAuthor] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    loadEventAndAuthor();
  }, [eventId]);

  async function loadEventAndAuthor() {
    setLoading(true);

    const evt = await fetchEvent(eventId, pool, relays);
    setEvent(evt);

    if (evt) {
      const profile = await fetchProfile(evt.pubkey, pool, relays);
      setAuthor(profile);
    }

    setLoading(false);
  }

  if (loading) {
    return <div className="embedded-note loading">Loading...</div>;
  }

  if (!event) {
    return <div className="embedded-note error">Note not found</div>;
  }

  return (
    <div className="embedded-note">
      <div className="author">
        <img src={author?.picture} alt={author?.name} />
        <span>{author?.name || 'Anonymous'}</span>
      </div>
      <div className="content">
        {event.content}
      </div>
      <div className="metadata">
        {new Date(event.created_at * 1000).toLocaleDateString()}
      </div>
    </div>
  );
}

Rich Content Renderer

function RichContent({ content, pool, relays }) {
  const [references, setReferences] = useState([]);

  useEffect(() => {
    parseAndFetchReferences(content, pool, relays).then(setReferences);
  }, [content]);

  function renderContent() {
    let parts = [];
    let lastIndex = 0;

    references.forEach(ref => {
      const index = content.indexOf(ref.raw, lastIndex);

      // Add text before reference
      parts.push(content.substring(lastIndex, index));

      // Add rendered reference
      if (ref.type === 'profile') {
        parts.push(
          <MentionLink key={ref.raw} pubkey={ref.data} />
        );
      } else if (ref.type === 'note') {
        parts.push(
          <EmbeddedNote key={ref.raw} eventId={ref.data} relays={ref.relays} />
        );
      }

      lastIndex = index + ref.raw.length;
    });

    // Add remaining text
    parts.push(content.substring(lastIndex));

    return parts;
  }

  return <div className="rich-content">{renderContent()}</div>;
}

Creating Posts with References

Compose with Mentions

function ComposeWithMentions() {
  const [content, setContent] = useState("");
  const [showUserPicker, setShowUserPicker] = useState(false);
  const [cursorPosition, setCursorPosition] = useState(0);

  function handleTextChange(e) {
    const text = e.target.value;
    const cursor = e.target.selectionStart;

    setContent(text);
    setCursorPosition(cursor);

    // Detect @ mentions
    const beforeCursor = text.substring(0, cursor);
    if (beforeCursor.endsWith('@')) {
      setShowUserPicker(true);
    }
  }

  function insertMention(user) {
    const beforeMention = content.substring(0, cursorPosition - 1);
    const afterMention = content.substring(cursorPosition);

    const npub = nip19.npubEncode(user.pubkey);
    const mention = `@${user.name} nostr:${npub}`;

    setContent(beforeMention + mention + afterMention);
    setShowUserPicker(false);
  }

  return (
    <div>
      <textarea
        value={content}
        onChange={handleTextChange}
        placeholder="What's happening? Type @ to mention someone"
      />

      {showUserPicker && (
        <UserPicker onSelect={insertMention} />
      )}

      <button onClick={() => publishPost(content)}>
        Post
      </button>
    </div>
  );
}

Quote Tweet

function QuoteTweetButton({ originalEvent }) {
  async function handleQuote() {
    const nevent = nip19.neventEncode({
      id: originalEvent.id,
      author: originalEvent.pubkey,
      relays: userRelays
    });

    const content = `
Your thoughts here...

nostr:${nevent}
    `.trim();

    // Open compose with pre-filled content
    openCompose(content);
  }

  return (
    <button onClick={handleQuote}>
      Quote Tweet
    </button>
  );
}

Advanced Patterns

Thread with References

function createThreadWithReferences(posts) {
  const thread = [];
  let previousEventId = null;

  for (const post of posts) {
    let content = post.content;

    // Reference previous post in thread
    if (previousEventId) {
      const nevent = nip19.neventEncode({
        id: previousEventId,
        relays: userRelays
      });

      content += `\n\n↩️ nostr:${nevent}`;
    }

    const event = {
      kind: 1,
      content: content,
      tags: [],
      created_at: Math.floor(Date.now() / 1000),
      pubkey: myPubkey
    };

    // Add reply tags for threading
    if (previousEventId) {
      event.tags.push(["e", previousEventId, "", "reply"]);
    }

    const signed = signEvent(event, privateKey);
    thread.push(signed);

    previousEventId = signed.id;
  }

  return thread;
}

Article Quotes

function quoteArticle(article, quoteText, commentary) {
  const naddr = nip19.naddrEncode({
    kind: 30023,
    pubkey: article.pubkey,
    identifier: article.tags.find(t => t[0] === 'd')[1],
    relays: userRelays
  });

  const content = `
${commentary}

> ${quoteText}
> — from nostr:${naddr}
  `.trim();

  return createPost(content);
}

// Usage
const content = quoteArticle(
  article,
  "Nostr is the future of social media",
  "Exactly right. Decentralization is key."
);

Display Patterns

Inline Mentions

.mention {
  color: #0066ff;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
}

.mention:hover {
  text-decoration: underline;
}

Embedded Cards

.embedded-note {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px;
  margin: 16px 0;
  background: #f9f9f9;
}

.embedded-note .author {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 12px;
}

.embedded-note .author img {
  width: 32px;
  height: 32px;
  border-radius: 50%;
}

.embedded-note .content {
  color: #333;
  line-height: 1.5;
}

.embedded-note .metadata {
  margin-top: 8px;
  font-size: 14px;
  color: #666;
}

Hover Previews

function MentionWithPreview({ pubkey }) {
  const [showPreview, setShowPreview] = useState(false);
  const [profile, setProfile] = useState(null);

  async function handleHover() {
    setShowPreview(true);
    if (!profile) {
      const p = await fetchProfile(pubkey);
      setProfile(p);
    }
  }

  return (
    <span
      className="mention"
      onMouseEnter={handleHover}
      onMouseLeave={() => setShowPreview(false)}
    >
      @{profile?.name || pubkey.slice(0, 8)}

      {showPreview && profile && (
        <div className="preview-card">
          <img src={profile.picture} alt={profile.name} />
          <h4>{profile.name}</h4>
          <p>{profile.about}</p>
        </div>
      )}
    </span>
  );
}

Security Considerations

Content Validation

function sanitizeReferences(content) {
  // Extract references
  const references = extractNostrReferences(content);

  // Verify each reference
  for (const ref of references) {
    // Check hex lengths
    if (ref.type === 'profile' && ref.data.length !== 64) {
      throw new Error("Invalid pubkey in reference");
    }

    if (ref.type === 'note' && ref.data.length !== 64) {
      throw new Error("Invalid event ID in reference");
    }
  }

  return true;
}

Rate Limiting Fetches

class ReferenceFetcher {
  constructor(maxConcurrent = 5) {
    this.maxConcurrent = maxConcurrent;
    this.queue = [];
    this.active = 0;
  }

  async fetch(references) {
    const results = [];

    for (const ref of references) {
      while (this.active >= this.maxConcurrent) {
        await new Promise(resolve => setTimeout(resolve, 100));
      }

      this.active++;

      const promise = this.fetchOne(ref)
        .finally(() => this.active--);

      results.push(promise);
    }

    return Promise.all(results);
  }

  async fetchOne(ref) {
    // Fetch logic here
  }
}

Spam Prevention

function filterSpamReferences(references, maxReferences = 10) {
  // Limit number of references
  if (references.length > maxReferences) {
    console.warn("Too many references, limiting to", maxReferences);
    return references.slice(0, maxReferences);
  }

  // Filter duplicate references
  const seen = new Set();
  return references.filter(ref => {
    const key = `${ref.type}:${ref.data}`;
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

Client Support

Clients with NIP-27 Support

  • Damus - Full mention and embed support
  • Amethyst - Rich rendering of all reference types
  • Primal - Interactive previews
  • Snort - Embedded notes and mentions
  • Nostrudel - Advanced reference handling
  • Iris - Basic mention support

Most modern clients support NIP-27.


Common Questions

What’s the difference between NIP-21 and NIP-27?

  • NIP-21: Defines nostr: URI format
  • NIP-27: Defines how to use nostr: URIs inline in content

NIP-27 builds on NIP-21.

Can I mention multiple users?

Yes! Include as many nostr:npub... references as needed.

Do embedded notes show full content?

Depends on the client. Some show full content, others show previews with “read more” buttons.

Can I embed my own notes?

Yes, but consider UX. Self-embedding can look odd to readers.

How do clients know to render references?

They parse the content looking for nostr: patterns and render them as interactive elements.

Are references clickable?

Yes, clients render them as clickable links to the referenced content.


  • NIP-01 - Basic protocol (events, content)
  • NIP-19 - bech32-encoded entities (npub, note, etc.)
  • NIP-21 - nostr: URI scheme (foundation for NIP-27)

Technical Specification

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


Summary

NIP-27 enables rich content with inline references:

Inline nostr: URIs in text content ✅ User mentions (@username style) ✅ Embedded notes (quote tweets) ✅ Profile recommendations inline ✅ Rich client rendering with previews

Example content:

Hey nostr:npub1abc...!

Check out this great post:
nostr:note1xyz...

Totally agree with this take!

Status: Draft - widely adopted for rich content.

Best practice: Include relay hints (nprofile, nevent) for better fetching.


Next Steps:


Last updated: December 2023 Official specification: GitHub

Client Support

This NIP is supported by the following clients:

damus amethyst primal snort nostrudel
View all clients →

Related NIPs

NIP-01 NIP-19 NIP-21
← Browse All NIPs