A forum + chat built on Nostr to manage your community
Svelte
11
152 commits
updated Sep 21, 2026
Squalk is a forum built on Nostr that permits to manage simple or large communities; in fact you can choose to setup it in "simple" or "full" mode. Simple mode expose a single forum, while in Full mode you can have as many forum as you like.
Each forum includes a chat feature in the right-hand sidebar, which is useful for quickly interacting with members.




Squalk is built on Nostr and implement NIP-29 and NIP-7D.
It needs a personal relay that supports NIP-29 to host the group(s) and a Blossom server for the uploads; Pyramid includes both and is the suggested solution.
Squalk is configured entirely through environment variables (all prefixed PUBLIC_, since they are read in the browser). Copy .env.example to .env and fill in the values; SvelteKit also reads .env.development (used by npm run dev) and .env.production (used by npm run build).
| Variable | Required | Default | Description |
|---|---|---|---|
PUBLIC_RELAY_URL | yes | — | WebSocket URL of the NIP-29 relay hosting the group(s), e.g. wss://relay.example.com. |
PUBLIC_MODE | no | simple | simple (a single forum) or full (multiple rooms). The admin can later upgrade simple → full at runtime. |
PUBLIC_GROUP_ID | in simple mode | — | The single forum's group id. Required when PUBLIC_MODE=simple; ignored in full mode, where rooms are selected at runtime. |
PUBLIC_TITLE | no | group name | Title shown in the top bar. When empty it falls back to the group's name. |
PUBLIC_JOINCODE | no | no | yes to show an invite-code field when a join request is rejected (for code-gated relays). |
PUBLIC_SSR | no | no | yes to render pages on the server (crawlable HTML, real 404s). The build then targets Node (node build) instead of a static bundle; see Deploying. |
PUBLIC_SSR_WARM | no | yes | With PUBLIC_SSR=yes, each client-side navigation also asks the server to fetch and cache that page, so a later refresh, shared link or crawler hit is served warm. Costs one extra relay query per navigation on the server; set to no to disable. |
PUBLIC_SSR_CACHE_FRESH | no | 300 | Seconds a server-rendered snapshot is served as is. Also the edge cache's s-maxage. |
PUBLIC_SSR_CACHE_STALE | no | 21600 | Seconds after which a snapshot is no longer served while being refreshed in the background (until then a stale page is answered instantly and updated for the next visitor). Also the edge cache's stale-while-revalidate. |
PUBLIC_SEARCH | no | no | yes to show a search box at the top of the homepage. Requires a relay with NIP-50 search support. |
PUBLIC_LABELS | no | — | Comma-separated discussion labels offered when composing, e.g. bug,feature,question. |
PUBLIC_BLOSSOM_URL | no | — | Blossom server URL used for media uploads, e.g. https://blossom.primal.net. Uploads are disabled when unset. |
PUBLIC_ACCENT_COLOR | no | #e32a6d | Override the accent (primary) color. Quote the value ("#00ff00") — an unquoted leading # is read as a comment. The hover shade is derived automatically. |
PUBLIC_SECONDARY_COLOR | no | #ffaf25 | Override the secondary color. Same quoting rule and derived hover shade as above. |
Squalk fills its sidebar links and personalizes the homepage and contacts page from NIP-23 long-form events (kind 30023) published to the same relay that hosts the group(s). Only events authored by a forum admin (a pubkey listed in a group's NIP-29 39001 admin event) are surfaced — the relay query is open, so the admin set is the trust gate.
Content is plain markdown. The sample .md files in the repo root (about.md, guidelines.md, homepage.md, contacts.md) are starting points you can adapt and publish.
Resources appear in the left sidebar and are served at /resource/<slug>. Publish a kind 30023 event with:
| Tag | Required | Purpose |
|---|---|---|
["t", "squalk-resource"] | yes | marks the event as a resource |
["d", "<slug>"] | yes | the d/identifier tag — also the URL slug (/resource/<slug>) |
["title", "<title>"] | recommended | label shown in the sidebar (falls back to the slug) |
["position", "<n>"] | optional | ordering hint, ascending |
The content field is the markdown body. Ordering: resources with a position come first, sorted ascending; ties and unpositioned resources fall back to alphabetical order by title. Because events are addressable, re-publishing with the same d slug updates the resource (newest wins).
Example (the about resource linked from the homepage):
kind: 30023
tags:
["t", "squalk-resource"]
["d", "about"]
["title", "About"]
["position", "1"]
content: "# About this forum\n\n..."
Partials inject custom markdown into fixed slots. There are exactly two slots: home (rendered at the top of the homepage) and contacts (the contacts page). Publish a kind 30023 event with:
| Tag | Required | Purpose |
|---|---|---|
["t", "squalk-partial"] | yes | marks the event as a partial |
["d", "home"] or ["d", "contacts"] | yes | the slot to fill (any other value is ignored) |
["title", "<title>"] | optional | not displayed in the slot, but useful for clients |
The newest admin-authored event for a slot wins. The home partial renders above the room list / discussions feed; a leading image URL on its own line (see homepage.md) is rendered as a banner image.
Once you've created a project and installed dependencies with npm install (or pnpm install or yarn), start a development server:
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
Two deployment targets share the same code base, selected by PUBLIC_SSR:
PUBLIC_SSR=no) — npm run build (or just build) writes a single-page bundle to build/; serve it from any web server with index.html as the fallback for unknown paths. Everything is fetched by the browser.PUBLIC_SSR=yes) — just build-ssr writes a Node app to build/. Pages arrive as crawlable HTML (threads, rooms, resources, contacts, with description/Open Graph tags, JSON-LD, a live robots.txt and sitemap.xml, and real 404s), then the browser takes over exactly as in the static build. The server reads the relay anonymously, so it only ever renders public content; members see their private rooms once the client is running.Preview a build locally with npm run preview (static) or node --env-file=.env.production build (server).
just deploy <host> rsyncs the static bundle to ~/squalk/ on the host and purges the Cloudflare cache.
just deploy-ssr <mode> builds with --mode <mode> (so vite bakes .env.<mode> in), ships the Node build, package.json/package-lock.json and .env.<mode> (as .env in the app dir, since the server reads the PUBLIC_* values at runtime), runs npm ci --omit=dev and restarts the instance's systemd unit. Everything instance-specific lives in .env.<mode>.local (gitignored, never shipped): DEPLOY_HOST, DEPLOY_DIR and DEPLOY_SERVICE (all required), plus the Cloudflare credentials (CF_ZONE_ID/CF_API_TOKEN) for the cache purge. Multiple instances coexist by giving each its own mode, directory, unit and port. On the host you need:
Node 22 or newer (the relay client uses the built-in WebSocket).
The unit from deploy/production-example.service, with ORIGIN set to the public URL — it feeds canonical links, robots.txt and the sitemap.
A reverse proxy in front of the port in PORT, replacing whatever served the static files before. With Caddy:
forum.example.com {
reverse_proxy 127.0.0.1:3000
}
If Cloudflare sits in front, a cache rule that caches HTML and respects origin headers: pages and snapshots are sent with Cache-Control: public, max-age=0, s-maxage=<PUBLIC_SSR_CACHE_FRESH>, stale-while-revalidate=<PUBLIC_SSR_CACHE_STALE> (by default served for five minutes, then refreshed in the background for up to six hours), the same windows the server's own in-memory cache uses. just deploy-ssr purges the cache after each release.
152 commits
Svelte
53.9%
TypeScript
43.9%
A forum + chat built on Nostr to manage your community
Svelte
11
152 commits
updated Sep 21, 2026
Squalk is a forum built on Nostr that permits to manage simple or large communities; in fact you can choose to setup it in "simple" or "full" mode. Simple mode expose a single forum, while in Full mode you can have as many forum as you like.
Each forum includes a chat feature in the right-hand sidebar, which is useful for quickly interacting with members.




