Text Note References
NIP-27 enables inline nostr: references (mentions, note embeds) in text content, allowing clients to render interactive previews and links.
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
- Mentions: Tag users in conversations
- Quote Tweets: Embed others’ posts with commentary
- Thread References: Link to previous posts
- Profile Shares: Recommend users inline
- Article Embeds: Quote long-form content
- 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:
- Parse content for
nostr:patterns - Decode bech32 entities
- Fetch referenced data
- 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.
Related NIPs
- 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:
- Learn about parameterized events in NIP-33
- Explore URI schemes in NIP-21
- Understand bech32 encoding in NIP-19
- Browse all NIPs in our reference
Last updated: December 2023 Official specification: GitHub
Client Support
This NIP is supported by the following clients: