What It Is
Zappy is a white-label tournament platform for esports communities — Discord servers, college clubs, creator-led leagues — that gives each organization its own branded site at <org>.zappy.page. An admin spins up a tournament from the dashboard (name, format, ticket price, prize pool); Zappy mints the bracket, themes the org's portal with their logo and background, and lets players register teams via Discord/Twitch/Twitter OAuth and pay entry fees through the org's wallet. During play, each tournament becomes its own Socket.io namespace — players connect, sit in a waiting room until their match opens, and once both captains report a score (or two minutes elapse), the bracket advances and prize money flows platform → org → captain.
Architecture
The architecture is shaped by two distinct rendering models. The player-facing portal has to render per-tenant on first byte (one Next.js deployment, 131 white-label sites at <org>.zappy.page); the admin dashboard is a single-tenant SPA that doesn't need the rewrite trickery. Both share one Heroku web dyno running Express + Apollo GraphQL + Socket.io against MongoDB. A separate worker dyno exists because match-result writes have to be queue-mediated — bracket logic lives at a third-party API (Challonge) that doesn't issue webhooks, so Zappy bridges that to a live UX through Bull queues + Redis pub-sub (covered in deep dive #1). Multi-tenancy is enforced at the data layer through the mongo-tenant Mongoose plugin: every player, team, tournament, and registration row carries a tenantId, and every resolver reads Model.byTenant(accountId) before any query — one missed filter would leak data across 131 orgs. Auth is @hapi/iron-sealed JSON tokens carrying { userId, tenantId }, issued on Discord/Twitch/Twitter OAuth callback. Bracket logic is delegated entirely to Challonge; each org's API key is field-encrypted at rest, and the Challonge URL slug is the tournament's external identity. Real-time runs through a regex Socket.io namespace per tournament, with each match as a room. Payments split across Stripe Connect Express (cards into the org's connected account with a 5% application fee) and Dots' wallet rails (player → org → captain), with prize payouts going exclusively through Dots.
Technical Deep Dive
1. Live Match Mirror over Challonge
A Zappy tournament works like a single-elimination bracket: teams play 1v1 matches, the winner advances, the loser is out. Two team captains finalize the score from their browsers, and within seconds both see their next state — the winner sees who they play next, the loser sees a "tournament ended" screen — without anyone refreshing. That live update is what this dive covers.
Bracket logic itself isn't Zappy's. It's delegated entirely to Challonge, a third-party tournament-management API that owns the truth of who-plays-who, who-won, and who-advances. Challonge is fine at being a CRUD service, with one constraint that shapes the whole design: it issues no webhooks for match advances. If the bracket changes, you only find out by asking. The naive workarounds both fail. Letting clients poll Challonge directly burns the org's per-account API quota at scale — 16k players hammering one tournament minute drains the rate limit before round-1 ends. Routing every poll through the backend collapses that load onto one process and stacks Challonge's latency on top. Neither delivers a "your opponent just joined" notification, because Challonge produces no such signal at all.
The fix is a server-side mirror layer: Zappy keeps a per-player view of "what's your current match?" in Redis and pushes updates to clients over Socket.io. Each tournament becomes its own Socket.io namespace, and each match a room. At tournament start, a cacheAllMatchesQueue Bull job fetches every open match into Redis under both players' keys with a 5-minute TTL. Captain reports enqueue a matchReportQueue job with a 2-minute delay; the opposing captain's matching report inspects the existing job, removes it, and re-enqueues with delay 0 — same jobId, second writer's score wins, identity-checked against match.reporterId so the same captain can't double-report. The worker PUTs the result to Challonge, re-fetches each player's new match into Redis, then exits with done(null, { room, tournamentChallongeUrl }). The cross-process bridge is the architectural punchline: lib/bull.js is loaded from the web process (which holds Socket.io's io handle), so when Bull publishes global:completed over Redis pub-sub from the worker, the web process receives it and emits refetch into the match room. Clients re-emit getCurrentMatch, hit the now-fresh cache, and transition state.
The result is a push-driven live-match UX on top of a strictly pull-only REST API. Zappy's load on Challonge stays constant in spectator count — exactly two writes per match advance, regardless of how many people are watching the bracket.
2. Per-Tenant Host Routing
Each esports org running Zappy gets their own branded tournament site at <org>.zappy.page — same domain root, different logo, background, and name on every variant. Visit one and the page is fully their brand on first paint, nothing flashes, nothing waits for branding to load. There are 131 of them in production, sharing zero design content. They all serve from one Next.js deployment.
Three obvious approaches don't fit. Per-tenant Next.js deployments mean 131 Vercel projects, 131 build pipelines, 131 caches — every feature ships through 131 deploys. Wildcard routing with a client-side branding fetch kills SSR; first paint shows no logo, no background, no page title until a round-trip completes — exactly the outcome a white-label product can't ship. Next.js Middleware (introduced as beta in Next 12, the version this build runs on) could read the Host header at the edge but had limited runtime APIs at the time and pushed tenant resolution out of the standard getStaticProps lifecycle.
The pragmatic middle path is to rewrite at the framework layer. next.config.js matches every public path with a has condition that captures the Host header via the named-capture regex (?<host>.*), injecting it as the first segment of the internal route. A request to https://myorg.zappy.page/login lands on pages/[host]/login.js with params.host = 'myorg.zappy.page'; every page runs getAccountByDomain(host) in getStaticProps with revalidate: 5. First hit on any domain triggers on-demand SSG, subsequent hits inside the window serve cached HTML. The catch is that Next.js's router then pushes the internal path to the browser's history stack, so the address bar would read myorg.zappy.page/myorg.zappy.page/login. The fix is a single (admittedly hacky) line: a routeChangeComplete listener that splits the URL, checks if paths[1] === location.hostname, and calls history.replaceState(history.state, '', '/' + paths.slice(2).join('/')) to strip the host segment — passing the existing history.state so back/forward still work.
131 fully-SSR'd white-label sites from one deployment, ISR cache making tenant pages nearly free at scale after first hit. The price is a 5-second staleness window on branding updates, no per-org accent colors (white-label is logo + background only), and one line of history-state surgery on every client-side navigation.