Squalk is built on Nostr and implement NIP-29 and NIP-7D.
It needs a personal relay that supports NIP-29 to host the group(s) and a Blossom server for the uploads; Pyramid includes both and is the suggested solution.
Squalk is configured entirely through environment variables (all prefixed PUBLIC_, since they are read in the browser). Copy .env.example to .env and fill in the values; SvelteKit also reads .env.development (used by npm run dev) and .env.production (used by npm run build).
| Variable | Required | Default | Description |
|---|---|---|---|
PUBLIC_RELAY_URL | yes | — | WebSocket URL of the NIP-29 relay hosting the group(s), e.g. wss://relay.example.com. |
PUBLIC_MODE | no | simple | simple (a single forum) or full (multiple rooms). The admin can later upgrade simple → full at runtime. |
PUBLIC_GROUP_ID | in simple mode | — | The single forum's group id. Required when PUBLIC_MODE=simple; ignored in full mode, where rooms are selected at runtime. |
PUBLIC_TITLE | no | group name | Title shown in the top bar. When empty it falls back to the group's name. |
PUBLIC_JOINCODE | no | no | yes to show an invite-code field when a join request is rejected (for code-gated relays). |
PUBLIC_SSR | no | no | yes to render pages on the server (crawlable HTML, real 404s). The build then targets Node (node build) instead of a static bundle; see Deploying. |
PUBLIC_SSR_WARM | no | yes | With PUBLIC_SSR=yes, each client-side navigation also asks the server to fetch and cache that page, so a later refresh, shared link or crawler hit is served warm. Costs one extra relay query per navigation on the server; set to no to disable. |
PUBLIC_SSR_CACHE_FRESH | no | 300 | Seconds a server-rendered snapshot is served as is. Also the edge cache's s-maxage. |
PUBLIC_SSR_CACHE_STALE | no | 21600 | Seconds after which a snapshot is no longer served while being refreshed in the background (until then a stale page is answered instantly and updated for the next visitor). Also the edge cache's stale-while-revalidate. |
PUBLIC_SEARCH | no | no | yes to show a search box at the top of the homepage. Requires a relay with NIP-50 search support. |
PUBLIC_LABELS | no | — | Comma-separated discussion labels offered when composing, e.g. bug,feature,question. |
PUBLIC_BLOSSOM_URL | no | — | Blossom server URL used for media uploads, e.g. https://blossom.primal.net. Uploads are disabled when unset. |
PUBLIC_ACCENT_COLOR | no | #e32a6d | Override the accent (primary) color. Quote the value ("#00ff00") — an unquoted leading # is read as a comment. The hover shade is derived automatically. |
PUBLIC_SECONDARY_COLOR | no | #ffaf25 | Override the secondary color. Same quoting rule and derived hover shade as above. |
Squalk fills its sidebar links and personalizes the homepage and contacts page from NIP-23 long-form events (kind 30023) published to the same relay that hosts the group(s). Only events authored by a forum admin (a pubkey listed in a group's NIP-29 39001 admin event) are surfaced — the relay query is open, so the admin set is the trust gate.
Content is plain markdown. The sample .md files in the repo root (about.md, guidelines.md, homepage.md, contacts.md) are starting points you can adapt and publish.
Resources appear in the left sidebar and are served at /resource/<slug>. Publish a kind 30023 event with:
| Tag | Required | Purpose |
|---|---|---|
["t", "squalk-resource"] | yes | marks the event as a resource |
["d", "<slug>"] | yes | the d/identifier tag — also the URL slug (/resource/<slug>) |
["title", "<title>"] | recommended | label shown in the sidebar (falls back to the slug) |
["position", "<n>"] | optional | ordering hint, ascending |
The content field is the markdown body. Ordering: resources with a position come first, sorted ascending; ties and unpositioned resources fall back to alphabetical order by title. Because events are addressable, re-publishing with the same d slug updates the resource (newest wins).
Example (the about resource linked from the homepage):
kind: 30023
tags:
["t", "squalk-resource"]
["d", "about"]
["title", "About"]
["position", "1"]
content: "# About this forum\n\n..."
Partials inject custom markdown into fixed slots. There are exactly two slots: home (rendered at the top of the homepage) and contacts (the contacts page). Publish a kind 30023 event with:
| Tag | Required | Purpose |
|---|---|---|
["t", "squalk-partial"] | yes | marks the event as a partial |
["d", "home"] or ["d", "contacts"] | yes | the slot to fill (any other value is ignored) |
["title", "<title>"] | optional | not displayed in the slot, but useful for clients |
The newest admin-authored event for a slot wins. The home partial renders above the room list / discussions feed; a leading image URL on its own line (see homepage.md) is rendered as a banner image.
Once you've created a project and installed dependencies with npm install (or pnpm install or yarn), start a development server:
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
Two deployment targets share the same code base, selected by PUBLIC_SSR:
PUBLIC_SSR=no) — npm run build (or just build) writes a single-page bundle to build/; serve it from any web server with index.html as the fallback for unknown paths. Everything is fetched by the browser.PUBLIC_SSR=yes) — just build-ssr writes a Node app to build/. Pages arrive as crawlable HTML (threads, rooms, resources, contacts, with description/Open Graph tags, JSON-LD, a live robots.txt and sitemap.xml, and real 404s), then the browser takes over exactly as in the static build. The server reads the relay anonymously, so it only ever renders public content; members see their private rooms once the client is running.Preview a build locally with npm run preview (static) or node --env-file=.env.production build (server).
just deploy <host> rsyncs the static bundle to ~/squalk/ on the host and purges the Cloudflare cache.
just deploy-ssr <mode> builds with --mode <mode> (so vite bakes .env.<mode> in), ships the Node build, package.json/package-lock.json and .env.<mode> (as .env in the app dir, since the server reads the PUBLIC_* values at runtime), runs npm ci --omit=dev and restarts the instance's systemd unit. Everything instance-specific lives in .env.<mode>.local (gitignored, never shipped): DEPLOY_HOST, DEPLOY_DIR and DEPLOY_SERVICE (all required), plus the Cloudflare credentials (CF_ZONE_ID/CF_API_TOKEN) for the cache purge. Multiple instances coexist by giving each its own mode, directory, unit and port. On the host you need:
Node 22 or newer (the relay client uses the built-in WebSocket).
The unit from deploy/production-example.service, with ORIGIN set to the public URL — it feeds canonical links, robots.txt and the sitemap.
A reverse proxy in front of the port in PORT, replacing whatever served the static files before. With Caddy:
forum.example.com {
reverse_proxy 127.0.0.1:3000
}
If Cloudflare sits in front, a cache rule that caches HTML and respects origin headers: pages and snapshots are sent with Cache-Control: public, max-age=0, s-maxage=<PUBLIC_SSR_CACHE_FRESH>, stale-while-revalidate=<PUBLIC_SSR_CACHE_STALE> (by default served for five minutes, then refreshed in the background for up to six hours), the same windows the server's own in-memory cache uses. just deploy-ssr purges the cache after each release.
152 commits
Svelte
53.9%
TypeScript
43.9%