What is Nostr?
npub106w…tw9x
2023-07-31 12:44:20

Building a Blog using SvelteKit and Nostr as a CMS (Part 1)

Introduction

Users of Nostr are most likely already familiar with the fact that there are multiple kinds of events on Nostr. It’s not just for Twitter like experiences. In this series of posts we’ll take a look at how to build a fully functional blog with the following features:

  • Setting up and getting started with SvelteKit and Tailwind
  • How to fetch our posts from Nostr
  • Dynamic SSR or prerendering using SvelteKit
  • Building a simple cache adapter for Cachified that uses SQLite as a storage
  • Adding a contact us form that sends a DM to use using Nostr.

We will be adding features as we go. At some point in the future I want to add some kind of authentication (using NIP-07) as well as comments, reactions and analytics.

If you’re just interested in checking out the code, you can find it on GitHub. Each part and section has a separate branch.

This post and the upcoming parts will also be available on my blog where you can see the end result.

Getting started with SvelteKit

Getting the project set up

SvelteKit is one of the simplest solutions out there if you want to build your own blog. It’s more work than just using something like Ghost but you get the advantage of customizability.

I will assume that you have some experience using the terminal here. To get going run the following commands somewhere on your compouter:

npm create svelte@latest my-blog

Select the following options:

  1. Which template: Skeleton project
  2. Type Checking: Yes, using Typescript syntax
  3. Addition options: ESLint, Prettier

When the installation process is done, run the following commands:

cd my-blog
npm install
npm run dev -- --open

You’ll also need an editor. I prefer VSCode (or in the spirit of Nostr, go for VSCodium). If you’re new to Svelte, make sure to download the Svelte extension on the marketplace.

To make our website look somewhat decent, let’s also add Tailwind to handle the styling. Run npx svelte-add@latest tailwindcss in the terminal.

If you are completely new to Svelte and SvelteKit I would recommend going through the learning material to get a basic understanding of what is going on.

Fetching posts from Nostr

Before we actually set things up to show only our own posts, let’s start with just fetching the latest 5 events from a couple of different relays. We’ll be using @pablof7z’s excellent library @nostr-dev-kit/ndk. We’ll also need to polyfill the websocket functionality since node doesn’t support this out of the box:

npm install @nostr-dev-kit/ndk websocket-polyfill

Once it’s installed, go ahead and create a file called nostr.ts in the following path: lib/server/. This is where we will be interacting with Nostr.

First off, we’ll need a list of relays that support NIP-04 (long form content). I used wss://purplepag.es. Find more on nostr.watch. Let’s write some code.

// lib/server/nostr.ts
import NDK from "@nostr-dev-kit/ndk";
import 'websocket-polyfill'

const relays = ['wss://relay.damus.io', 'wss://purplepag.es']

class Nostr {
    private ndk: NDK

    constructor(relays: string[]) {
        this.ndk = new NDK({ explicitRelayUrls: relays })
    }
    
    public async init() {
        try {
            await this.ndk.connect()
        } catch (error) {
            console.error('Error connecting to NDK:', error)
        }
    }
}

export const ndk = new Nostr(relays);

We’recreating a simple class and instantiating NDK inside of it. We’re then passing in our list of relays and exporting an instance of this class that we can use in the rest of our application. We’ve also added an init() method that we’ll run when we start our application that connects to the relays.

Next we’ll create a method to fetch our posts but first, let’s add an interface so we get some type safety. In src/app.d.ts go ahead and add the following interface:

...
interface Article {
	slug?: string,
	d?: string,
	title: string,
	summary: string,
	tags: string[]
	published_at: string,
}

Add a method to our Nostr class in nostr.ts. This will be the method that fetches the latest 5 events from our relays.

...
import type { NDKEvent } from "@nostr-dev-kit/ndk";

class Nostr {
    ...
    public async getAllArticles(): Promise<Article[]> {
        let events: NDKEvent[];
            try {
                events = [...await this.ndk.fetchEvents({ kinds: [ 30023 ], limit: 5 })]
            } catch (error) {
                console.error('Error fetching events:', error)
                events = []
            }
        const articles = events.map(({tags}) => {
            const { summary, image, published_at, title, t, d } = mapTags(tags)

            return {
                slug: generateSlug(title as string),
                d,
                summary, 
                image, 
                published_at, 
                title,
                tags: t || []
                }
            })
        if (articles.length === 0) {
            return []
        }
        
        return articles as Article[]
  }
}

Let’s try to understand what’s going on here. First we’re importing the type NDKEvent. This is what we’ll get back from NDK when we fetch our events.

