How to build a nostr-driven blog with NextJS

Imagine content could live on the internet, completely decoupled from the platform someone chooses to consume it on. Well, guess what, the future is here!

The nostr protocol is perfect for sharing content because it is client-agnostic. It even comes with a special event type for long-form content, taxonomy, markdown support, etc.

You can use nostr as your personal headless content management platform and weave it into your NestJS website. In this blog post, I'll explain how.

Requirements

Before we get started lets talk about what needs to happen in order to display your nostr posts on your personal website. We are going to use a couple of libraries as well as NextJS.

Getting content from the nostr network

First, we gotta connect to some nostr relays and query them for your blog posts. We are going to use nostr-tools for that. Nostr-tools is like a Swiss army knife for anything nostr-related

Extracting metadata from the posts

SEO is important for any web blog. So we are going to use the native support for taxonomy in nostr long-form content to generate metadata like tags and OG data. We are going to use NextJS inbuilt metadata functionality.

Rendering markdown

Finally, we need to take the markdown string we got from the relay and convert it into HTML to return it from our server. There are many libraries to render Markdown in React, but only a few support React Server Components. We are going to use next-mdx-remote for this.

Enyoing the content?

Support this blog and leave a tip!

Creating our blog post component

Our BlogPost component will be responsible for retrieving a specific blog post from the nostr network and then parsing its content into HTML. This component is going to be an asynchronous server-rendered component. Here is a basic code example.

import { MDXRemote } from "next-mdx-remote/rsc";

async function BlogPost({ nevent }: {nevent: `nevent1${string}`}) {
  const event = await getCachedSinglePost(nevent);
  if (!event || event.pubkey !== authorData.pubkey) {
    notFound();
  }
  return (
    <main>
      <MDXRemote
        source={event.content}
      />
    </main>
  );
}

export default BlogPost;

So what is going on here? BlogPost is an asynchronous component that receives a nevent entity as prop. It then proceeds to look up the event in question. If none is found, it will return a 404 using NextJS' notFound function. Finally it passes the event's content on to MDXRemote which takes care of the parsing.

Getting a post from nostr

Lets take a look at our getCachedSinglePost function, shall we?

import { Event, SimplePool, nip19 } from "nostr-tools";
import { cache } from "react";

const pool = new SimplePool();

async function getSinglePost(nevent: Nevent): Promise<Event | undefined> {
  const {
    data: { id, relays },
  } = nip19.decode(nevent);
  const relayList =
    relays && relays.length > 0 ? relays : ["wss://relay.damus.io"];
  const event = await pool.get(
    relayList,
    {
      ids: [id],
    },
    { maxWait: 2500 },
  );
  if (!event) {
    return undefined;
  }
  return event;
}

export const getCachedSinglePost = cache(getSinglePost);

This function is responsible for parsing our nevent string, connecting to the nostr network and receiving the event in question. Here is a quick rundown:

  1. Parsing the nevent entity for the event id and relay hints
  2. If no relay hints are present, we fall back to a default
  3. We are using nostr-tools SimplePool to connect to a pool of relays and look up the id in question
  4. If after 2.5 no event is found we return undefined, otherwise the event
  5. Optional: In order to avoid unnecessary duplications we use react's cache function. It will make sure that even if called multiple times in a single request, it is only executed once.

Extracting additional info

Nostr long-form content comes with standardized tags for featured-images, taxonomy, summary, title, and so on. All that information can be found in an events tags array. What follows is a utility function to extract these tags from an event:

export function getTagValue(e: Event, tagName: string, position: number) {
  for (let i = 0; i < e.tags.length; i++) {
    if (e.tags[i][0] === tagName) {
      return e.tags[i][position];
    }
  }
}

const title = getTagValue(event, "title", 1);
const summary = getTagValue(event, "summary", 1);

This can be used to add a H1 heading to our BlogPost component:

async function BlogPost({ nevent }: {nevent: `nevent1${string}`}) {
  const event = await getCachedSinglePost(nevent);
  if (!event || event.pubkey !== authorData.pubkey) {
    notFound();
  }
  const title = getTagValue(event, "title", 1);
  return (
    <main>
      <h1>{title}</h1>
      <MDXRemote
        source={event.content}
      />
    </main>
  );
}

