Long-form Content
NIP-23 enables publishing articles and blog posts on Nostr using kind 30023 events with markdown support, metadata, and SEO-friendly features.
NIP-23: Long-form Content
Status: Draft Author: fiatjaf Category: Application
Overview
NIP-23 defines how to publish long-form content like articles, blog posts, and essays on the Nostr protocol.
Core Concept:
- Articles use kind 30023 (parameterized replaceable events)
- Content formatted in Markdown
- Rich metadata (title, summary, image, published date)
- Updatable without losing event ID
- Decentralized blogging platform
Features:
- ✅ Markdown support: Full formatting capabilities
- ✅ Metadata: Title, summary, cover images
- ✅ Updatable: Edit articles without breaking links
- ✅ SEO-friendly: Published dates, summaries, tags
- ✅ Portable: Publish to multiple clients/relays
- ✅ Monetizable: Integrate with zaps (NIP-57)
Status: Draft (several specialized clients implemented)
Why Long-form Content Matters
Decentralized Publishing
Without NIP-23:
- Writers depend on Medium, Substack, WordPress
- Platforms can ban, censor, or monetize your audience
- Content locked in proprietary systems
- No portability between platforms
With NIP-23:
- Publish directly to relays (no intermediaries)
- Own your content permanently
- Portable across all Nostr clients
- Censorship-resistant publishing
Use Cases
- Personal Blogging: Publish articles on your own terms
- Technical Writing: Share tutorials, documentation
- Journalism: Censorship-resistant reporting
- Creative Writing: Stories, essays, poetry
- Newsletter Alternative: Replace Substack with Nostr
- Knowledge Sharing: Educational content, how-tos
How It Works
Article Event Structure
Long-form content uses kind 30023 (parameterized replaceable event):
{
"kind": 30023,
"content": "# Article Title\n\nFull markdown content here...",
"tags": [
["d", "unique-article-slug"],
["title", "How to Build on Nostr"],
["summary", "A comprehensive guide to building applications on Nostr"],
["published_at", "1673347337"],
["image", "https://example.com/cover.jpg"],
["t", "nostr"],
["t", "development"]
],
"created_at": 1673347337,
"pubkey": "<author-pubkey>"
}
Required Tags:
d- Unique identifier (slug) for the articletitle- Article titlepublished_at- Unix timestamp of publication
Optional Tags:
summary- Brief descriptionimage- Cover image URLt- Topic hashtagsa- References to other articles
Creating Articles
Basic Article
import { getPublicKey, signEvent } from 'nostr-tools';
function createArticle(content, metadata, privateKey) {
const article = {
kind: 30023,
created_at: Math.floor(Date.now() / 1000),
content: content, // Markdown content
tags: [
["d", metadata.slug],
["title", metadata.title],
["published_at", String(metadata.publishedAt || Math.floor(Date.now() / 1000))]
],
pubkey: getPublicKey(privateKey)
};
// Add optional metadata
if (metadata.summary) {
article.tags.push(["summary", metadata.summary]);
}
if (metadata.image) {
article.tags.push(["image", metadata.image]);
}
if (metadata.tags) {
metadata.tags.forEach(tag => {
article.tags.push(["t", tag]);
});
}
return signEvent(article, privateKey);
}
// Usage
const articleContent = `
# Building Decentralized Social Networks
Nostr is a simple, open protocol that enables decentralized social networks.
## Why Nostr?
Unlike traditional platforms, Nostr gives users:
- **Control** over their identity
- **Ownership** of their content
- **Freedom** from censorship
## Getting Started
Here's how to build your first Nostr app...
`;
const articleMetadata = {
slug: "building-decentralized-social-networks",
title: "Building Decentralized Social Networks",
summary: "Learn how to build censorship-resistant social apps with Nostr",
image: "https://example.com/nostr-cover.jpg",
tags: ["nostr", "development", "tutorial"],
publishedAt: 1673347337
};
const article = createArticle(articleContent, articleMetadata, myPrivateKey);
await pool.publish(article, myRelays);
Article with Rich Metadata
function createRichArticle({
content,
slug,
title,
summary,
coverImage,
tags,
author,
publishedAt,
language = "en",
privateKey
}) {
const article = {
kind: 30023,
created_at: Math.floor(Date.now() / 1000),
content: content,
tags: [
["d", slug],
["title", title],
["published_at", String(publishedAt || Math.floor(Date.now() / 1000))]
],
pubkey: getPublicKey(privateKey)
};
// Optional metadata
if (summary) article.tags.push(["summary", summary]);
if (coverImage) article.tags.push(["image", coverImage]);
if (language) article.tags.push(["lang", language]);
// Hashtags
if (tags && tags.length > 0) {
tags.forEach(tag => article.tags.push(["t", tag]));
}
// Author attribution (if different from pubkey)
if (author) {
article.tags.push(["author", author]);
}
return signEvent(article, privateKey);
}
// Usage
const richArticle = createRichArticle({
content: articleMarkdown,
slug: "nostr-development-guide-2024",
title: "Nostr Development Guide 2024",
summary: "Everything you need to know about building on Nostr in 2024",
coverImage: "https://cdn.example.com/nostr-guide.jpg",
tags: ["nostr", "development", "guide", "2024"],
language: "en",
publishedAt: 1705824000,
privateKey: myPrivateKey
});
await pool.publish(richArticle, myRelays);
Updating Articles
Edit Without Breaking Links
Because kind 30023 is parameterized replaceable, you can update articles:
async function updateArticle(slug, updatedContent, updatedMetadata, privateKey, pool, relays) {
// Fetch existing article
const existingArticle = await pool.get(relays, {
kinds: [30023],
authors: [getPublicKey(privateKey)],
"#d": [slug]
});
if (!existingArticle) {
throw new Error("Article not found");
}
// Create updated version (same slug = replaces old version)
const updatedArticle = createArticle(
updatedContent,
{ ...updatedMetadata, slug: slug },
privateKey
);
await pool.publish(updatedArticle, relays);
return updatedArticle;
}
// Usage: Fix typo in published article
const updated = await updateArticle(
"building-decentralized-social-networks",
updatedMarkdown,
{
title: "Building Decentralized Social Networks",
summary: "Updated summary with better SEO",
tags: ["nostr", "development"]
},
myPrivateKey,
pool,
myRelays
);
console.log("Article updated successfully");
Key Benefit: Same d tag means same canonical URL across clients.
Fetching Articles
Get All Articles by Author
async function getAuthorArticles(authorPubkey, pool, relays) {
const articles = await pool.list(relays, [
{
kinds: [30023],
authors: [authorPubkey]
}
]);
// Sort by published date
articles.sort((a, b) => {
const aDate = parseInt(a.tags.find(t => t[0] === "published_at")?.[1] || "0");
const bDate = parseInt(b.tags.find(t => t[0] === "published_at")?.[1] || "0");
return bDate - aDate; // Newest first
});
return articles.map(parseArticle);
}
function parseArticle(event) {
return {
id: event.id,
slug: event.tags.find(t => t[0] === "d")?.[1],
title: event.tags.find(t => t[0] === "title")?.[1],
summary: event.tags.find(t => t[0] === "summary")?.[1],
image: event.tags.find(t => t[0] === "image")?.[1],
content: event.content,
tags: event.tags.filter(t => t[0] === "t").map(t => t[1]),
publishedAt: parseInt(event.tags.find(t => t[0] === "published_at")?.[1] || "0"),
author: event.pubkey,
createdAt: event.created_at
};
}
// Usage
const myArticles = await getAuthorArticles(myPubkey, pool, myRelays);
console.log(`Found ${myArticles.length} articles`);
Get Specific Article by Slug
async function getArticle(authorPubkey, slug, pool, relays) {
const article = await pool.get(relays, {
kinds: [30023],
authors: [authorPubkey],
"#d": [slug]
});
return article ? parseArticle(article) : null;
}
// Usage
const article = await getArticle(
authorPubkey,
"building-decentralized-social-networks",
pool,
myRelays
);
if (article) {
console.log(article.title);
console.log(article.content);
}
Search Articles by Tag
async function searchArticlesByTag(tag, pool, relays, limit = 20) {
const articles = await pool.list(relays, [
{
kinds: [30023],
"#t": [tag],
limit: limit
}
]);
return articles.map(parseArticle);
}
// Usage: Find all Nostr development articles
const devArticles = await searchArticlesByTag("nostr", pool, myRelays, 50);
Markdown Support
Full Markdown Formatting
const articleContent = `
# Main Heading
## Section Heading
This is a paragraph with **bold** and *italic* text.
### Code Example
\`\`\`javascript
function hello() {
console.log("Hello Nostr!");
}
\`\`\`
### Lists
- Bullet point 1
- Bullet point 2
- Bullet point 3
1. Numbered item
2. Another item
### Links
Check out [Nostr](https://nostr.com) for more info.
### Images

### Quotes
> This is a blockquote about decentralization.
### Tables
| Feature | Nostr | Twitter |
|---------|-------|---------|
| Censorship | ❌ | ✅ |
| Portability | ✅ | ❌ |
`;
Embedded Nostr Content
Reference other Nostr events in articles:
const articleWithReferences = `
# My Analysis of Recent Events
Here's an interesting post I saw:
nostr:note1abc123...
And this user has great insights:
nostr:npub1xyz789...
`;
// Clients can render these as embedded cards
Advanced Features
Drafts vs Published
function createDraft(content, metadata, privateKey) {
// Use negative timestamp for drafts (convention)
const draft = createArticle(content, {
...metadata,
publishedAt: -1 // Indicates draft status
}, privateKey);
return draft;
}
function publishDraft(draft, privateKey) {
// Update published_at to current time
const published = {
...draft,
created_at: Math.floor(Date.now() / 1000),
tags: draft.tags.map(tag =>
tag[0] === "published_at"
? ["published_at", String(Math.floor(Date.now() / 1000))]
: tag
)
};
return signEvent(published, privateKey);
}
Series and Collections
Link related articles:
function createArticleInSeries(content, metadata, seriesSlug, partNumber, privateKey) {
const article = createArticle(content, metadata, privateKey);
// Add series metadata
article.tags.push(["series", seriesSlug]);
article.tags.push(["part", String(partNumber)]);
return article;
}
// Create a 3-part series
const part1 = createArticleInSeries(
content1,
{ slug: "nostr-basics-part-1", title: "Nostr Basics: Introduction" },
"nostr-basics",
1,
myPrivateKey
);
const part2 = createArticleInSeries(
content2,
{ slug: "nostr-basics-part-2", title: "Nostr Basics: Events" },
"nostr-basics",
2,
myPrivateKey
);
Article References
Reference other articles:
function createArticleWithReferences(content, metadata, referencedArticles, privateKey) {
const article = createArticle(content, metadata, privateKey);
// Add references (NIP-33 style)
referencedArticles.forEach(({ pubkey, slug }) => {
article.tags.push(["a", `30023:${pubkey}:${slug}`]);
});
return article;
}
// Usage: Create article that references others
const articleWithRefs = createArticleWithReferences(
content,
metadata,
[
{ pubkey: "abc123...", slug: "previous-article" },
{ pubkey: "def456...", slug: "related-post" }
],
myPrivateKey
);
SEO and Discoverability
Optimized Metadata
function createSEOOptimizedArticle({
content,
slug,
title,
summary,
coverImage,
tags,
canonicalUrl,
privateKey
}) {
const article = createRichArticle({
content,
slug,
title,
summary,
coverImage,
tags,
privateKey
});
// Add canonical URL (for cross-posting)
if (canonicalUrl) {
article.tags.push(["r", canonicalUrl, "canonical"]);
}
// Add author metadata
article.tags.push(["client", "YourClientName"]);
return article;
}
Hashtag Strategy
function extractHashtags(content) {
const regex = /#(\w+)/g;
const matches = content.match(regex);
return matches ? [...new Set(matches.map(m => m.slice(1)))] : [];
}
function createArticleWithAutoTags(content, metadata, privateKey) {
// Extract hashtags from content
const autoTags = extractHashtags(content);
// Combine with manual tags
const allTags = [...new Set([...(metadata.tags || []), ...autoTags])];
return createArticle(content, {
...metadata,
tags: allTags
}, privateKey);
}
Client Integration
Article Listing Component
function ArticleList({ authorPubkey, pool, relays }) {
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadArticles();
}, [authorPubkey]);
async function loadArticles() {
setLoading(true);
const articles = await getAuthorArticles(authorPubkey, pool, relays);
setArticles(articles);
setLoading(false);
}
if (loading) return <div>Loading articles...</div>;
return (
<div className="article-list">
{articles.map(article => (
<ArticleCard key={article.slug} article={article} />
))}
</div>
);
}
function ArticleCard({ article }) {
return (
<article className="article-card">
{article.image && (
<img src={article.image} alt={article.title} />
)}
<h2>{article.title}</h2>
<p>{article.summary}</p>
<div className="metadata">
<time>{new Date(article.publishedAt * 1000).toLocaleDateString()}</time>
<div className="tags">
{article.tags.map(tag => (
<span key={tag} className="tag">#{tag}</span>
))}
</div>
</div>
<a href={`/articles/${article.slug}`}>Read more →</a>
</article>
);
}
Markdown Renderer
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
function ArticleViewer({ article }) {
return (
<article className="article-viewer">
{article.image && (
<img
src={article.image}
alt={article.title}
className="cover-image"
/>
)}
<header>
<h1>{article.title}</h1>
<div className="metadata">
<time>{new Date(article.publishedAt * 1000).toLocaleDateString()}</time>
<AuthorInfo pubkey={article.author} />
</div>
</header>
<div className="content">
<ReactMarkdown
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
}
}}
>
{article.content}
</ReactMarkdown>
</div>
<footer>
<div className="tags">
{article.tags.map(tag => (
<a key={tag} href={`/tags/${tag}`}>#{tag}</a>
))}
</div>
<ZapButton eventId={article.id} authorPubkey={article.author} />
</footer>
</article>
);
}
Monetization
Integrate with Zaps (NIP-57)
function ArticleWithZaps({ article }) {
const [totalZaps, setTotalZaps] = useState(0);
useEffect(() => {
// Subscribe to zap receipts for this article
const sub = pool.sub(myRelays, [
{
kinds: [9735], // Zap receipts
"#e": [article.id]
}
]);
sub.on('event', (zapReceipt) => {
const amount = getZapAmount(zapReceipt);
setTotalZaps(prev => prev + amount);
});
return () => sub.unsub();
}, [article.id]);
return (
<div className="article-monetization">
<p>This article has received {totalZaps} sats</p>
<button onClick={() => zapArticle(article.id, 1000)}>
⚡ Zap 1,000 sats
</button>
</div>
);
}
Performance Optimization
Caching Articles
class ArticleCache {
constructor() {
this.cache = new Map();
this.ttl = 5 * 60 * 1000; // 5 minutes
}
get(authorPubkey, slug) {
const key = `${authorPubkey}:${slug}`;
const cached = this.cache.get(key);
if (!cached) return null;
if (Date.now() - cached.timestamp > this.ttl) {
this.cache.delete(key);
return null;
}
return cached.article;
}
set(authorPubkey, slug, article) {
const key = `${authorPubkey}:${slug}`;
this.cache.set(key, {
article,
timestamp: Date.now()
});
}
}
const articleCache = new ArticleCache();
async function getCachedArticle(authorPubkey, slug, pool, relays) {
// Check cache first
const cached = articleCache.get(authorPubkey, slug);
if (cached) return cached;
// Fetch from relays
const article = await getArticle(authorPubkey, slug, pool, relays);
if (article) {
articleCache.set(authorPubkey, slug, article);
}
return article;
}
Security Considerations
Content Validation
function validateArticle(event) {
// Check event kind
if (event.kind !== 30023) {
return { valid: false, error: "Invalid event kind" };
}
// Check required tags
const dTag = event.tags.find(t => t[0] === "d");
if (!dTag || !dTag[1]) {
return { valid: false, error: "Missing 'd' tag (slug)" };
}
const titleTag = event.tags.find(t => t[0] === "title");
if (!titleTag || !titleTag[1]) {
return { valid: false, error: "Missing 'title' tag" };
}
const publishedTag = event.tags.find(t => t[0] === "published_at");
if (!publishedTag || !publishedTag[1]) {
return { valid: false, error: "Missing 'published_at' tag" };
}
// Check content length
if (event.content.length < 100) {
return { valid: false, error: "Content too short for article" };
}
// Verify signature
if (!verifySignature(event)) {
return { valid: false, error: "Invalid signature" };
}
return { valid: true };
}
Spam Prevention
function isLikelySpam(article) {
// Check for excessive links
const linkCount = (article.content.match(/https?:\/\//g) || []).length;
if (linkCount > 20) return true;
// Check for keyword stuffing
const words = article.content.toLowerCase().split(/\s+/);
const wordFreq = {};
words.forEach(word => {
wordFreq[word] = (wordFreq[word] || 0) + 1;
});
const maxFreq = Math.max(...Object.values(wordFreq));
if (maxFreq > words.length * 0.1) return true; // 10% threshold
// Check title quality
if (article.title.length < 10 || article.title.length > 200) return true;
return false;
}
Client Support
Specialized Long-form Clients
- Habla - Nostr blogging platform (habla.news)
- YakiHonne - Magazine-style content reader
- Blogstack - Decentralized blogging
- Highlighter - Article highlights and annotations
General Clients with Article Support
| Client | Read | Write | Markdown | Images |
|---|---|---|---|---|
| Habla | ✅ | ✅ | ✅ | ✅ |
| YakiHonne | ✅ | ✅ | ✅ | ✅ |
| Blogstack | ✅ | ✅ | ✅ | ✅ |
| Highlighter | ✅ | ⚠️ | ✅ | ✅ |
| Amethyst | ✅ | ❌ | ⚠️ | ⚠️ |
Check our Client Directory for details.
Common Questions
Why use kind 30023 instead of kind 1?
Kind 30023 is parameterized replaceable, allowing updates without creating duplicates. Kind 1 (text notes) are immutable.
Can I edit published articles?
Yes! Publish a new event with the same d tag (slug). The latest version replaces older ones.
How do I handle images in articles?
- Cover images: Use
imagetag - Inline images: Use markdown syntax:
 - Host images: On your own server or use Nostr image hosts (NIP-94/NIP-96)
Can I monetize articles with paywalls?
Not directly in NIP-23. Consider:
- Zaps for tips (NIP-57)
- Encrypted content with paid decryption (future NIP)
- Link to external paywall
Do all clients render markdown the same?
No. Markdown support varies. Test your content across multiple clients.
How do I migrate from Medium/Substack?
- Export your content as markdown
- Convert to NIP-23 format
- Publish to Nostr relays
- Add redirects from old URLs
Related NIPs
- NIP-01 - Basic protocol (event structure)
- NIP-51 - Lists (for bookmarking articles)
- NIP-57 - Lightning Zaps (monetization)
- NIP-58 - Badges (for featured articles)
Technical Specification
For the complete technical specification, see NIP-23 on GitHub.
Summary
NIP-23 enables long-form content on Nostr:
✅ Kind 30023 parameterized replaceable events ✅ Markdown formatting for rich content ✅ Rich metadata: title, summary, image, tags ✅ Updatable without breaking links ✅ SEO-friendly with proper tagging ✅ Monetizable via zaps
Article structure:
{
"kind": 30023,
"content": "# Article\n\nMarkdown content...",
"tags": [
["d", "article-slug"],
["title", "Article Title"],
["summary", "Brief description"],
["published_at", "1673347337"],
["image", "https://example.com/cover.jpg"],
["t", "tag1"], ["t", "tag2"]
]
}
Status: Draft - actively used by specialized blogging clients.
Best practice: Use unique slugs, rich metadata, and quality markdown formatting.
Next Steps:
- Learn about relay preferences in NIP-65
- Explore authentication in NIP-42
- Understand lists in NIP-51
- Browse all NIPs in our reference
Last updated: January 2024 Official specification: GitHub
Client Support
This NIP is supported by the following clients: