diff --git a/src/components/NewsletterBackfillPanel.tsx b/src/components/NewsletterBackfillPanel.tsx
new file mode 100644
index 0000000..aa8b028
--- /dev/null
+++ b/src/components/NewsletterBackfillPanel.tsx
@@ -0,0 +1,108 @@
+import { useState } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { Archive, Loader2, Check, AlertTriangle } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useNostrPublish } from '@/hooks/useNostrPublish';
+import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
+import { useToast } from '@/hooks/useToast';
+import { isAdminPubkey } from '@/lib/admins';
+import { buildIssueArticle, issueSlug, type StaticIssueLike } from '@/lib/newsletterArticle';
+
+/**
+ * Admin-only one-time backfill: publishes the static archive issues
+ * (NEWSLETTER_ISSUES, passed in as a prop to avoid an import cycle with the
+ * page) to Nostr as NIP-23 articles. Skips any issue whose slug already exists
+ * on-relay, so it's safe to run repeatedly. The rich static page rendering is
+ * untouched — this just mirrors the archive onto Nostr for subscribers/readers.
+ */
+export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[] }) {
+ const { user } = useCurrentUser();
+ const { mutateAsync: publish } = useNostrPublish();
+ const { data: liveIssues } = useNewsletterIssues();
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+
+ const [running, setRunning] = useState(false);
+ const [done, setDone] = useState(false);
+
+ if (!isAdminPubkey(user?.pubkey) || !issues || issues.length === 0) return null;
+
+ // Which static issues aren't on Nostr yet? (match by slug)
+ const liveSlugs = new Set((liveIssues ?? []).map((i) => i.identifier));
+ const missing = issues.filter((i) => !liveSlugs.has(issueSlug(i.number)));
+
+ const handleBackfill = async () => {
+ setRunning(true);
+ let published = 0;
+ const failures: number[] = [];
+ // Publish oldest → newest so numbering reads naturally on relays.
+ const ordered = [...missing].sort((a, b) => a.number - b.number);
+ for (const issue of ordered) {
+ try {
+ await publish(buildIssueArticle(issue));
+ published++;
+ } catch {
+ failures.push(issue.number);
+ }
+ }
+ setRunning(false);
+ setDone(true);
+ queryClient.invalidateQueries({ queryKey: ['newsletter-issues'] });
+
+ if (failures.length === 0) {
+ toast({
+ title: `Backfilled ${published} issue${published === 1 ? '' : 's'} 🛹`,
+ description: 'The archive now lives on Nostr for subscribers and readers.',
+ });
+ } else {
+ toast({
+ title: `Published ${published}, ${failures.length} failed`,
+ description: `Retry to finish: #${failures.map((n) => String(n).padStart(3, '0')).join(', #')}.`,
+ variant: 'destructive',
+ });
+ setDone(false);
+ }
+ };
+
+ if (missing.length === 0) {
+ return (
+
+ Archive synced to Nostr
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {missing.length} archive issue{missing.length === 1 ? '' : 's'} not on Nostr yet
+
+
+ Publish {missing.map((i) => `#${String(i.number).padStart(3, '0')}`).join(', ')} as
+ NIP-23 articles so subscribers and long-form readers can see the full archive. The page
+ keeps its rich layout regardless.
+
+
+
+
+
+ );
+}
diff --git a/src/hooks/useNewsletterSubscriberCount.ts b/src/hooks/useNewsletterSubscriberCount.ts
new file mode 100644
index 0000000..cda8f06
--- /dev/null
+++ b/src/hooks/useNewsletterSubscriberCount.ts
@@ -0,0 +1,46 @@
+import { useQuery } from '@tanstack/react-query';
+import { useSkateRelays } from '@/hooks/useSkateRelays';
+import { APP_DATA_KIND, NEWSLETTER_SUB_D } from '@/lib/types';
+
+/**
+ * A best-effort community tally of newsletter subscribers.
+ *
+ * Each subscriber owns a public NIP-78 marker (kind 30078, d=NEWSLETTER_SUB_D)
+ * with a `subscribed` boolean tag. We query those markers and count the ones
+ * currently set to "true", deduped by author (one vote per pubkey).
+ *
+ * This is intentionally approximate — relays only return what they hold — so we
+ * present it as social proof ("N+ skaters"), not an exact figure.
+ */
+export function useNewsletterSubscriberCount() {
+ const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+
+ return useQuery({
+ queryKey: ['newsletter-subscriber-count', getQueryKeySuffix()],
+ staleTime: 5 * 60 * 1000,
+ gcTime: 30 * 60 * 1000,
+ queryFn: async (c): Promise => {
+ const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
+ const events = await querySkateContent(
+ [{ kinds: [APP_DATA_KIND], '#d': [NEWSLETTER_SUB_D], limit: 1000 }],
+ { signal },
+ );
+
+ // Keep the newest marker per author, then count the "true" ones.
+ const latestByAuthor = new Map();
+ for (const ev of events) {
+ const subscribed = ev.tags.find(([n]) => n === 'subscribed')?.[1] === 'true';
+ const existing = latestByAuthor.get(ev.pubkey);
+ if (!existing || ev.created_at > existing.createdAt) {
+ latestByAuthor.set(ev.pubkey, { subscribed, createdAt: ev.created_at });
+ }
+ }
+
+ let count = 0;
+ for (const { subscribed } of latestByAuthor.values()) {
+ if (subscribed) count++;
+ }
+ return count;
+ },
+ });
+}
diff --git a/src/lib/newsletterArticle.ts b/src/lib/newsletterArticle.ts
new file mode 100644
index 0000000..664d43f
--- /dev/null
+++ b/src/lib/newsletterArticle.ts
@@ -0,0 +1,101 @@
+import { NEWSLETTER_KIND, NEWSLETTER_TAG } from '@/lib/types';
+
+/**
+ * The subset of a static newsletter issue we need to turn it into a NIP-23
+ * long-form article (kind 30023). Kept structural (not importing the page) to
+ * avoid a page → lib → page import cycle.
+ */
+export interface StaticIssueLike {
+ number: number;
+ date: string; // ISO "YYYY-MM-DD"
+ vibe: string;
+ updates: { title: string; tag: string; body: string }[];
+ spotOfTheWeek?: {
+ title: string;
+ location: string;
+ why: string;
+ mapperName?: string;
+ image?: string;
+ href?: string;
+ };
+ communityNote: string;
+}
+
+/** Stable addressable identifier for a backfilled archive issue. */
+export function issueSlug(number: number): string {
+ return `issue-${number}`;
+}
+
+/** Human title for an issue, e.g. "SkateSpots Drop #005". */
+export function issueTitle(number: number): string {
+ return `SkateSpots Drop #${String(number).padStart(3, '0')}`;
+}
+
+/** Render a static issue's structured sections into a Markdown article body. */
+export function issueToMarkdown(issue: StaticIssueLike): string {
+ const lines: string[] = [];
+
+ lines.push(`_${issue.vibe}_`);
+ lines.push('');
+
+ lines.push('## What shipped');
+ lines.push('');
+ for (const u of issue.updates) {
+ lines.push(`### ${u.title}`);
+ lines.push(`**${u.tag}**`);
+ lines.push('');
+ lines.push(u.body);
+ lines.push('');
+ }
+
+ if (issue.spotOfTheWeek) {
+ const s = issue.spotOfTheWeek;
+ lines.push('## Spot of the Week');
+ lines.push('');
+ lines.push(`**${s.title}** — ${s.location}`);
+ lines.push('');
+ lines.push(s.why);
+ if (s.mapperName) {
+ lines.push('');
+ lines.push(`_Mapped by ${s.mapperName}_`);
+ }
+ lines.push('');
+ }
+
+ lines.push('## Community');
+ lines.push('');
+ lines.push(issue.communityNote);
+ lines.push('');
+ lines.push('---');
+ lines.push('');
+ lines.push('Read every drop at https://skate-spots.org/#/newsletter');
+
+ return lines.join('\n');
+}
+
+/** A one-line summary for the article's NIP-23 `summary` tag. */
+export function issueSummary(issue: StaticIssueLike): string {
+ return issue.vibe.length > 180 ? `${issue.vibe.slice(0, 177)}…` : issue.vibe;
+}
+
+/** Build the full (unsigned) kind-30023 event template for a static issue. */
+export function buildIssueArticle(issue: StaticIssueLike) {
+ const publishedAt = Math.floor(new Date(issue.date + 'T12:00:00Z').getTime() / 1000);
+ const image = issue.spotOfTheWeek?.image;
+ return {
+ kind: NEWSLETTER_KIND,
+ content: issueToMarkdown(issue),
+ tags: [
+ ['d', issueSlug(issue.number)],
+ ['title', issueTitle(issue.number)],
+ ['summary', issueSummary(issue)],
+ ...(image ? [['image', image]] : []),
+ ['published_at', String(Number.isFinite(publishedAt) ? publishedAt : Math.floor(Date.now() / 1000))],
+ ['t', NEWSLETTER_TAG],
+ ['t', 'skatespots'],
+ ['t', 'newsletter'],
+ ['t', 'skateboarding'],
+ ['alt', `SkateSpots newsletter issue: ${issueTitle(issue.number)}`],
+ ] as string[][],
+ };
+}
diff --git a/src/pages/NewsletterPage.tsx b/src/pages/NewsletterPage.tsx
index ccf7ed6..69d3e5e 100644
--- a/src/pages/NewsletterPage.tsx
+++ b/src/pages/NewsletterPage.tsx
@@ -28,7 +28,9 @@ import { genUserName } from '@/lib/genUserName';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButton';
import { NewsletterComposer } from '@/components/NewsletterComposer';
+import { NewsletterBackfillPanel } from '@/components/NewsletterBackfillPanel';
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
+import { useNewsletterSubscriberCount } from '@/hooks/useNewsletterSubscriberCount';
import { useNewsletterRead } from '@/hooks/useNewsletterRead';
import { NEWSLETTER_KIND, type SkateNewsletterIssue } from '@/lib/types';
@@ -1113,6 +1115,19 @@ function LiveIssueCard({ issue }: { issue: SkateNewsletterIssue }) {
);
}
+/** Social-proof subscriber tally shown under the subscribe CTA. */
+function SubscriberCount() {
+ const { data: count, isLoading } = useNewsletterSubscriberCount();
+ if (isLoading || !count || count < 3) return null; // hide until it's meaningful
+ const rounded = count >= 100 ? `${Math.floor(count / 50) * 50}+` : `${count}`;
+ return (
+