<h1>Comments & reactions for a static-export blog</h1>
<pclass="lede">How to add comments and reactions to me.nech.pl — a Next.js <strong>static export</strong> (no server runtime on the web host) served by nginx on the owner’s own server <em>erable</em> (Debian, Docker available), with an existing personal API gateway <em>nechapi</em> at api.nech.pl (SSOT <codestyle="font-family:var(--mono);font-size:.85em;">~/Work/Perso/nechapi</code>). Bilingual audience (FR/EN). This is a personal site, not a product.</p>
<divclass="verdict">
<divclass="kicker">Verdict</div>
<p>Self-hosted comments + reactions as a nechapi service on erable — SQLite, approval-queue antispam, no captcha — is the recommendation; the open questions are identity warmth and how much lands in v1.</p>
</div>
<divclass="fixed-panel">
<divclass="kicker">Fixed constraints</div>
<pstyle="margin:.3rem 0 0; font-size:.9rem; color:var(--ink-2);">From the owner, 2026-11-01. Not up for debate — every option below is judged against these.</p>
<ul>
<li><spanclass="tag">Fixed</span>1st-party / self-hosted ideally; data under owner control</li>
<li><spanclass="tag">Fixed</span>A database is acceptable (DB ok)</li>
<li><spanclass="tag">Required</span>Antispam — this is a public, unauthenticated posting surface</li>
<li><spanclass="tag">Banned</span>No CAPTCHA — explicitly unwanted (no Cloudflare Turnstile, no hCaptcha, no puzzles)</li>
<li><spanclass="tag">Fixed</span>Low maintenance — a personal site, not a product</li>
<svgviewBox="0 0 880 150"role="img"aria-label="Architecture diagram: static blog fetches comments client-side from api.nech.pl, which writes to SQLite and exposes a moderation queue">
<li><strongstyle="color:var(--ink);">Erable reliability.</strong> If an always-on service on erable proves unreliable, DP1 flips to B (git-as-DB) — comments become build artifacts and the uptime question disappears.</li>
<li><strongstyle="color:var(--ink);">Threading wanted early.</strong> If the owner wants threaded replies in v1, DP8 reopens (option B) and the data model gains a parent_id + UI cost now.</li>
<li><strongstyle="color:var(--ink);">GitHub-first audience.</strong> If the audience is confirmed GitHub-centric, DP3 option B (OAuth-only) gains weight and auto-approval becomes safer.</li>
</ul>
</section>
<sectionid="dps"aria-labelledby="dps-h">
<divclass="kicker">Decision points</div>
<h2id="dps-h">Eight calls, one choice each</h2>
<pstyle="color:var(--ink-2); font-size:.95rem; margin-top:0;">Recommended options are listed first and badged. Every option carries its costs. Add a note where your reasoning departs from the reco rationale.</p>
<divid="dp-root"></div>
</section>
<sectionaria-labelledby="global-h">
<divclass="kicker">Global</div>
<h2id="global-h">Notes across the whole decision</h2>
<textareaclass="global-notes"id="global-notes"placeholder="Anything the eight boxes didn’t capture — sequencing, open questions, who to tell…"></textarea>
framing:"A static export has no server-side render, so comments either ship as static content baked at build, load client-side from a service, or use the origin server (erable) as the API. The real tradeoff is where truth lives and who runs it.",
options:[
{letter:"A",reco:true,name:"Self-hosted comments API on erable via nechapi",
body:"Small service (container tier on the nechapi golden base) + SQLite; blog fetches client-side from api.nech.pl; moderation queue in a tiny admin page or CLI.",
costs:"You own uptime, spam-filter code, and backups; ~1 weekend to build; comments need a client-side fetch (no-JS readers see nothing)."},
{letter:"B",reco:false,name:"Git-as-DB: comments as files via pull request or CMS queue",
body:"Readers post through the existing Sveltia CMS (admin.github OAuth) into a content/comments/ folder; comments become build artifacts, zero new runtime.",
costs:"Slow feedback loop (commit+deploy per comment), GitHub account required for posters, moderation = git hygiene; spam manifests as PR spam you close."},
{letter:"C",reco:false,name:"Third-party embed (giscus on GitHub Discussions)",
body:"Fastest, free, real accounts.",
costs:"Violates the 1st-party constraint, GitHub-centric audience, GitHub branding, data leaves your control. Listed for completeness; likely rejected."}
],
reco:"The owner already operates a personal API gateway precisely for this shape of thing; comments v1 is a small CRUD + queue, well inside nechapi’s remit."
},
{
id:"dp2",
title:"DP2 — Antispam without captcha",
framing:"An unauthenticated POST endpoint on the open internet gets automated abuse within days. Without captcha, the defense is layered friction + human review.",
costs:"Hostile to old devices, weird UX for a poetry blog’s audience, arms-race ownership."}
],
reco:"Approval-queue is the only option whose failure mode is “comment appears late” rather than “spam appears publicly”; honeypot + ratelimit kills 95% of robot traffic before it costs anything."
},
{
id:"dp3",
title:"DP3 — Identity & attribution",
framing:"Who is a commenter? This drives antispam strength and UX warmth.",
options:[
{letter:"A",reco:true,name:"Name + optional email (never displayed beyond name), no accounts",
body:"Lowest friction, fits a personal blog; email enables owner reply and (later) notifications.",
costs:"Nothing verifies identity; impersonation possible (acceptable at this scale)."},
costs:"Account wall contradicts the open-web feel; GitHub-only audience; more OAuth code to own."},
{letter:"C",reco:false,name:"Both: anonymous with hold, OAuth posts auto-approved",
body:"Two trust tiers in one surface.",
costs:"Two code paths from day one — a v2 move."}
],
reco:"v1 should be one path; hold-for-approval makes weak identity safe."
},
{
id:"dp4",
title:"DP4 — Reactions",
framing:"The owner asked for reactions alongside comments. Reactions are anonymous, one-click, and don’t need moderation — a different trust model from comments.",
options:[
{letter:"A",reco:true,name:"Same service, separate endpoint, no moderation",
body:"Emoji set (e.g. ♥ ⚡ ✨ 😂 😮), one count per post, 1 IP/post/day/emoji, no text → nothing to moderate.",
costs:"Counters are gameable per-IP (fine at this scale); needs the same API uptime."},
{letter:"B",reco:false,name:"Defer reactions to v2",
body:"Ship comments first.",
costs:"None functionally; the owner asked for reactions, so this defers a want."},
costs:"CMS is built for content folders (git), not an API-backed queue."}
],
reco:"The approval queue is the product; a 100-line admin page beats weekly ssh."
},
{
id:"dp8",
title:"DP8 — Reply/notify (v1 scope)",
framing:"How much conversation machinery lands in v1.",
options:[
{letter:"A",reco:true,name:"Flat comments, no threading, no notifications v1 — plus owner-email-on-hold as a checkbox item",
body:"Ship the smallest true thing; owner reads the queue anyway. Email-on-new-hold is ~10 lines given SMTP already configured on erable (MAIL-RECEIVE docs), so it folds into A.",
costs:"No conversations, no reply-by-email."},
{letter:"B",reco:false,name:"Threading v1",
body:"Replies nested under comments from day one.",
costs:"Data model + UI cost now; personal-blog threads rarely go deep."},
{letter:"C",reco:false,name:"Email notifications to owner on new hold (standalone option)",
body:"Small win on its own.",
costs:"Redundant if folded into A as recommended; standalone treatment splits the smallest-true-thing boundary for no extra gain."}
],
reco:"Flat + owner-email-on-hold is ~10 lines given existing mail; call that the boundary of v1."
html+='<textarea data-note-for="'+dp.id+'" rows="3" placeholder="Notes on '+esc(dp.title.split("—")[0].trim())+' — why, doubts, what would change your mind…"></textarea>';
lines.push("> Verdict: Self-hosted comments + reactions as a nechapi service on erable — SQLite, approval-queue antispam, no captcha — is the recommendation; the open questions are identity warmth and how much lands in v1.");
lines.push("");
vardecided=0;
DPS.forEach(function(dp){
varletter=state.choices[dp.id];
varnote=(state.notes[dp.id]||"").trim();
lines.push("## "+dp.title);
if(letter){
decided++;
varopt=findOption(dp.id,letter);
lines.push("");
lines.push("**Chosen: "+letter+" — "+(opt?opt.name:"")+"**"+(opt&&opt.reco?" (recommended)":"")+(opt&&!opt.reco?" (differs from recommendation)":""));
}else{
lines.push("");
lines.push("_Undecided._");
}
if(note){lines.push("");lines.push(note);}
lines.push("");
});
lines.push("---");
lines.push("");
lines.push("Progress: "+decided+"/8 decided.");
varg=state.global.trim();
if(g){lines.push("");lines.push("## Global notes");lines.push("");lines.push(g);lines.push("");}