Skip to content

React Content Rendering ​

This guide covers rendering Nostr content in React using useRenderedContent for text content and ReactMarkdown for markdown articles.

useRenderedContent Hook ​

The useRenderedContent hook renders NAST content in React components.

Basic Usage ​

tsx
import { useRenderedContent } from "applesauce-react/hooks";

function NoteContent({ event }) {
  const components = {
    text: ({ node }) => <span>{node.value}</span>,
    link: ({ node }) => (
      <a href={node.href} target="_blank">
        {node.value}
      </a>
    ),
  };

  const content = useRenderedContent(event, components);
  return <div className="whitespace-pre-wrap">{content}</div>;
}

ComponentMap ​

Define how each NAST node type renders. All components are optional:

tsx
import { isImageURL, isVideoURL } from "applesauce-core/helpers";

const components = {
  text: ({ node }) => <span>{node.value}</span>,
  link: ({ node }) => {
    if (isImageURL(node.href)) return <img src={node.href} loading="lazy" />;
    if (isVideoURL(node.href)) return <video src={node.href} controls />;
    return (
      <a href={node.href} target="_blank" rel="noopener noreferrer">
        {node.value}
      </a>
    );
  },
  mention: ({ node }) => <a href={`https://njump.me/${node.encoded}`}>@{node.encoded.slice(0, 8)}...</a>,
  hashtag: ({ node }) => <span className="text-orange-500">#{node.hashtag}</span>,
  emoji: ({ node }) => <img src={node.url} alt={node.code} className="inline w-6 h-6" />,
  gallery: ({ node }) => (
    <div className="flex gap-2">
      {node.links.map((link, i) => (
        <img key={i} src={link} className="max-h-64" />
      ))}
    </div>
  ),
};

Options ​

tsx
// Truncate content
const content = useRenderedContent(event, components, { maxLength: 280 });

// Custom transformers
const content = useRenderedContent(event, components, {
  transformers: [links, nostrMentions, hashtags],
});

// Custom cache key
const content = useRenderedContent(event, components, { cacheKey: Symbol("custom") });

// Disable caching
const content = useRenderedContent(event, components, { cacheKey: null });

// Override content
const content = useRenderedContent(event, components, { content: customContent });
tsx
import { LinkRenderer } from "applesauce-react/helpers";

const imageRenderer: LinkRenderer = (url) => (isImageURL(url) ? <img src={url.toString()} /> : null);

const content = useRenderedContent(event, components, {
  linkRenderers: [imageRenderer],
});

ReactMarkdown Integration ​

For markdown content (kind 30023 articles):

tsx
import ReactMarkdown from "react-markdown";
import { remarkNostrMentions } from "applesauce-content/markdown";
import remarkGfm from "remark-gfm";

function ArticleContent({ content }) {
  const plugins = useMemo(() => [remarkGfm, remarkNostrMentions], []);
  return <ReactMarkdown remarkPlugins={plugins}>{content}</ReactMarkdown>;
}

Styling Markdown ​

Define components at module level for static styling:

tsx
const plugins = [remarkGfm, remarkNostrMentions];
const components = {
  h1: (props) => <h1 className="text-3xl font-bold" {...props} />,
  h2: (props) => <h2 className="text-2xl font-bold" {...props} />,
  a: (props) => <a className="link" target="_blank" rel="noopener noreferrer" {...props} />,
  table: (props) => (
    <div className="overflow-x-auto">
      <table className="table" {...props} />
    </div>
  ),
};

function Article({ content }) {
  return (
    <ReactMarkdown remarkPlugins={plugins} components={components}>
      {content}
    </ReactMarkdown>
  );
}
tsx
function UserMention({ pubkey }) {
  const profile = use$(() => eventStore.profile(pubkey), [pubkey]);
  return <span>@{profile?.displayName || "..."}</span>;
}

const components = {
  a: ({ href, children, ...props }) => {
    if (href?.startsWith("nostr:")) {
      const decoded = decodePointer(href.slice(6));
      if (decoded.type === "npub") return <UserMention pubkey={decoded.data.pubkey} />;
    }
    return (
      <a href={href} target="_blank" rel="noopener noreferrer" {...props}>
        {children}
      </a>
    );
  },
};

Integration ​

With Cast Events ​

tsx
function NoteCard({ note }: { note: Note }) {
  const profile = use$(note.author.profile$);
  const content = useRenderedContent(note.event, components);

  return (
    <div>
      <img src={profile?.picture} />
      <span>{profile?.displayName}</span>
      {content}
    </div>
  );
}

With Event Loaders ​

tsx
createEventLoaderForStore(eventStore, pool, {
  lookupRelays: ["wss://purplepag.es/"],
});

// Mentions now auto-load their targets
const components = {
  mention: ({ node }) => {
    const profile = use$(() => eventStore.profile(node.decoded.data.pubkey), [node.decoded.data.pubkey]);
    return <span>@{profile?.displayName || "..."}</span>;
  },
};

Interactive Components ​

tsx
function NoteContent({ event, onHashtagClick }) {
  const components = useMemo(
    () => ({
      hashtag: ({ node }) => <button onClick={() => onHashtagClick(node.hashtag)}>#{node.hashtag}</button>,
    }),
    [onHashtagClick],
  );

  return <div>{useRenderedContent(event, components)}</div>;
}

Best Practices ​

Memoize Components ​

tsx
// Static: define at module level
const components = {
  text: ({ node }) => <span>{node.value}</span>,
  link: ({ node }) => <a href={node.href}>{node.value}</a>,
};

// Dynamic: use useMemo
const components = useMemo(() => ({ ... }), [dependencies]);

Security ​

tsx
// Always use target="_blank" with rel="noopener noreferrer"
link: ({ node }) => <a href={node.href} target="_blank" rel="noopener noreferrer">{node.value}</a>,

Media Handling ​

tsx
link: ({ node }) => {
  if (isImageURL(node.href)) {
    return <img src={node.href} loading="lazy" onError={(e) => (e.currentTarget.src = "/placeholder.png")} />;
  }
  return <a href={node.href}>{node.value}</a>;
},

Truncation ​

tsx
const content = useRenderedContent(event, components, { maxLength: 280 });
if (getParsedContent(event).truncated) {
  // Show "read more"
}

Content Validation ​

tsx
function NoteContent({ event }) {
  if (!event?.content) return <div>No content</div>;
  return <div>{useRenderedContent(event, components)}</div>;
}

Choose Renderer by Kind ​

tsx
function EventContent({ event }) {
  if (event.kind === 30023) return <ReactMarkdown remarkPlugins={plugins}>{event.content}</ReactMarkdown>;
  if (event.kind === 1) return <div>{useRenderedContent(event, components)}</div>;
  return <p className="whitespace-pre-wrap">{event.content}</p>;
}