NIP-23

Long-form Content

draft application

NIP-23 enables publishing articles and blog posts on Nostr using kind 30023 events with markdown support, metadata, and SEO-friendly features.

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

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

  1. Personal Blogging: Publish articles on your own terms
  2. Technical Writing: Share tutorials, documentation
  3. Journalism: Censorship-resistant reporting
  4. Creative Writing: Stories, essays, poetry
  5. Newsletter Alternative: Replace Substack with Nostr
  6. 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 article
  • title - Article title
  • published_at - Unix timestamp of publication

Optional Tags:

  • summary - Brief description
  • image - Cover image URL
  • t - Topic hashtags
  • a - 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

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

![Alt text](https://example.com/image.jpg)

### 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

ClientReadWriteMarkdownImages
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 image tag
  • Inline images: Use markdown syntax: ![alt](url)
  • 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?

  1. Export your content as markdown
  2. Convert to NIP-23 format
  3. Publish to Nostr relays
  4. Add redirects from old URLs

  • 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:


Last updated: January 2024 Official specification: GitHub

Client Support

This NIP is supported by the following clients:

habla yakihonne blogstack highlighter
View all clients →

Related NIPs

NIP-01 NIP-51 NIP-58
← Browse All NIPs