Fosforonero
SplitVote16 giugno 2026 · 6 min di lettura

Come ho costruito SplitVote: Redis real-time, Stripe e la trappola del curiosity gap

Dilemmi morali in tempo reale, Stripe idempotency, AdSense e un bug di psicologia che ha azzerato il k-factor. Tutto quello che ho imparato costruendo SplitVote da solo.

SplitVote è una piattaforma di dilemmi morali in tempo reale: voti anonimi, risultati live, profilo della personalità morale e loop virali. L'ho costruita da solo in circa due mesi. Questo articolo racconta le decisioni tecniche principali e i bug più interessanti — inclusa una scoperta tardiva sulla psicologia della condivisione che ha richiesto di riscrivere tutto il copy di share.

Il sito è live su splitvote.io. Per il ragionamento dietro la scelta dello stack (Next.js, Supabase, Redis) rimando a Lo stack che uso nel 2026. Per la scelta di rendere il voto completamente anonimo senza account, ho scritto un articolo dedicato.

Qui mi concentro su quello che non ho scritto altrove: Redis, caching, Stripe, AdSense e il curiosity gap.


Redis come source of truth per i voti live

Il requisito principale era la risposta istantanea al tap — senza nessuna latenza percepibile. Postgres va benissimo per lo storico, ma su read-modify-write ad alta frequenza introduce contesa.

La soluzione: Redis (Upstash) come source of truth per i conteggi. Ogni voto incrementa un counter con HINCRBY — atomico, sub-millisecondo, nessuna race condition su voti concorrenti.

// lib/redis.ts
await redis.hincrby(`dilemma:${id}`, option, 1)

Supabase riceve una write parallela in dilemma_votes per lo storico degli utenti loggati. Un cron snapshot orario sincronizza Redis → Postgres come backup.

Il problema che ho sottovalutato: Redis può perdere dati su restart senza persistence configurata. L'ho scoperto a maggio quando un flush ha azzerato ~500 voti. Il cron snapshot è la protezione principale; ho aggiunto anche un backup giornaliero su Postgres.


Next.js App Router: force-dynamic vs revalidate

La distinzione più importante dell'architettura di caching:

| Route | Strategia | Perché | |-------|-----------|--------| | /play/[id] | force-dynamic | existingVote è per-utente, non cacheable | | /results/[id] | revalidate = 60 | Dati aggregati, ISR safe | | /moral-dilemmas | revalidate = 300 | Catalogo statico |

Ho imparato a mie spese che mettere revalidate su una pagina che legge cookie rompe silenziosamente il comportamento per-utente. Next.js non lancia errori — serve ISR e restituisce la versione cached dell'utente precedente, incluso lo stato "hai già votato".

SplitVote usa la 14.2.3, mentre questo blog gira su Next.js 16. La domanda naturale è: vale la pena migrare? Al momento no. La migrazione richiederebbe una settimana (params e searchParams diventano Promise<>, breaking changes accumulati in due major version) per zero valore user-visible. Il constraint attuale è la distribuzione, non il framework. Riaprirò il discorso quando ci sarà traffico reale.


Stripe e idempotency: il bug che avrebbe regalato Premium gratis

Ho scritto una libreria di idempotency per i webhook Stripe — claimWebhookEvent / markWebhookEventProcessed — con row locking su Postgres. Il problema: non l'avevo mai importata nel webhook handler.

Stripe ritenta i webhook su timeout o 5xx. Senza idempotency ogni retry avrebbe:

  • Incrementato il contatore name_changes due volte
  • Assegnato XP doppi sugli acquisti cosmetics
  • Mai revocato is_premium a scadenza, perché il handler per customer.subscription.deleted era assente

Il fix è stato collegare la libreria esistente e aggiungere i lifecycle events mancanti (customer.subscription.updated, customer.subscription.deleted). Lezione: scrivi i test di idempotency prima di mettere in produzione il webhook, non dopo.


AdSense e il problema del "low value content"

AdSense ha rifiutato il sito per "contenuto di scarso valore". Ho impiegato settimane a capire perché.

