Real-time PWA built and deployed for a live ~300-person TEDx event. Attendees scanned a QR code to join live reactions, polls, a shared conversation feed, and a QR-based challenge leaderboard.
Served 60+ concurrent users during the event with zero downtime.
Stack: .NET 9 · SignalR · React 18 / TypeScript · PostgreSQL · GitHub Actions
Two-service architecture with a real-time WebSocket layer:
React PWA (Vite)
│
├── REST ──► .NET 9 Minimal APIs
└── WS ──► SignalR Hub ──► PostgreSQL (EF Core)
All real-time features (reactions, polls, leaderboard updates) flow through a single SignalR hub. The frontend maintains a singleton HubConnection shared across all tabs so listeners stay alive during tab switches.
GitHub Actions pipeline on every push to main:
- Build and test .NET backend
- Lint and type-check React frontend
dotnet publishbundles Vite frontend into backend wwwroot
| Feature | Mechanism |
|---|---|
| Emoji reactions with live counts | SignalR broadcast |
| Live poll with real-time vote tallies | SignalR + idempotency guard |
| Shared conversation feed with upvotes | SignalR broadcast |
| QR challenge scanner with live leaderboard | SignalR per-caller + broadcast |
tedx-app/
├── TedxBackend/ .NET 9 — Minimal APIs, SignalR hub, EF Core
│ ├── Hubs/ SignalR hub (all real-time methods)
│ ├── Models/ EF Core entities
│ └── Data/ DbContext
└── tedx-frontend/ React 18 + TypeScript + Vite + Tailwind
├── src/components/ Live, Quest, Board tabs
├── src/utils/ SignalR singleton, API wrappers
└── src/hooks/ Swipe handler, custom hooks
Backend
cd TedxBackend
dotnet run # http://localhost:5085Requires PostgreSQL. Connection string in appsettings.json.
Override via env: ConnectionStrings__DefaultConnection=Host=...
Frontend
cd tedx-frontend
npm install && npm run dev # http://localhost:5173Vite proxies /api, /eventHub, /speakers to the backend.
Production build
dotnet publish -c ReleaseTriggers npm run build, copies dist/ into wwwroot/.
Backend serves the SPA via static files + fallback to index.html.
Admin endpoints (POST /api/admin/*) require X-Admin-Secret header.
Override the default via environment variable before any deployment:
AdminSecret=your-secret-here