Skip to main content

Build Your First App

Let's build a simple Nostr client that can read and post notes. By the end, you'll understand the core concepts through hands-on code.

What We'll Build

A minimal web app that:

  • Generates or imports a keypair
  • Connects to relays
  • Fetches recent notes
  • Posts new notes

Prerequisites

  • Node.js 18+ installed
  • Basic JavaScript/TypeScript knowledge
  • A code editor

Step 1: Project Setup

Create a new project:

mkdir my-nostr-app
cd my-nostr-app
npm init -y
npm install nostr-tools

Create an index.html:

<!DOCTYPE html>
<html>
<head>
<title>My First Nostr App</title>
<style>
body { font-family: system-ui; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
.note { border: 1px solid #ddd; padding: 1rem; margin: 1rem 0; border-radius: 8px; }
.note-author { color: #666; font-size: 0.875rem; }
.note-content { margin-top: 0.5rem; }
textarea { width: 100%; padding: 0.5rem; }
button { margin-top: 0.5rem; padding: 0.5rem 1rem; cursor: pointer; }
</style>
</head>
<body>
<h1>My Nostr App</h1>

<div id="key-section">
<p>Your public key: <code id="pubkey">Loading...</code></p>
<button id="new-key">Generate New Key</button>
</div>

<hr>

<div id="post-section">
<h2>Post a Note</h2>
<textarea id="note-input" rows="3" placeholder="What's on your mind?"></textarea>
<button id="post-btn">Post</button>
</div>

<hr>

<div id="feed-section">
<h2>Recent Notes</h2>
<div id="notes"></div>
</div>

<script type="module" src="app.js"></script>
</body>
</html>

Step 2: Connect to Relays

Create app.js:

import {
SimplePool,
generateSecretKey,
getPublicKey,
finalizeEvent
} from 'nostr-tools';
import { nip19 } from 'nostr-tools';

// Relay list
const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band'
];

// Initialize pool for managing relay connections
const pool = new SimplePool();

// Key management
let sk = null;
let pk = null;

function initKeys() {
// Check localStorage for existing key
const stored = localStorage.getItem('nostr_sk');
if (stored) {
sk = Uint8Array.from(JSON.parse(stored));
} else {
sk = generateSecretKey();
localStorage.setItem('nostr_sk', JSON.stringify(Array.from(sk)));
}
pk = getPublicKey(sk);

// Display npub
const npub = nip19.npubEncode(pk);
document.getElementById('pubkey').textContent = npub.slice(0, 20) + '...';
}

// Generate new key button
document.getElementById('new-key').addEventListener('click', () => {
if (confirm('Generate new key? You will lose access to the old one.')) {
sk = generateSecretKey();
localStorage.setItem('nostr_sk', JSON.stringify(Array.from(sk)));
pk = getPublicKey(sk);
const npub = nip19.npubEncode(pk);
document.getElementById('pubkey').textContent = npub.slice(0, 20) + '...';
}
});

// Initialize on load
initKeys();

Step 3: Fetch Notes

Add the subscription logic:

async function fetchNotes() {
const notesDiv = document.getElementById('notes');
notesDiv.innerHTML = '<p>Loading...</p>';

try {
// Subscribe to kind 1 (text notes) from the last hour
const events = await pool.querySync(RELAYS, {
kinds: [1],
limit: 20,
since: Math.floor(Date.now() / 1000) - 3600 // Last hour
});

// Sort by created_at (newest first)
events.sort((a, b) => b.created_at - a.created_at);

// Render notes
notesDiv.innerHTML = '';
for (const event of events) {
const noteDiv = document.createElement('div');
noteDiv.className = 'note';

const npub = nip19.npubEncode(event.pubkey);
const date = new Date(event.created_at * 1000).toLocaleString();

noteDiv.innerHTML = `
<div class="note-author">${npub.slice(0, 16)}... · ${date}</div>
<div class="note-content">${escapeHtml(event.content)}</div>
`;
notesDiv.appendChild(noteDiv);
}

if (events.length === 0) {
notesDiv.innerHTML = '<p>No notes found in the last hour.</p>';
}
} catch (err) {
notesDiv.innerHTML = `<p>Error: ${err.message}</p>`;
}
}

function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

// Fetch notes on load
fetchNotes();

Step 4: Post Notes

Add posting functionality:

document.getElementById('post-btn').addEventListener('click', async () => {
const input = document.getElementById('note-input');
const content = input.value.trim();

if (!content) {
alert('Please enter some text');
return;
}

const btn = document.getElementById('post-btn');
btn.disabled = true;
btn.textContent = 'Posting...';

try {
// Create and sign the event
const event = finalizeEvent({
kind: 1,
content: content,
tags: [],
created_at: Math.floor(Date.now() / 1000)
}, sk);

// Publish to all relays
await Promise.all(
RELAYS.map(relay => pool.publish([relay], event))
);

// Clear input and refresh
input.value = '';
alert('Posted successfully!');
fetchNotes();
} catch (err) {
alert(`Error posting: ${err.message}`);
} finally {
btn.disabled = false;
btn.textContent = 'Post';
}
});

Step 5: Run the App

You'll need a bundler to handle the imports. Use Vite for simplicity:

npm install vite
npx vite

Open http://localhost:5173 in your browser.

Complete Code

Here's the full app.js:

import {
SimplePool,
generateSecretKey,
getPublicKey,
finalizeEvent
} from 'nostr-tools';
import { nip19 } from 'nostr-tools';

const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band'
];

const pool = new SimplePool();
let sk = null;
let pk = null;

function initKeys() {
const stored = localStorage.getItem('nostr_sk');
if (stored) {
sk = Uint8Array.from(JSON.parse(stored));
} else {
sk = generateSecretKey();
localStorage.setItem('nostr_sk', JSON.stringify(Array.from(sk)));
}
pk = getPublicKey(sk);
const npub = nip19.npubEncode(pk);
document.getElementById('pubkey').textContent = npub.slice(0, 20) + '...';
}

function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

async function fetchNotes() {
const notesDiv = document.getElementById('notes');
notesDiv.innerHTML = '<p>Loading...</p>';

try {
const events = await pool.querySync(RELAYS, {
kinds: [1],
limit: 20,
since: Math.floor(Date.now() / 1000) - 3600
});

events.sort((a, b) => b.created_at - a.created_at);
notesDiv.innerHTML = '';

for (const event of events) {
const noteDiv = document.createElement('div');
noteDiv.className = 'note';
const npub = nip19.npubEncode(event.pubkey);
const date = new Date(event.created_at * 1000).toLocaleString();
noteDiv.innerHTML = `
<div class="note-author">${npub.slice(0, 16)}... · ${date}</div>
<div class="note-content">${escapeHtml(event.content)}</div>
`;
notesDiv.appendChild(noteDiv);
}

if (events.length === 0) {
notesDiv.innerHTML = '<p>No notes found in the last hour.</p>';
}
} catch (err) {
notesDiv.innerHTML = `<p>Error: ${err.message}</p>`;
}
}

document.getElementById('new-key').addEventListener('click', () => {
if (confirm('Generate new key? You will lose access to the old one.')) {
sk = generateSecretKey();
localStorage.setItem('nostr_sk', JSON.stringify(Array.from(sk)));
pk = getPublicKey(sk);
const npub = nip19.npubEncode(pk);
document.getElementById('pubkey').textContent = npub.slice(0, 20) + '...';
}
});

document.getElementById('post-btn').addEventListener('click', async () => {
const input = document.getElementById('note-input');
const content = input.value.trim();

if (!content) {
alert('Please enter some text');
return;
}

const btn = document.getElementById('post-btn');
btn.disabled = true;
btn.textContent = 'Posting...';

try {
const event = finalizeEvent({
kind: 1,
content: content,
tags: [],
created_at: Math.floor(Date.now() / 1000)
}, sk);

await Promise.all(RELAYS.map(relay => pool.publish([relay], event)));
input.value = '';
alert('Posted successfully!');
fetchNotes();
} catch (err) {
alert(`Error posting: ${err.message}`);
} finally {
btn.disabled = false;
btn.textContent = 'Post';
}
});

initKeys();
fetchNotes();

What's Next?

You've built a working Nostr client! Here are ways to extend it:

Add Features

  • Display user profiles (kind 0)
  • Show reactions (kind 7)
  • Implement replies (using tags)
  • Add image support

Improve Security

  • Integrate NIP-07 for key management
  • Don't store keys in localStorage in production

Learn More

Troubleshooting

Notes not loading?

  • Check browser console for WebSocket errors
  • Some relays may be down—try different ones

Post not appearing?

  • Relays may have rate limits
  • Verify the event format in console

Key issues?

  • Clear localStorage and regenerate
  • Check browser's developer tools for errors