Il problema: ogni dilemma generava due URL indicizzate — /play/[id] e /results/[id]. Semanticamente identiche, con l'unica differenza dei conteggi voto. 100 dilemmi = 200 pagine thin content, il 50% duplicate.

La soluzione:

  • robots: { index: false, follow: true } su tutte le /results/[id]
  • Rimosse ~118 URL dalla sitemap
  • Rimosso dateModified: new Date().toISOString() dal JSON-LD Dataset — il churning giornaliero su contenuto invariato è un segnale negativo per i crawler

Risultato atteso: profilo "low value" risolto, link equity concentrata sulle /play/[id] come unica URL indicizzabile per ogni dilemma.


Il curiosity gap: un bug di psicologia, non di codice

Questo è il problema più interessante perché non era un bug di codice.

I testi di condivisione originali dicevano:

"Il 73% ha scelto A. Cosa sceglieresti tu?"

Risultato: chi riceveva il link sapeva già la risposta. Il mistero era risolto prima di cliccare. K-factor ≈ 0.

La versione corretta:

"Ho già votato. Scommetto che non indovini cosa ha scelto la maggioranza."

Stessa informazione (hai votato), zero rivelazione. Il destinatario deve cliccare per scoprire. Lo stesso principio si applica all'immagine OG nei link preview — ho rimosso le percentuali anche dalla card, sostituendole con "Sai come si divide il voto?" con la barra visiva ma senza i numeri.


La pipeline AI con review umana

SplitVote genera nuovi dilemmi con Claude (Anthropic) tramite un cron giornaliero:

  1. Il cron genera N bozze per locale (EN + IT)
  2. Le bozze finiscono in Redis in stato draft
  3. Dall'admin panel reviewo e approvo/rifiuto manualmente
  4. Solo dopo l'approvazione il dilemma è visibile

Ho scelto deliberatamente nessun auto-publish. Con ~800 dilemmi approvati e traffico ancora basso, la supply non è il collo di bottiglia. Ho abbassato la generazione da 10 a 5 bozze per locale/giorno quando ho capito che accumulavo bozze più velocemente di quanto riuscissi a reviewarle.


Cosa ho sbagliato: la distribuzione

Dopo circa 560 commit e due mesi di lavoro, il sito ha pochissimi visitatori organici.

Il motivo è semplice: zero ore spese sulla distribuzione. Nessun post su Reddit, nessun thread su Twitter/X, nessuna menzione su community di psicologia o filosofia. Il codice era pulito, il prodotto funzionava, ma nessuno sapeva che esistesse.

Il dominio ha sei settimane di vita — Google impiega mesi a dare authority a un nuovo dominio. Anche la SEO tecnica più rigorosa non aiuta senza traffico iniziale e backlink.

Questo articolo è il primo backlink da fosforonero.com. La prossima fase è la distribuzione.


FAQ

Qual è lo stack di SplitVote? Next.js 14 App Router con TypeScript su Vercel, Supabase per Auth e Postgres, Upstash Redis per i conteggi live, Stripe per i pagamenti Premium, AdSense per la monetizzazione. Per la filosofia di scelta dello stack: Lo stack che uso nel 2026.

Perché Redis invece di Postgres per i voti? Latenza e atomicità. Redis risponde in sub-millisecondo e HINCRBY è atomico — nessuna race condition su voti concorrenti. Postgres è usato per lo storico e come backup in caso di flush Redis.

Vale la pena migrare da Next.js 14 a Next.js 16? Per SplitVote al momento no: params e searchParams diventano async, i breaking changes si accumulano su due major version. Ha senso riaprirlo con traffico reale o una feature specifica che giustifica lo sforzo.

Come funziona la pipeline AI per i nuovi dilemmi? Un cron giornaliero genera bozze via Claude. Ogni bozza richiede approvazione manuale dall'admin prima di diventare visibile. Nessun auto-publish.

Cos'è il curiosity gap nella condivisione? È il principio per cui un contenuto viene condiviso solo se il destinatario è motivato a cliccare per scoprire qualcosa. Rivelare il risultato nel testo di share distrugge questo meccanismo. SplitVote nasconde le percentuali finché non voti.