export default BlogPost;

Styling our Markdown

MDXRemote takes care of parsing our Markdown into HTML. If you have global styles setup for all the required HTML tags, then you are good to go, but if you rely on classes, you will need to add custom components to MDXRemote. To do so, create an object components with a function representing each Tag you wish to replace and pass it to MDXRemote. Our final might look something like this:

const components = {
  p: (props: any) => (
    <p {...props} className="my_custom_class">
      {props.children}
    </p>
  ),
};


async function BlogPost({ nevent }: {nevent: `nevent1${string}`}) {
  const event = await getCachedSinglePost(nevent);
  if (!event || event.pubkey !== authorData.pubkey) {
    notFound();
  }
  const title = getTagValue(event, "title", 1);
  return (
    <main>
      <h1>{title}</h1>
      <MDXRemote
        source={event.content}
        components={components}
      />
    </main>
  );
}

export default BlogPost;

Creating a blog post page with metadata

In the next step, we will create a dynamic route segment and within render the component we just created. For my blog, I went with domain.com/blog/[nevent]. To achieve this path with NextJS' app router we create a file with this path: src/app/blog/[nevent]/page.tsx. The brackets will tell NextJS that this is a dynamic route segment and will automatically pass the parameter to the page component as props.

Here is our starting point:

function BlogEntry({ params }: {params: {nevent: `nevent1${string}`}}) {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <BlogPost nevent={params.nevent} />
    </Suspense>
  );
}

export default BlogEntry;

In this page component, we use the BlogPost component we created in the first step. We pass to it the nevent that we receive from the router. Because this component is asynchronously rendered, we wrap it in a Suspense boundary, in order to render a fallback, while the BlogPost component is still fetching data.

Adding metadata

Of course, our blog posts need metadata for SEO and sharing. NextJS comes with an inbuilt function that lets you dynamically generate a metadata object, that will be sent along with each request.

We simply add the following code to our page.tsx file (but outside the component itself).

export async function generateMetadata({
  params,
}: BlogEntryProps): Promise<Metadata> {
  const event = await getSinglePost(params.nevent);
  if (!event) {
    return {};
  }
  const title = getTagValue(event, "title", 1);
  const image = getTagValue(event, "image", 1);
  return {
    title,
    openGraph: {
      title,
      images: [image || ""],
    },
    twitter: {
      title,
      images: [image || ""],
    },
  };
}

Notice that we called getSinglePost a second time, but because we wrapped its functionality in cache it will only get executed once. We then use our getTagValue utility to extract metadata from our event and return a Metadata object as required by NextJS.

Statically prerendering your posts

Dynamic route segments are rendered on request by default, but for performance reasons it might be wise to render them beforehand. NextJS allows you to specify a list of route ids that you wish to render at build time using the generateStaticParams function. Simply add this function outside the page component but in the same file.

export async function generateStaticParams() {
  const [events, relays] = await getCachedAllPosts("YOUR PUBKEY HERE");
  return events.map((event) => ({
    nevent: nip19.neventEncode({ id: event.id, relays }),
  }));
}

getAllPosts is another cached function that looks like this:

const relays = ["wss://relay.damus.io"];

async function getPostsByAuthor(pubkey: string): Promise<[Event[], string[]]> {
  const events = await pool.querySync(relays, {
    kinds: [30023],
    authors: [pubkey],
  });
  return [events, relays];
}

export const getCachedAllPosts = cache(getPostsByAuthor);

This function will get all long-form posts on a list of relays and then add their nevent entity ids to an array as expected by NextJS. Next will then use this array to generate these paths at build time.

Wrapping Up

We have now successfully created a BlogPost component that looks up an event on nostr and parses its Markdown contents into HTML. We also created a dynamic route segment that receives and nevent entity as a parameter, looks up metadata for it and renders our BlogPost component.

I used the exact same approaches to build my personal blog my2sats, so if you are interested in a real-life code example take a look at its repository (and let me know if you find any bugs ^^).

If you have any questions about this guide please contact me on nostr or on Twitter

Image of Jibun AI author Starbuilder

egge

Building current; a nostr + bitcoin client! https://app.getcurrent.io 💜⚡️🧡

Share this post