We’re fetching out event here: events = [...await this.ndk.fetchEvents({ kinds: [ 30023 ], limit: 5 })]. NDK returns a set of NDKEvents. I like to work with arrays so I convert them to just that by spreading them into an empty array. The argument that we pass into the fetchEvents method is a filter object. It follows the NIP-01 specification, so you can put all sorts of things in here. You can read more about in the spec. In our case we’re using the 30023 kind which is the long-form content kind and we’re limiting the number of results to 5.

Nostr events have a few fields that we need to pay attention to. Often you’ll find the actual body of a message in the content field, but other things that are specific to the kind will be found in the t field as an array of tuples so in order to more easily use this information we’ll need to go through and clean it all up.

The keen-eyed among you have probably already spotted the mapTags() and generateSlug() functions here. Create a new file lib/server/utils.ts and inside add the following:

import type { NDKTag } from "@nostr-dev-kit/ndk";

export function generateSlug(headline: string): string {
    const lowerCaseHeadline = headline.toLowerCase();
    const slug = lowerCaseHeadline.replace(/[^\w\s]/g, '').replace(/\s+/g, '-');
    return slug;
  }

export function mapTags(tuple_tags: NDKTag[]): Record<string, string | string[]> {
  let tags = tuple_tags.reduce((acc, [key, value]) => {
      if (!acc[key]) acc[key] = value
      else if (typeof acc[key] === 'string') {
        acc[key] = [acc[key], value] as string[]
      } else {
        (acc[key] as string[]).push(value)
      }
      return acc
  }, {} as Record<string, string | string[]>)
    
  return tags
}

You don’t need to understand what’s going on here other than the fact that the functions will generate a slug and map the t field into usable fields.

Let’s go back to our lib/server/nostr.ts file and import these.

import { generateSlug, mapTags } from './utils'

class Nostr {
    ...
}

Ok. with that done we should be able to fetch events. Let’s move on to the SvelteKit bits.

Showing posts on the front-end

SvelteKit apps are server-rendered by default for first load performance and SEO. This is important in order to get your posts ranked on search engines like Google or Bing.

First off, we need to make sure our Nostr client is initiated. Create a file called hooks: /hooks.server.ts and add the following:

import type { Handle } from '@sveltejs/kit';
import { ndk } from '$lib/server/nostr'

await ndk.init()

export const handle = (async ({ event, resolve }) => {
    const response = await resolve(event);
    return response;
}) satisfies Handle;

This file runs when the server starts. The handle hook runds on each request, we’re not using it here but we need to add it in order for the rest of the file to be run.

Loading our events

Let’s start with loading our events. Create a file routes/+page.server.ts. In here we’ll call the get articles method on our Nostr client.

import { ndk } from '$lib/server/api';

export const load = async () => {
    const articles = await ndk.getAllArticles()

    return {
        articles
    };
}

A load function in SvelteKit is used to return data to the front-end. It runs whenever a request is sent to the corresponding route. In this case the root: /.

Since we have already initiated our Nostr client in hooks.server.ts we don’t need to do it here. Simply call ndk.getAllArticles() and return the result.

Building the front-end

Let’s make something appear on the page. Open up routes/+page.svelte. Remove the contents of the file and add the following:

<script lang="ts">
	export let data;
</script>

<h1>My blog</h1>

<ul class="grid gap-7 p-8">
	{#each data.articles as article}
		<li>
			{article.title}
		</li>
	{/each}
</ul>

The things we returned in our load function will be available in our data prop.

To get a list of our posts we use an {#each} block and loop through our articles.

Yay! Titles of posts! Amazing. Let’s make it look a bit better. Create a new component: routes/Summary.svelte and add the following:

<script lang="ts">
    export let article: Article
</script>

<div class="bg-slate-200 mx-auto rounded-xl py-6 px-6 prose prose-invert hover:scale-105 transition-transform">
    <h2 class="mb-3 font-bold text-lg">{article.title}</h2>
    <div>
        {@html article.summary}
    </div>
</div>

And finally import it and use it in /routes/+page.svelte:

<script lang="ts">
    import Summary from './Summary.svelte'
    ...
</script>

<h1>My blog</h1>

<ul class="grid gap-7 p-8">
	{#each data.articles as article}
		<li>
			<Summary {article} />
		</li>
	{/each}
</ul>

Last but not least we’ll want to only fetch our own posts. To do that modify the filter we used in the Nostr class (lib/server/nostr.ts):

...
events = [...await this.ndk.fetchEvents({ kinds: [ 30023 ], authors: [YOUR_PUBKEY_GOES_HERE, SOME_OTHER_PUBKEY])]
...

Make sure that the pubkey you’re using is in a hex format. If you only have your npub1..... style key you can use something like Nostr Check to convert it.

You will probably notice that it takes quite a while to load your page at this point. In the next part we’ll take a look at how we can fix this by using caching.

Until next time! 🤘

Author Public Key
npub106wfyjh9y4wftfz5629w392j58sgr7pg44xuqwq4pz6sjxyvse0qcptw9x