ποΈ TownHall β Live Anonymous Q&A & Idea Voting for Communities
Date: 2026-07-11
Idea ID: 019
Category: community, events
Difficulty: ββ (Intermediate)
Estimated Build Time: 2β3 days (solo developer)
Tech Stack: Next.js 15 (App Router) Β· React 19 Β· TypeScript 5.5 Β· PostgreSQL 16 (Supabase) Β· Drizzle ORM 0.38 Β· Supabase Auth Β· Supabase Realtime Β· Tailwind CSS v4 Β· shadcn/ui Β· Framer Motion Β· Stripe Β· Resend Β· Sentry Β· PostHog Β· Vercel
π― Elevator Pitch
TownHall is the anonymous Q&A and idea-voting board your all-hands, meetup, or conference has been missing. Create a session, share a link, and watch questions roll in β ranked by upvotes, filtered by relevance, and answered in real-time. No sign-up required for participants. No more awkward silences or chat-scroll fatigue.
π₯ Problem
- People don't ask questions in large meetings. Fear of judgment, "that's a dumb question," social hierarchy, and plain old stage fright silence the people who need answers most.
- Chat is chaos. In virtual meetings, questions fly into a side chat that nobody reads. In-person, nobody writes them down at all. Good questions get buried, duplicates pile up, and the organizer has no pulse on what matters most.
- The loudest voices win. In every all-hands, Q&A is dominated by the same 3 people. The quiet majority β new hires, juniors, remote workers β never get their questions heard.
- No prioritization. Even when questions are collected, there's no democratic way to surface the most important ones. Organizers guess at what matters.
- Enterprise tools are overkill and overpriced. Slido ($124+/mo), Mentimeter ($84+/mo), and Poll Everywhere are designed for enterprise conference organizers, not for a weekly team standup or a 50-person community call.
Current workflow for a community organizer:
- "Any questions?" β silence β awkward pause β move on
- Type questions into Google Docs / Notion during the call
- Forget to follow up
- Repeat next week
β
Solution
TownHall is a dead-simple, beautiful, real-time anonymous Q&A board:
- Create a session β one click, zero configuration. You get a shareable link.
- Participants submit questions anonymously β no sign-up, no app install, just open the link and type.
- Live voting β upvote questions you want answered. Real-time ranking via WebSocket.
- Organizer dashboard β see all questions ranked by votes, mark as "answered," pin to top, or dismiss.
- Moderation controls β approve/reject questions before they go public. Block keywords. Mute participants.
- Export β CSV of all Q&As for meeting notes or retrospectives.
- Replay mode β after the session, anyone can view the full Q&A timeline with vote history.
Key differentiator: TownHall is participant-zero-friction β no account, no email, no CAPTCHA. Show up, type, vote. That's it. For organizers, it's one-click setup β no onboarding wizard, no template selection, no configuration. The product surfaces in under 10 seconds.
π₯ Target User
| Persona | Description | Pain Level | Willingness to Pay |
|---|
| Engineering Manager | Runs weekly all-hands for 20-100 person eng org. Tired of silence during Q&A. | π΄ High β wants inclusive culture | $15-29/mo |
| Meetup Organizer | Runs a community meetup (React, Python, etc.) with 50-300 attendees. Needs to keep Q&A organized. | π΄ High β chaotic in-person Q&A | $19-39/mo |
| Conference Speaker/Workshop Lead | Speaks at conferences, runs workshops. Wants to collect questions without being overwhelmed. | π‘ Medium β nice-to-have during talks | Pay-per-event ($5/event) |
| Remote Team Lead | Manages a distributed team. Struggles with async Q&A across time zones. | π‘ Medium β wants async input | $15-29/mo |
| Product Manager | Runs customer feedback sessions, user research groups. Needs structured Q&A. | π‘ Medium β needs exportable results | $29-49/mo |
| Community Manager (Discord/Slack) | Manages 500+ person community. Wants structured feedback without channel spam. | π‘ Medium β needs weekly pulse | $19-39/mo |
π οΈ Full Tech Stack
Frontend (Main App)
| Layer | Technology |
|---|
| Framework | Next.js 15 (App Router) + React 19 + TypeScript 5.5 |
| Styling | Tailwind CSS v4 (utility-first, @apply sparingly) |
| Component Library | shadcn/ui (Radix Primitives β Button, Card, Dialog, Sheet, Input, Textarea, Badge, Separator, Tabs, Tooltip) |
| Animation | Framer Motion 11.x (question card transitions, vote animations, real-time updates) |
| Icons | Lucide React (Tree-shakeable, MIT license) |
| State Management | React Server Components (default) + TanStack React Query 5.x for client mutations + real-time subscriptions |
| Form Handling | React 19 useActionState (server actions for question submission, minimal client JS) |
| Real-time Client | Supabase Realtime JS client (WebSocket subscriptions for live vote counts + new questions) |
Backend & API
| Layer | Technology |
|---|
| API Layer | Next.js Route Handlers (RESTful JSON) + React Server Actions for form submissions |
| Database | PostgreSQL 16 via Supabase (managed, includes auth + realtime + storage) |
| ORM | Drizzle ORM 0.38.x (type-safe, drizzle-kit for migrations) |
| Auth | Supabase Auth (email magic link + Google OAuth + GitHub OAuth) |
| Realtime | Supabase Realtime (WebSocket subscriptions on questions and votes tables) |
| Rate Limiting | Upstash Redis (serverless, limits: 5 questions/min per session, 1 vote/2s per IP) |
| Background Jobs | Inngest (serverless queue: session-end emails, weekly reports, stale session cleanup) |
Storage & CDN
| Layer | Technology |
|---|
| File Storage | Supabase Storage (session cover images, optional, < 2MB) |
| CDN | Vercel Edge Network (static assets, API response caching at edge) |
| Public Assets | Vercel Asset Optimization (WebP conversion for uploads) |
Payments & Email
| Layer | Technology |
|---|
| Payments | Stripe (Checkout Sessions + Webhook β Inngest for plan lifecycle) |
| Email | Resend (transactional: session shared, question answered, weekly digest) |
| Session Management | Server-side sessions (anonymous participants get a localStorage UUID) + Supabase Auth for organizers |
Monitoring & Analytics
| Layer | Technology |
|---|
| Error Tracking | Sentry (frontend + API route errors, performance traces) |
| Product Analytics | PostHog (session creation funnels, engagement, vote actions) |
| Performance | Vercel Analytics (Web Vitals for session pages) |
| Uptime | Better Stack (HTTP monitoring on session public routes) |
Testing & CI/CD
| Layer | Technology |
|---|
| Unit Testing | Vitest + React Testing Library |
| E2E Testing | Playwright (session creation, anonymous Q&A, voting flow) |
| CI/CD | GitHub Actions (lint β typecheck β test β build on push & PR) |
| Deployment | Vercel (automatic preview deploys on PR, production on main push) |
π¨ UI Design Language
Foundation
| Element | Choice |
|---|
| Base Framework | Tailwind CSS v4 (utility-first, minimal custom CSS) |
| Component Library | shadcn/ui (Radix primitives, Tailwind-styled) |
| Design Style | Warm minimal with soft edges β clean white/purple surfaces, generous whitespace, pill-shaped badges, subtle gradients on interactive elements |
| Color Palette | β |
Color System
Primary: #7C3AED (Violet-600) β buttons, active session badge, upvote icon
Secondary: #8B5CF6 (Violet-500) β hover states, secondary CTAs
Accent: #06B6D4 (Cyan-500) β "Live" indicator, answered badge, progress pulse
Background: #FAFAFA (Neutral-50) β app background
Surface: #FFFFFF β cards, modals, question bubbles
Border: #E5E7EB (Gray-200) β dividers, card borders
Text: #111827 (Gray-900) β headings, question text
Text Body: #374151 (Gray-700) β body copy, descriptions
Muted: #9CA3AF (Gray-400) β timestamps, vote counts, labels
Success: #10B981 (Emerald-500) β "answered" badge, confirmed actions
Warning: #F59E0B (Amber-500) β pending moderation, session nearing end
Error: #EF4444 (Red-500) β rejected questions, blocking errors
Typography
Headings: Plus Jakarta Sans (sans-serif) β weights 600, 700, 800
Body: Inter (sans-serif) β weights 400, 500
Code: JetBrains Mono β export snippets, embedded links
Scale: 12 / 14 / 16 / 18 / 20 / 24 / 30 / 36 / 48 px
Line-height: 1.5 body / 1.2 headings
Letter-spacing: -0.02em for H1-H2
Spacing & Radius
- Border radius:
rounded-xl (12px) for cards and question bubbles, rounded-lg (8px) for inputs, rounded-full for badges, avatars, and the upvote button
- Spacing: 4px grid basis β
p-6 for cards, p-4 for question items, gap-4 for list spacing, gap-8 for section spacing
- Shadows:
shadow-sm for card elevation, shadow-lg for modals, shadow-md for the floating "New Question" fab
- Max width: Session page capped at
max-w-3xl for readability
Interaction Design
- Upvote button: Pill button with micro-bounce animation on click (Framer Motion
scale: [1, 1.3, 1] within 200ms). Vote count animates (number rolls up) with color shift from muted β primary on active.
- Question cards: Slide in from bottom on new questions (Framer Motion
AnimatePresence with initial={{ opacity: 0, y: 20 }}). Answered questions fade to 80% opacity with a green checkmark badge.
- Live indicator: Pulsing cyan dot in session header (CSS
@keyframes pulse) β visible while session is active.
- Participant view: Full-screen minimal β just a question input at the top and a scrollable feed of questions below. No nav, no settings, no distractions.
- Organizer view: Toggle between "Live Feed" (ordered by votes, newest) and "Moderation Queue" (pending questions) via tabs. Floating action buttons to mark answered, pin, dismiss.
- Empty state: Illustrated town hall podium with microphone graphic (CSS/SVG illustration) β "No questions yet. Be the first to ask!"
Dark Mode
- Strategy: Class-based toggle (
dark class on <html>). Organizer preference persisted in Supabase user metadata. Respects prefers-color-scheme on first visit.
- Dark palette: Background
#0A0A0A, Surface #18181B (Zinc-900), Text #F5F5F5, Border #27272A (Zinc-800). Primary stays Violet but at 90% lightness (#A78BFA). Cards get a subtle border-white/[0.05] ring instead of shadow.
Responsive
- Participant page: Mobile-first β single column, full-width input, stacked question cards. On desktop,
max-w-3xl centered layout.
- Organizer dashboard: Desktop-first sidebar layout on tablet+. On mobile, bottom-tab navigation (Live / Moderation / Settings).
- Question input: Sticky at top on mobile (scrolls with page), fixed position on desktop with
sticky top-4.
- Embedded session: Responsive embed widget (for embedding a session on a conference page or Notion doc) β scales to container width, max 480px height.
Iconography
- Library: Lucide React v0.450+
- Style: Outlined, 1.5px stroke, 20x20 default on question cards, 24x24 on CTAs
- Key icons:
MessageCircleQuestion (ask question), ThumbsUp / ArrowBigUp (upvote), CheckCheck (answered), Eye (viewed), Shield (moderation), Users (participants), Timer (session timer), Download (export), Link (share), Crown (organizer), SendHorizonal (submit)
ποΈ Full System Architecture
Repository Structure
townhall/
βββ app/ # Next.js 15 App Router
β βββ (marketing)/ # Landing page, pricing, docs
β β βββ page.tsx # Landing page
β β βββ pricing/ # Pricing page
β β βββ docs/ # How-to guides
β βββ (dashboard)/ # Authenticated organizer routes
β β βββ layout.tsx # Sidebar layout + Supabase listener
β β βββ page.tsx # Dashboard home (active/recent sessions)
β β βββ sessions/ # Session CRUD
β β β βββ new/ # New session form
β β β βββ [id]/ # Session organizer view
β β β βββ page.tsx # Live Q&A management board
β β β βββ settings/ # Session settings
β β β βββ analytics/ # Question stats, vote distribution
β β β βββ export/ # CSV export
β β βββ settings/ # Account, billing, preferences
β β βββ sessions/ # Session list
β βββ s/ # Public session routes (participant view)
β β βββ [slug]/ # Single session (no auth required)
β β βββ page.tsx # Participant Q&A view
β β βββ replay/ # Archived session replay
β βββ api/ # Route Handlers
β β βββ webhooks/ # Supabase Auth, Stripe, Inngest
β β βββ sessions/ # CRUD (organizer only)
β β βββ questions/ # CRUD + voting
β β βββ votes/ # Vote actions
β β βββ export/ # CSV generation
β βββ auth/ # Supabase Auth pages (login, callback)
β βββ layout.tsx # Root layout
β βββ page.tsx # Redirect to dashboard or landing
βββ components/
β βββ session/ # Session-specific components
β β βββ QuestionCard.tsx # Single question with vote button
β β βββ QuestionList.tsx # Live-updating question feed
β β βββ QuestionInput.tsx # Anonymous question form
β β βββ VoteButton.tsx # Animated upvote button
β β βββ LiveIndicator.tsx # Pulsing "Live" badge
β β βββ ModerationQueue.tsx # Pending questions moderation
β β βββ SessionHeader.tsx # Session title, participant count, timer
β βββ dashboard/ # Dashboard components
β β βββ SessionCard.tsx # Session preview card
β β βββ StatsGrid.tsx # Question count, vote count, answered %
β β βββ EmptyState.tsx # First-session onboarding
β β βββ RecentSessions.tsx # Recently active sessions
β βββ ui/ # shadcn/ui primitives
βββ db/ # Drizzle schema + migrations
β βββ schema.ts # All table definitions
β βββ migrations/ # Generated SQL migrations
βββ lib/
β βββ db.ts # Drizzle client (Supabase)
β βββ auth.ts # Supabase server-side helpers
β βββ realtime.ts # Supabase Realtime channel setup
β βββ rate-limit.ts # Upstash rate limiter helper
β βββ stripe.ts # Stripe client helpers
βββ inngest/ # Background job functions
β βββ session-ended.ts # Email recap to organizer
β βββ weekly-digest.ts # Weekly activity summary
β βββ cleanup-sessions.ts # Archive stale sessions
βββ public/ # Static assets (favicon, OG images)
Architecture Pattern
- Pattern: Single Next.js 15 repo (no turborepo β overkill for this scope)
- Route groups:
(marketing) for public pages, (dashboard) for authenticated routes
- Public session routes:
/s/[slug] β no auth, renders with React Server Components, real-time via Supabase Realtime client-side subscription
- Bundle splitting: App Router auto-splits. Public session page loads minimal JS (~40KB gzipped) β no dashboard code on participant pages.
API Design
- Style: RESTful JSON via Next.js Route Handlers + React Server Actions for form submissions (question creation, voting)
- Endpoints:
| Endpoint | Method | Purpose |
|---|
/api/sessions | GET | List organizer's sessions (paginated) |
/api/sessions | POST | Create new session |
/api/sessions/[id] | GET | Fetch session details (organizer) |
/api/sessions/[id] | PATCH | Update session settings |
/api/sessions/[id] | DELETE | Archive/delete session |
/api/sessions/[id]/questions | GET | List questions with votes (public + organizer) |
/api/sessions/[id]/export | GET | Download CSV export |
/api/questions | POST | Submit question (anonymous, requires session slug) |
/api/questions/[id] | PATCH | Update question (moderation: approve/reject, mark answered) |
/api/questions/[id] | DELETE | Remove question (organizer only) |
/api/votes | POST | Toggle upvote on question |
/api/webhooks/supabase | POST | Handle Auth user lifecycle |
/api/webhooks/stripe | POST | Handle subscription changes |
Server Actions (React 19)
| Action | Input | Purpose |
|---|
submitQuestion(formData) | sessionSlug, text | Creates question (anonymous user) |
toggleVote(formData) | questionId, sessionSlug | Toggle upvote (anonymous user) |
createSession(formData) | title, settings | Creates session (organizer) |
markAnswered(formData) | questionId | Marks question as answered (organizer) |
Using Server Actions for participant-facing operations (question submit, vote) minimizes client JS and enables progressive enhancement. API routes handle organizer CRUD operations and webhooks.
Database Schema
-- Core tables
sessions
id: uuid PK (default gen_random_uuid())
organizer_id: uuid FK -> auth.users (Supabase Auth)
title: text (required, max 200 chars)
slug: text (unique, URL-safe, auto-generated from title)
description: text (optional, max 500 chars)
status: enum: active, paused, ended (default: active)
moderation_enabled: boolean (default: false)
anonymous_allowed: boolean (default: true)
max_questions_per_participant: int (default: 10, 0 = unlimited)
participant_count: int (approx counter, real-time)
question_count: int (denormalized counter)
is_public: boolean (default: true)
cover_emoji: text (optional, e.g., "ποΈ")
scheduled_start: timestamptz (nullable)
ended_at: timestamptz (nullable)
created_at: timestamptz (default now())
updated_at: timestamptz (default now())
questions
id: uuid PK (default gen_random_uuid())
session_id: uuid FK -> sessions (cascade delete)
participant_id: text (anonymous UUID stored in localStorage, nullable)
text: text (required, max 500 chars)
votes_count: int (default 0, denormalized counter)
status: enum: pending, approved, answered, dismissed (default: approved)
is_pinned: boolean (default: false)
is_anonymous: boolean (default: true)
answered_at: timestamptz (nullable)
created_at: timestamptz (default now())
votes
id: uuid PK (default gen_random_uuid())
question_id: uuid FK -> questions (cascade delete)
participant_id: text (anonymous UUID from localStorage)
created_at: timestamptz (default now())
UNIQUE(question_id, participant_id) -- one vote per person per question
organizer_profiles
id: uuid PK (FK -> auth.users)
display_name: text
plan: enum: free, pro, business (default: free)
stripe_customer_id: text (nullable)
stripe_subscription_id: text (nullable)
created_at: timestamptz (default now())
-- Indexes
CREATE INDEX idx_questions_session_status ON questions(session_id, status, votes_count DESC);
CREATE INDEX idx_questions_created ON questions(session_id, created_at DESC);
CREATE INDEX idx_votes_question ON votes(question_id);
CREATE INDEX idx_sessions_organizer ON sessions(organizer_id, created_at DESC);
CREATE INDEX idx_sessions_slug ON sessions(slug);
Auth Flow
- Organizer auth: Supabase Auth (email magic link + Google OAuth + GitHub OAuth)
- Session type: JWT (Supabase auto-managed, short-lived with refresh)
- Middleware: Next.js middleware checks Supabase session β redirects unauthenticated users to
/auth/login for dashboard routes
- Participant auth: None. Anonymous participants get a UUID stored in
localStorage (persists across browser sessions). This UUID is used as participant_id in votes and questions β no account, no email, no friction.
- Rate limiting: Upstash Redis β 5 questions/minute per participant (by IP), 1 vote per 2 seconds per participant (by IP + participant_id). Prevents spam without requiring auth.
File Storage Strategy
- Provider: Supabase Storage (S3-compatible, managed bucket)
- Usage: Optional session cover images / event branding for Pro tier
- Bucket structure:
townhall/sessions/{session_id}/cover.{ext}
- Constraints: Max 2MB, JPEG/PNG/WebP only, auto-resize via Supabase Image Transformations
Real-time Architecture
TownHall's real-time is its killer feature. Here's how it works:
1. Participant opens /s/[slug] β page loads as React Server Component (initial SSR)
2. Client hydrates β creates Supabase Realtime channel:
channel = supabase.channel(`session:${slug}`)
channel.on('postgres_changes',
{ event: '*', schema: 'public', table: 'questions',
filter: `session_id=eq.${sessionId}` },
(payload) => { /* update question list */ }
)
3. Vote mutations use Server Actions:
- Client calls toggleVote(questionId) β Server Action
- Server Action validates (rate limit, session active, question exists)
- Inserts/deletes vote row, updates denormalized votes_count
- Supabase Realtime broadcasts change β all connected clients update
4. Organizer sees questions appearing in real-time via same channel
5. When organizer marks "answered" or "dismissed" β Realtime broadcast updates all participants
This means:
- No polling. No manual refresh. Questions and votes update in < 200ms across all clients.
- Supabase Realtime handles WebSocket management (connection pooling, reconnection, presence).
- Zero additional infrastructure for real-time.
Background Jobs (Inngest)
session-ended β When organizer ends a session, sends a recap email via Resend with: total questions, top-voted question, answer rate, participant count, CSV download link.
weekly-digest β Every Monday 9:00 UTC, sends organizers a summary of their week: sessions created, questions received, votes cast, top unanswered questions.
cleanup-stale-sessions β Daily, auto-archives sessions that have been ended for 30+ days (moves to read-only replay mode, removes from active dashboard).
auto-end-session β If a scheduled session's scheduled_end time passes, auto-ends the session and triggers session-ended.
Caching Strategy
| Layer | Strategy |
|---|
| Public session page | React Server Component (fresh on every SSR β questions update in real-time client-side anyway, no ISR needed) |
| Dashboard | Server Components + React cache() β fresh on navigation. Client-side TanStack Query with staleTime: 30s for session list |
| Realtime updates | Supabase Realtime WebSocket β no manual cache invalidation needed. The database is the source of truth |
| Vote counts | Denormalized questions.votes_count column (atomic UPDATE questions SET votes_count = votes_count + 1). No separate counter table |
| CDN | Vercel Edge Network for static assets. Supabase CDN for uploaded images |
| Rate limit data | Upstash Redis (serverless, ephemeral β TTL-based, no persistence needed) |
Environment Structure
# .env.local (development) / .env.production (Vercel)
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
DATABASE_URL= # Supabase connection string (pooled)
DIRECT_URL= # Supabase direct connection (migrations)
SUPABASE_AUTH_WEBHOOK_SECRET=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PRICE_IDS= # JSON: {pro: "price_xxx", business: "price_yyy"}
RESEND_API_KEY=
RESEND_AUDIENCE_ID= # For newsletter/onboarding
INNGEST_EVENT_KEY=
INNGEST_SIGNING_KEY=
NEXT_PUBLIC_APP_URL= # https://townhall.app
SENTRY_DSN=
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=
Deployment Topology
[Participant Browser]
β
βββ https://townhall.app ββββββββββββ Vercel Edge Network
β βββ / (Landing page) ββ SSG, revalidate 300s
β βββ /s/[slug] (Session) ββ SSR + client Realtime subscription
β βββ /dashboard/* ββ SSR + Server Actions
β βββ /pricing ββ SSG, revalidate 3600s
β
βββ Supabase Realtime ββββββββββββββ WebSocket (long-lived persistent connection)
β
βββ Supabase Auth ββββββββββββββββββ OAuth / Magic link flows
β
[Vercel Serverless Functions]
β
βββ Route Handlers βββββββββββββββββ Supabase PostgreSQL (via Drizzle)
βββ Server Actions βββββββββββββββββ Supabase PostgreSQL (via Drizzle)
βββ Inngest Functions ββββββββββββββ Supabase PostgreSQL (via Drizzle)
βββ Webhooks (Stripe, Supabase) ββββ Resend (email), Sentry (errors)
β
βββ Upstash Redis βββββββββββββββββ Rate limiting (ephemeral)
[Supabase]
βββ PostgreSQL 16 βββββββββββββββββ Main data (sessions, questions, votes)
βββ Auth ββββββββββββββββββββββββββ User management, JWT sessions
βββ Realtime ββββββββββββββββββββββ WebSocket broadcast on table changes
βββ Storage βββββββββββββββββββββββ Session cover images (Pro tier)
Monitoring
| Tool | What It Monitors |
|---|
| Sentry | JavaScript errors (frontend + API routes), Realtime connection failures |
| PostHog | Session creation funnel, question submission rate, vote conversion, retention |
| Vercel Analytics | Web Vitals (LCP, CLS, INP), public session page load times |
| Better Stack | HTTP uptime for public session routes and API |
| Supabase Dashboard | DB query performance, Realtime active connections, auth rate limits |
Data Flow Diagram (Description)
Submitting a question (anonymous participant):
- Visitor opens
/s/my-allhands β React Server Component renders initial SSR with existing questions (sorted by votes).
- Client hydrates β Supabase Realtime channel subscribes to
questions for this session.
- Visitor types question β clicks "Submit" β React 19 Server Action fires.
- Server Action: validates question length (max 500 chars), checks rate limit via Upstash Redis (5/min per IP), checks session is active, inserts question row into Postgres via Drizzle.
- Supabase Realtime detects the INSERT on
questions table β broadcasts to all subscribed clients (including the submitter and all other participants).
- All connected clients update their question list in real-time β new question card slides in at the bottom (if no votes) or ranks by votes immediately.
- Organizer's dashboard updates simultaneously β question appears in their moderation queue (if moderation enabled) or directly in the live feed.
Voting on a question:
- Visitor clicks upvote button β Server Action fires with
questionId and participantId.
- Server Action: checks rate limit (1 vote/2s per IP + participant), checks question exists in active session, upserts row in
votes table, atomically increments questions.votes_count.
- Supabase Realtime broadcasts the update β all clients update the vote count with micro-animation (number rolls up).
- If the vote pushes this question to #1, it animates to the top of the list (Framer Motion layout animation
layoutId).
Organizer answering a question:
- Organizer sees question in dashboard β clicks "Mark Answered" β PATCH to
/api/questions/[id] with status: answered.
- Server validates ownership (question belongs to organizer's session) β updates row.
- Realtime broadcast β participant client shows green checkmark + "Answered by organizer" badge on that question card.
- Question drops to 80% opacity, sorted below unanswered questions (answered questions move down in priority).
π PRD β Functional Requirements
FR-01: Session Creation & Management
| ID | Requirement | Priority | Notes |
|---|
| FR-01.1 | User can sign up with email magic link, Google, or GitHub (Supabase Auth) | P0 | |
| FR-01.2 | User can create a new session with title, optional description, and optional emoji | P0 | Slug auto-generated from title |
| FR-01.3 | User can set session to active, paused, or ended | P0 | Toggle in header |
| FR-01.4 | User can enable/disable moderation (questions require approval before appearing) | P0 | Toggle in session settings |
| FR-01.5 | User can enable/disable anonymous submissions | P1 | Default: enabled |
| FR-01.6 | User can set a max questions-per-participant limit | P1 | Default: 10, 0 = unlimited |
| FR-01.7 | User can delete/archive a session | P0 | Archive preserves data, delete removes everything |
| FR-01.8 | User can duplicate an existing session (reuse settings for recurring events) | P1 | |
| FR-01.9 | User can schedule a session for a future date/time | P2 | |
FR-02: Anonymous Q&A (Participant View)
| ID | Requirement | Priority | Notes |
|---|
| FR-02.1 | Participant can open a session link with no sign-up required | P0 | Just the URL, nothing else |
| FR-02.2 | Participant can submit a question (text, max 500 chars) | P0 | Server Action, rate-limited |
| FR-02.3 | Participant's question appears in the feed immediately (or goes to moderation queue if enabled) | P0 | Realtime update |
| FR-02.4 | Participant can upvote any question (one vote per question per participant) | P0 | Toggle on/off |
| FR-02.5 | Participant can see question list sorted by votes (highest first) by default | P0 | Server-sorted, real-time updates |
| FR-02.6 | Participant can toggle between "Top Voted" and "Newest" sort orders | P1 | Client-side sort |
| FR-02.7 | Participant sees "Answered" badge on questions the organizer has addressed | P0 | |
| FR-02.8 | Participant sees live participant count in session header | P1 | Supabase Realtime presence |
| FR-02.9 | Participant can report a question as inappropriate | P2 | Flags for organizer review |
FR-03: Organizer Dashboard (Live Moderation)
| ID | Requirement | Priority | Notes |
|---|
| FR-03.1 | Organizer sees a live feed of all questions sorted by votes | P0 | Real-time updates |
| FR-03.2 | Organizer can approve or reject pending questions (moderation queue tab) | P0 | |
| FR-03.3 | Organizer can mark a question as "Answered" | P0 | Shows visual badge to participants |
| FR-03.4 | Organizer can pin a question to the top of the feed | P1 | Stays at top regardless of votes |
| FR-03.5 | Organizer can dismiss/hide a question without answering | P1 | |
| FR-03.6 | Organizer can delete a question (removes from feed entirely) | P0 | Cascade deletes votes |
| FR-03.7 | Organizer sees session stats header: questions count, votes count, answered %, participants | P1 | Real-time counters |
| FR-03.8 | Organizer can share the session link via copy button or QR code | P0 | |
FR-04: Session Analytics & Export
| ID | Requirement | Priority | Notes |
|---|
| FR-04.1 | Organizer can view analytics per session: total questions, total votes, answered %, peak question rate | P1 | |
| FR-04.2 | Organizer can export session Q&A as CSV (question text, votes count, status, timestamp) | P1 | |
| FR-04.3 | Organizer can view a timeline of when questions were submitted (activity graph) | P2 | Recharts bar chart |
| FR-04.4 | Organizer can see which questions were most divisive (high votes but left unanswered) | P2 | |
| FR-04.5 | Organizer receives a session recap email when they end a session | P1 | Inngest + Resend |
FR-05: Account & Billing
| ID | Requirement | Priority | Notes |
|---|
| FR-05.1 | User can manage their account (name, avatar, email preferences) | P0 | Supabase Auth |
| FR-05.2 | User can view current plan, upgrade, or cancel via Stripe Customer Portal | P0 | |
| FR-05.3 | Free tier allows 3 active sessions, unlimited questions per session, basic analytics | P0 | |
| FR-05.4 | Pro tier ($19/mo) allows 15 active sessions, custom branding, moderation, CSV export | P0 | |
| FR-05.5 | Business tier ($39/mo) allows unlimited sessions, priority support, team accounts, white-label | P1 | |
| FR-05.6 | User can cancel anytime β sessions remain accessible but read-only after plan downgrade | P0 | Stripe webhook |
| FR-05.7 | Trial: 14 days full-featured Pro (no credit card required) | P0 | |
FR-06: Moderation & Safety
| ID | Requirement | Priority | Notes |
|---|
| FR-06.1 | Organizer can block specific keywords (questions containing them auto-rejected) | P1 | Configurable list in session settings |
| FR-06.2 | Organizer can ban a participant by their anonymous participant ID | P1 | |
| FR-06.3 | System rate-limits question submissions (5/min per IP) | P0 | Upstash Redis |
| FR-06.4 | System rate-limits voting (1 vote per 2s per participant) | P0 | Prevents vote manipulation |
| FR-06.5 | AI moderation (optional, Pro tier): GPT-4o-mini flags toxic/spam questions | P2 | Inngest step function, runs async |
Non-Functional Requirements
| ID | Requirement | Target | Notes |
|---|
| NFR-01 | Public session page loads < 1.5s on 3G (FCP) | β€ 1.5s | RSC + minimal client JS (~40KB gzipped) |
| NFR-02 | Question submission < 500ms (p95) | < 500ms | Server Action + direct DB insert |
| NFR-03 | Vote propagation to all clients < 200ms (p95) | < 200ms | Supabase Realtime (WebSocket) |
| NFR-04 | 99.9% uptime for public session routes | 99.9% | Vercel Edge + Supabase managed |
| NFR-05 | Support 500+ concurrent participants per session | 500+ | Supabase Realtime connection limit: 500 per project (scalable) |
| NFR-06 | No PII stored for anonymous participants | β | participant_id is random UUID, not email/name/IP |
| NFR-07 | All public endpoints rate-limited | β | Upstash Redis, configurable thresholds |
| NFR-08 | CSP headers on all routes (XSS protection) | β | Content-Security-Policy via Next.js middleware |
| NFR-09 | Dashboard API responses < 200ms p95 | < 200ms | Indexed queries + Drizzle |
| NFR-10 | Auth session check < 100ms | < 100ms | Supabase Auth middleware at edge |
π Epics & User Stories
Epic 1: Session Foundation & Auth (Scaffolding)
Estimated: Day 1 morning
| Story | Description | Acceptance Criteria |
|---|
| US-1.1 | As a new user, I can sign up with my Google/GitHub account and land on an empty dashboard | Supabase Auth flow works; dashboard shows "Create your first session" empty state |
| US-1.2 | As a user, I can create a session by giving it a title and get a shareable link | Click "New Session" β enter title β session created with unique slug β copy link button appears |
| US-1.3 | As a user, I can see my created sessions listed on the dashboard | Dashboard shows session cards: title, status, question count, date created |
| US-1.4 | As a user, I can end a session or delete it from the dashboard | End session button β status changes to "ended"; Delete β confirmation β session removed |
| US-1.5 | As a user, I can change session settings (title, description, moderation toggle) | Settings page with form fields β save updates session metadata |
Epic 2: Anonymous Q&A & Real-time Voting (Core Experience)
Estimated: Day 1 afternoon β Day 2 morning
| Story | Description | Acceptance Criteria |
|---|
| US-2.1 | As a participant, I can open a session link without any sign-up and see the Q&A board | URL loads β question input + question feed visible. No auth prompt. No loading spinner. |
| US-2.2 | As a participant, I can submit a question anonymously and see it appear in the feed instantly | Type question β click Submit β card appears in feed with Realtime animation < 500ms |
| US-2.3 | As a participant, I can upvote any question and see the count update in real-time | Click upvote β count increments +1 with micro-animation β click again to toggle off |
| US-2.4 | As a participant, I can see questions sorted by most votes at the top | Feed sorted by votes_count DESC. Top question has #1 badge. |
| US-2.5 | As a participant, I can see which questions have been answered (green checkmark badge) | Answered questions show "β Answered" badge, fade to 80% opacity |
Epic 3: Organizer Live Moderation Board (Power User)
Estimated: Day 2
| Story | Description | Acceptance Criteria |
|---|
| US-3.1 | As an organizer, I can see a live-updating board of all questions with vote counts | Dashboard opens β real-time question list sorted by votes, with vote count badges |
| US-3.2 | As an organizer, I can mark a question as "Answered" with one click | Click checkmark icon β question gets "Answered" badge β participants see it in real-time |
| US-3.3 | As an organizer, I can pin an important question to the top of the feed | Pin icon β question sticks at top with "π Pinned" badge, independent of vote count |
| US-3.4 | As an organizer, I can approve or reject questions from a moderation queue | Moderation tab β pending questions β Approve (moves to live) or Reject (deletes with notification) |
| US-3.5 | As an organizer, I can share the session link or a QR code from the dashboard | Share button β copy link + display QR code (via qrcode.react) |
Epic 4: Analytics, Export & Replay (Insights)
Estimated: Day 2 afternoon β Day 3 morning
| Story | Description | Acceptance Criteria |
|---|
| US-4.1 | As an organizer, I can see per-session stats: total questions, votes, answered % | Analytics tab shows stat cards with real-time counters |
| US-4.2 | As an organizer, I can export session Q&A as CSV | Export button β downloads CSV with: question text, votes, status, timestamp, answered_at |
| US-4.3 | As an organizer, I can view a question submission timeline (activity graph) | Bar chart showing questions per minute/hour across session duration (Recharts) |
| US-4.4 | As a participant, I can view an ended session's replay (read-only Q&A log) | Ended session link shows full question list sorted by final vote count, no new submissions allowed |
| US-4.5 | As an organizer, I receive a recap email when I end a session | Email sent via Resend with: total questions, top-3 voted, answer rate, participant count |
Epic 5: Payments & Plans (Monetization)
Estimated: Day 3 morning
| Story | Description | Acceptance Criteria |
|---|
| US-5.1 | As a user, I can see my current plan and upgrade on the billing page | Billing page shows plan card with features, "Upgrade" button β Stripe Checkout |
| US-5.2 | As a user, I can manage my subscription via Stripe Customer Portal | "Manage Subscription" β Stripe-hosted portal β change/cancel plan |
| US-5.3 | As a Pro user, I have access to custom branding (custom logo on session page) | Session settings β upload logo β session page shows logo instead of default |
| US-5.4 | As a Free user, I'm limited to 3 active sessions but can upgrade anytime | Attempt to create 4th session β upgrade prompt modal with feature comparison |
| US-5.5 | As a Business user, I can add team members to my account | Team management page β invite by email β team members share sessions |
Epic 6: Polish, Safety & Edge Cases (Launch Ready)
Estimated: Day 3 afternoon
| Story | Description | Acceptance Criteria |
|---|
| US-6.1 | As an organizer, I can set blocked keywords that auto-reject questions | Settings β "Blocked Words" textarea β questions containing those words go directly to rejected |
| US-6.2 | As an organizer, I can mute a disruptive participant | Ban by participant_id β their future submissions are silently rejected |
| US-6.3 | As a participant, I'm rate-limited if I try to spam questions | 5 questions in rapid succession β 6th shows "Too many questions. Please wait." toast |
| US-6.4 | As a participant, duplicate questions are prevented (same text within 30s) | Submit exact same question twice β "This question was already asked" with link to existing |
| US-6.5 | As a user, the UI gracefully handles network disconnects (offline toast) | Realtime connection drops β "Reconnecting..." indicator β auto-reconnects via Supabase SDK |
π° Pricing Model
| Feature | π Free | π Pro ($19/mo) | π’ Business ($39/mo) |
|---|
| Active sessions | 3 | 15 | Unlimited |
| Questions per session | Unlimited | Unlimited | Unlimited |
| Participants per session | 50 | Unlimited | Unlimited |
| Moderation queue | β | β
Accept/Reject/Block | β
+ Auto-keyword blocking |
| Custom branding | β (TownHall logo) | β
(Upload your logo) | β
(White-label, no TownHall brand) |
| CSV export | β | β
| β
|
| Analytics | Basic (question count) | Detailed (votes, timeline, answered %) | Full (all metrics + export) |
| Session replay | β
(read-only) | β
| β
|
| Blocked keywords | β | β
(up to 20) | β
(unlimited) |
| Team seats | 1 | 1 | 5 (extra seats $10/mo) |
| AI Moderation | β | β | β
(GPT-4o-mini flagging) |
| API access | β | β | β
(CRUD sessions + export) |
| Support | Email (72h) | Email (24h) | Priority (4h) + Slack |
Annual pricing: Pro $190/yr (save $38), Business $390/yr (save $78)
Free trial: 14 days full-featured Pro (no credit card required)
π’ Go-to-Market Strategy
Pre-Launch (Days 1β7)
- Build in public β Tweet thread on X (@TownHallHQ) showing the session creation flow, anonymous Q&A, and real-time voting. Share a screen recording of the organizer marking questions answered live.
- Product Hunt Teaser β Create pre-launch page with a GIF of the real-time voting flow. Share on Indie Hackers.
- Personal outreach β DM 20 engineering managers and meetup organizers offering free lifetime Pro in exchange for running their next all-hands with TownHall and providing feedback.
Launch (Day 7β14)
- Product Hunt launch β Post with the hook: "Anonymous Q&A for your meetings. No sign-up. No app install. One link." Offer 30% off first 3 months for launch week.
- Indie Hackers post β "I built a real-time anonymous Q&A tool in 3 days β here's how it works with Supabase Realtime." Technical deep-dive that appeals to the IH audience.
- Community launches β Post in r/SaaS, r/startups, r/programming. Target: "Ever hosted a Q&A where nobody asked anything? Here's the fix."
Post-Launch (Weeks 2β4)
- Content marketing β Blog posts: "How to Run a Better All-Hands Meeting", "The Case for Anonymous Q&A in Remote Teams", "5 Ways to Get Your Team Asking Questions."
- Meetup sponsorship β Offer free Business tier to 10 popular meetup groups (React Brussels, Python London, etc.) in exchange for using TownHall and providing a testimonial.
- Conference outreach β Discounted event plans for conference organizers. Pay-per-event: $5/session for up to 500 participants.
- Open source communities β Free unlimited sessions for open-source projects and their community calls.
Growth Channels
| Channel | Strategy | Cost |
|---|
| Product Hunt | Launch with demo + launch discount | Free |
| Indie Hackers | Build in public threads + technical deep-dive | Free |
| Dev communities | Reddit r/SaaS, r/startups, Hacker News | Free |
| Meetup.com | Direct outreach to meetup organizers | Free (manual) |
| Content marketing | "Anonymous Q&A" SEO + meeting best practices | $0 (long-tail) |
| Conference partnerships | Discounted/white-label for events | Low (time) |
| Embeddable widget | "Add Q&A to your conference page" β embed snippet | Viral loop |
π Success Metrics
| Metric | Target (90 days) | Why It Matters |
|---|
| Total signups | 400+ | Top-of-funnel health |
| Sessions created | 1,000+ | Core engagement β are people actually using it? |
| Questions submitted | 10,000+ | Is the anonymous format working? Volume signal. |
| Paid conversions | 6β8% free β paid | Pricing + product-market fit signal |
| MRR | $1,200β$2,500 | Viability as sustainable micro-SaaS |
| Active session rate | > 40% of created sessions go live | Are sessions getting shared or abandoned? |
| Questions per session | > 8 avg | Quality signal β sessions with real participation |
| Churn (monthly) | < 6% | Retention health |
| Vote-to-question ratio | > 3:1 | Engagement depth β people are voting, not just lurking |
| Time to first question | < 30s from session open | UX optimization metric β zero friction test |
| NPS | > 40 | Word-of-mouth growth potential |
β οΈ Risks & Mitigations
| Risk | Severity | Likelihood | Mitigation |
|---|
| Spam/abuse (bots submit hundreds of questions) | High | Medium | Upstash rate limiting (5/min per IP); moderation queue on Pro; blocked keywords; report feature |
| Vote manipulation (organizer or participant rigs voting) | Medium | Low | IP + participant_id rate limiting; one vote per participant per question; admin can reset votes |
| Low engagement (people still don't ask questions) | High | Medium | Anonymous-by-default lowers barrier; session start notification emails; "Be the first to ask!" nudges; gamification (top-voted question gets shoutout) |
| Realtime scaling (500+ concurrent participants overwhelm WebSocket) | Medium | Medium | Supabase Realtime supports 500 concurrent per project; can scale to dedicated Realtime server or use Ably as fallback |
| Enterprise competitor (Slido/Mentimeter launches free tier) | High | Low | Focus on indie/community market where enterprise won't compete; differentiate on zero-setup + white-label |
| PII leakage (participant IP or data exposed) | High | Low | No PII stored; participant_id is random UUID; no IP logging in question/vote tables; CSP headers |
| Session hijacking (someone shares the organizer link) | Medium | Low | Organizer link is different from participant link; session settings page requires auth; ability to regenerate participant links |
| Duplicate questions fatigue (same question asked 10 times) | Medium | Medium | Duplicate detection (same text within 30s β auto-merge suggestion); upvote existing instead of re-asking |
| Low conversion to paid | High | Medium | Strong free tier builds habit; watermark/branding removal is key conversion driver; 14-day Pro trial |
| Technical complexity of Realtime sync | Medium | Low | Supabase Realtime handles WebSocket management; fallback to polling if WebSocket fails |
π Competitors
| Competitor | Strengths | Weaknesses | How TownHall Wins |
|---|
| Slido | Polished UI, enterprise integrations (Zoom, Teams), polling | $124+/mo, heavy feature set, overkill for small teams | Price ($19 vs $124), no-account participation, simpler UX |
| Mentimeter | Beautiful presentations, live polls, word clouds | $84+/mo, presentation-first, not Q&A-native | Purpose-built for Q&A, anonymous-by-default, real-time voting focus |
| Google Docs / Notion | Free, everyone has it | Not real-time, no voting, no anonymity, chaotic | Real-time voting, anonymous submission, structured moderation |
| Zoom Chat / Slack Threads | Already in use, low friction | Not anonymous, no voting, gets buried in noise | Anonymous voting, persistent session, organized feed |
| Poll Everywhere | Enterprise trust, multiple question types | $120+/mo, complex setup, not anonymous-first | Zero-setup, anonymous-first design, lower price |
| Tally / Typeform | Form builders, customization | Not real-time, no live voting, no Realtime | Real-time WebSocket voting, live feed, session-based |
| Discord Q&A threads | Free, community already there | Not anonymous, no voting, chaotic threading | Anonymous voting, sorted by consensus, session scoping |
TownHall's Moat
- Zero friction for participants β No app install, no sign-up, no email. Open link β ask question. This is the single biggest barrier competitors don't solve.
- Real-time voting via WebSocket β Supabase Realtime makes vote propagation feel instant. No polling, no refresh. Competitors require page reloads or have noticeable lag.
- Price-to-value ratio β $19/mo for a tool that replaces $84-$124/mo competitors, purpose-built for the indie/community market.
- Designed for recurring use β Weekly standups, monthly all-hands, recurring meetups. Not just one-off conferences. The dashboard shows session history, analytics across sessions, and weekly digests build the habit.
- Moderation-first β Enterprise tools hide moderation behind admin panels. TownHall puts it front and center: approve, reject, pin, block keywords β all from the live board.
π Brainstorm Review Score
Review Date: 2026-07-16
| Metric | Score | Rationale |
|---|
| Feasibility (1-5) | 1 | The PRD claims 3 days but includes full SaaS billing with 3 tiers (Free/Pro/Business), Stripe Checkout + Customer Portal + webhooks, background job orchestration via Inngest (4+ job types), real-time WebSocket infrastructure via Supabase Realtime, 3+ OAuth/integration flows (Supabase Auth with Google + GitHub), 9+ external services (Supabase, Upstash, Inngest, Stripe, Resend, Sentry, PostHog, Vercel), 7+ database tables with foreign keys, and complex moderation, analytics, export, replay features. Real implementation time: 8+ days minimum for a polished MVP. Hard rule violations: (1) Full billing + multi-tenant + customer-portal, (2) Background-job orchestration as core dependency, (3) 3+ OAuth flows. |
| Market Fit (1-5) | 5 | Strong problem: people don't ask questions in large meetings due to fear/judgment. Clear target users: engineering managers, meetup organizers, conference speakers. Wedge against competitors: zero-friction for participants (no account, no email), real-time voting, $19/mo vs $84-124/mo. Multiple viable monetization paths (per-event, subscriptions). The market fit is excellent - this is a real pain point with paid incumbents. |
| Complexity (1-5) | 1 | Too complex for 2-3 days: real-time sync, background jobs, multiple integrations, billing infrastructure. Requires implementing and debugging WebSocket subscriptions, rate limiting, Stripe webhooks, email workflows. The moderation, analytics, export, replay features alone are multiple days of work. Even with Supabase abstracting some infrastructure, this is a full-stack SaaS product. |
Total Score: 7/15
Status: βΈοΈ Deferred - Total < 12
Rationale: Strong market fit (5/5) but needs ~80% scope reduction. Rescope path: (A) client-side only (no DB, no auth, no backend) β localStorage + export to self-contained HTML file for session history, (B) single-user minimal server (no billing, no background jobs, no email, simplified analytics - just vote counts and question stats in PostgreSQL), or (C) "TownHall Lite" β anonymous Q&A board with real-time voting via Supabase Realtime, but no moderation, no analytics, no export, no replay, no billing. The core value prop (anonymous + real-time voting) can be preserved with a radically simplified MVP.