The hugo2nostr tool does something that sounds straightforward: take your Hugo blog posts and push them to the Nostr network as long-form articles. But the moment you look at what that actually involves, technically, the problem gets interesting. You’re mapping a filesystem-first content model to a cryptographically signed, append-mostly event log with eventual consistency and no real delete. That gap is worth understanding.
The Nostr Long-Form Content Model
Nostr is a relay-based protocol where everything is a signed JSON event. The minimal structure is:
{
"id": "<sha256 of the serialized event>",
"pubkey": "<32-byte hex public key>",
"created_at": 1700000000,
"kind": 30023,
"tags": [],
"content": "...",
"sig": "<64-byte Schnorr signature>"
}
The id is the SHA-256 hash of a specific serialization of the event fields. The sig is a Schnorr signature over that id using the secp256k1 curve. Relays are WebSocket servers that accept, store, and forward these events. There’s no central authority; anyone can run a relay, and clients connect to multiple relays simultaneously.
NIP-23 defines kind 30023 for long-form articles. This is a “parameterized replaceable event”: any event in the range 30000-39999 is keyed by both the author’s public key and a d tag, rather than by the event id alone. When you publish a new 30023 event with the same d value, relays replace the old one. This is the mechanism that makes editing work.
The recommended tags for NIP-23 are:
["d", "my-post-slug"]— the unique identifier, required["title", "My Post Title"]— human-readable title["published_at", "1700000000"]— original publication time as a Unix timestamp string["t", "systems"]— hashtags, one tag per topic["image", "https://..."]— cover image URL["summary", "A short description"]— for feeds and previews
The content field holds the full Markdown body of the post. The created_at field tracks when the event itself was created or last updated, while published_at is supposed to stay fixed at the original publication date. That distinction matters for clients that want to show a chronological feed of new posts versus an edit history.
The Hugo Side of the Mapping
Hugo posts are Markdown files with YAML, TOML, or JSON frontmatter:
---
title: "My Post Title"
date: 2024-01-15T10:00:00Z
tags: ["systems", "nostr"]
description: "A short description"
slug: "my-post-slug"
draft: false
---
Post body here...
The mapping from Hugo frontmatter to Nostr tags is mostly natural: slug becomes d, title stays title, date becomes published_at, tags become multiple t tags, description becomes summary. The Markdown content body flows into the content field. What hugo2nostr has to do is read this structure, build the event JSON, sign it with your Nostr private key (nsec), and broadcast it to one or more relay URLs over WebSocket.
Relay communication uses three message types. Publishing an event sends ["EVENT", <event-object>]. Querying events sends ["REQ", <subscription-id>, <filter>]. A filter for your own 30023 events looks like {"kinds": [30023], "authors": ["<your-pubkey>"], "#d": ["my-post-slug"]}. The relay responds with matching events followed by ["EOSE", <subscription-id>] to signal that historical results are complete.
This is how the sync-back feature works: query your own 30023 events from relays, deserialize the content and tags back into Hugo frontmatter, and write the files. The round-trip is lossier than it looks. Nostr events don’t know about Hugo’s draft flag, bundle configuration, section hierarchy, or any template-specific frontmatter fields. A two-way sync can only preserve what was explicitly serialized into Nostr tags.
The Deletion Problem
NIP-23’s deletion story is where the gap between a static site and an append-only event log becomes sharpest. Nostr has kind 5 (deletion request) events: you publish a 5 with an a tag pointing to the parameterized address of your long-form event:
{
"kind": 5,
"tags": [
["a", "30023:<your-pubkey>:my-post-slug"]
],
"content": "reason for deletion"
}
Relays that respect NIP-09 should stop serving the original event after receiving this. But “should” is the operative word. Relays are not required to honor deletion requests, and many don’t. Other users may have already copied the event to other relays before you requested deletion. In practice, publishing to Nostr should be treated as permanent broadcast, not as mutable publishing. If you delete a Hugo post, hugo2nostr can send the kind 5 event, but there’s no guarantee the post disappears from the network.
This is fundamentally different from deleting a page on your own Hugo site, where you control the server. It’s even different from ActivityPub federation, where delete activities are at least protocol-defined as mandatory to propagate (even if compliance varies in practice). On Nostr, the social contract around deletion is weaker by design. The network was built around censorship resistance first, and mutability second.
POSSE, Not Migration
The way to think about hugo2nostr is through the IndieWeb POSSE pattern: Publish on your Own Site, Syndicate Elsewhere. Your Hugo site remains the source of truth. Nostr becomes a distribution channel, much like cross-posting to Medium or Substack, except without an account on a centralized platform. You retain the original under your own domain, and the Nostr event reaches readers using Nostr clients like Primal, Amethyst, or Damus.
This framing makes the tool’s limitations acceptable. The sync-back feature is useful for bootstrapping a Hugo site from existing Nostr content, not for keeping two sources of truth in sync. The deletion behavior is fine as long as you understand that Nostr publishing is irreversible in the strong sense.
Other tools in this space have taken different approaches. nostr-sdk in Rust provides a full client library if you want to build your own publishing pipeline. There are WordPress plugins that can publish posts to Nostr relays. The nak CLI tool from fiatjaf is a lower-level Swiss Army knife for composing and querying Nostr events manually. hugo2nostr sits at a higher level of abstraction than nak and is more opinionated than building on a library directly, which is the right level for a workflow tool.
Key Management
The piece that the README glosses over but that deserves explicit attention is key management. Your Nostr identity is your private key (nsec). Every event you publish is signed with it. If you lose it, your identity is gone. If it leaks, anyone can publish as you. Feeding your nsec into a CLI tool means it needs to be stored somewhere, whether that’s an environment variable, a config file, or a secrets manager.
For a personal Hugo blog workflow, an environment variable (NOSTR_PRIVATE_KEY=nsec1...) is probably fine. For anything automated in CI, you want to use a dedicated Nostr key for publishing that isn’t connected to your main identity, the same principle as using a deploy key in SSH rather than your personal key.
Where This Fits
Nostr’s long-form content ecosystem is still maturing. Most Nostr clients were built around short-form notes (kind 1), and NIP-23 support varies. But the clients that do support it render the Markdown content well and show author metadata pulled from the kind 0 profile event, so there’s a functional reading experience for subscribers who follow your Nostr key.
hugo2nostr is a practical bridge for anyone who already has a Hugo site and wants to reach Nostr readers without duplicating work. The technical mapping between the two systems is coherent; the friction points (key management, one-way deletion, lossy sync) are fundamental to the Nostr model rather than tool-specific bugs. Understanding those constraints is what makes it possible to use the tool well.