zach.dev
← All posts

May 4, 2026

Building League Island: From a LoL Bracket Tool to a Multi-Tenant Tournament Platform

What started as a League of Legends tournament manager grew into League Island—a multi-tenant platform with host-branded sites, Riot API workflows, live scorekeeping, Discord automation, and Stripe Connect. This post is about the architecture choices, the features that forced schema rewrites, and what I’d do differently if I started again.

Building League Island: From a LoL Bracket Tool to a Multi-Tenant Tournament Platform

When I started this project, the goal was narrow: stop running League of Legends tournaments in spreadsheets. Generate a bracket, track matches, pull results from Riot when possible. The repo is still called lol-tournament, but the product outgrew that name long ago. Today it’s League Island—a multi-tenant platform where each organizer gets a branded site, a full admin console, and game-specific tooling for everything from registration to broadcast overlays.

This isn’t a launch announcement. It’s a honest look at how the system evolved, what broke along the way, and what actually held up.

It started simple (and that was the trap)

The first version was a single-organizer mental model: tournaments, teams, matches, games. That maps cleanly to tables and CRUD screens. It also maps cleanly to regret once a second league asks for their own logo, URL, and admins.

The pivot to multi-tenancy was the defining architectural decision. Every meaningful entity needed a host_id. Public routes became /{host-slug}/... instead of a flat /tournaments list. Users aren’t just “players”—they’re host-scoped players, because the same person might compete in two different leagues with different identities and rosters.

If you’re building something “for your friends’ league” and you have any appetite for a second customer, pay the tenancy tax early. I paid it in migration 0006 and a long tail of follow-ups—not catastrophic, but expensive in mental overhead.

Stack: boring on purpose

I shipped with a stack I could move in quickly:

  • Next.js (App Router) for UI and API routes in one repo
  • TypeScript everywhere the compiler could help
  • Supabase / PostgreSQL as the system of record
  • NextAuth.js for sessions
  • Tailwind CSS for styling velocity

“Boring” was the point. Tournament software is mostly state machines + scheduling + permissions. Fancy infrastructure doesn’t help when a captain can’t register at 11:47 p.m. the night before lock.

The repo now has on the order of 70 SQL migrations. That number is both a badge of honor and a warning label. Schema design is product design here: adding Swiss brackets, match timelines, pickleball rally events, or Stripe payment states isn’t a sidebar—it’s a migration and a backfill story.

Brackets are easy to draw and hard to run

Visual brackets are the feature everyone asks for first. They’re also the feature that explodes in edge cases:

  • Single vs double elimination with correct advancement paths
  • Swiss round 1 pairings vs “just show rounds in columns”
  • Seeding, byes, and stages that don’t all have brackets yet
  • Scores and scheduled_at on nodes that update while someone is staring at the page

I centralized generation and advancement in lib/bracketUtils.ts and leaned on tests early. That file became one of the highest-leverage test targets in the project—559 tests across 139 suites didn’t appear because I love green checkmarks; they appeared because bracket bugs are silent until finals week.

Server-generated bracket posters (ImageResponse / Satori-style rendering) were a fun detour: shareable PNGs without asking staff to screenshot a DOM tree in a weird zoom level. Same visibility rules as the JSON APIs—public data only.

Real-time: SSE first, humility second

Live tournaments want live UI. I implemented Server-Sent Events on a match stream: score changes, state transitions, bracket advancement, pickleball live updates.

SSE was the right default for this app: one direction, browser-native reconnect story, simpler ops than standing up a websocket farm for v1.

The lesson wasn’t “SSE vs WebSockets.” The lesson was always ship a fallback. When the stream drops, polling (even coarse polling) keeps the public page from lying. Production networks, sleeping laptops, and phone browsers will defeat your ideal path.

Riot integration: the API giveth and the timeline taketh away

League of Legends isn’t “enter scores manually” for serious leagues. Organizers expect:

  • Tournament codes per game (bulk generate, validate, track used/expired)
  • Imports from Riot with region defaults and callback handling
  • Match-V5 timelines stored in Postgres for analytics—not shipped as megabytes of JSON to every spectator

Timelines unlocked real product value: gold/XP charts on public match pages, derived stats like gold diff at 15, staff backfill when imports lag. They also unlocked storage, caching, and canonical match ID headaches. I added derived series caching with invalidation on upsert because recomputing chart series on every page view is how you accidentally DDoS yourself.

If you integrate a third-party game API, budget time for idempotency, retries, and “404 that actually means merge these two IDs.” The game is live; your DB is forever.

Discord: great UX, distributed system vibes

Discord started as OAuth linking and became an operational surface: channels, roles, match reminders, deadline nags, slash commands, rich match imagery.

The product win is obvious—players already live there. The engineering win is narrower: treat Discord as an unreliable downstream. Your tournament state must stay correct if a channel post fails. Cron jobs and dedupe columns (deadline_reminder_sent_at) sound unglamorous; they prevent “why did three bots ping #general.”

Pickleball: when “multi-game” really means “multi-product”

Adding pickleball taught me more about product boundaries than about pickleball. Live scorekeeping, rally-level events, spectator views, venue fields—none of that belongs stuffed into LoL match code as #ifdef PICKLEBALL.

We landed a cleaner rule: game_key on hosts and tournaments, and if an org runs LoL and pickleball, they run separate hosts. One brand per game. Less magical for marketing, far less cursed in permissions and analytics.

Payments: Stripe Connect is a feature, not a checkbox

Paid team registration pushed the platform into Stripe Connect Express, Checkout Sessions, application fees, webhooks, refunds, and (for US go-live) Stripe Tax.

The product rules are simple to say and hard to implement:

  • Host sets the registration subtotal
  • Platform fee = basis points with a minimum (integer cents; floor the bps math)
  • Application fee applies to subtotal, not sales tax
  • Don’t let hosts enable paid registration until Connect onboarding can charge

Webhooks are the real admin UI. Idempotency tables, reconciling completed sessions when the UI lags, reusing open Checkout sessions on retry—these are the details users never see until money is involved and then they see nothing else.

I’m still cautious calling payments “done.” Tax registrations, live-mode verification, and ops checklists are the kind of finish line that isn’t a Git merge.

Docs inside the product

Somewhere along the way I migrated help content into the database—89+ articles—because “read the Notion” doesn’t scale when you have host admins, staff, and players with different permissions.

Building a help center sounds like procrastination. It’s actually scope control: every weird registration rule becomes a support ticket unless it’s documented next to the button that triggers it.

What I’d tell past-me

  1. Tenancy first if there’s any chance of a second league.
  2. Test the scary libs, not the easy React components—brackets, fees, pairing algorithms, fee math in cents.
  3. Real-time needs degradation—SSE + polling beats SSE alone.
  4. Integrations get tables—webhook idempotency, reminder dedupe, timeline source backfills.
  5. Say no with architecture—separate hosts per game saved months of conditional UI hell.
  6. Ship slices vertically—a tournament can go from draft → registration → bracket → live match without waiting for the “perfect” analytics dashboard.

Where it stands

League Island is active development, not a weekend demo. Host onboarding, tournament wizards, match operations, LoL timelines, pickleball live scoring, Discord automation, broadcast overlays, notifications, and Stripe paid registration paths all exist in various stages of polish.

The through-line for me: tournament software is operations software. Reliability, permissions, and clear state beat feature count. Every migration in supabase/migrations/ is a diary entry about that lesson.

If you’re building in the same space—esports, rec leagues, school competitions—the hard parts won’t be React components. They’ll be time zones, forfeits, refunds, and the night-before-registration rush. Build for that hour. Everything else is decoration.