Créer GigaBracket : une plateforme SaaS de gestion de tournois en Next.js + PocketBase
TL;DR
GigaBracket est une plateforme SaaS de gestion de tournois esports. Fonctionnalités : créer un bracket, inviter des joueurs, générer les appairages automatiquement (format Swiss, double élimination, poules + croisements, etc.), tracker le score en live, exporter les résultats. Stack : Next.js 16 (App Router, RSC, Turbopack), Tailwind v4, PocketBase self-hosted sur Coolify, Radix UI, next-intl. Défi clé : implémentation des algos de formats à partir de la logique métier brute. Résultat : launched juillet 2026 pour la Fédération GIGA'GAMES Guadeloupe. ~500 tournois gérés en premiere version.
1. Origine & contexte
GIGA'GAMES est une fédération esports et jeux vidéo basée en Guadeloupe (Antilles). Ils organisent des compétitions de Smash Bros, Street Fighter, League of Legends, Tekken, etc. Leurs tournois vont de 8 à 200+ joueurs par event.
Avant GigaBracket : organisateurs utilisaient des spreadsheets (Google Sheets, Excel) et des outils généralistes (challonge, smash.gg, challonge clone local). Les problèmes :
- Zéro personnalisation. Pas du design GIGA'GAMES.
- Offline : si wifi tombe pendant un tournoi, plus rien marche.
- Pas multilingue (pour le contexte international).
- Algos des formats pas toujours corrects (Swiss bugué, poules confuses).
- Pas d'intégration locale. Faut cliquer 20 fois pour une action simple.
Demande : créer une plateforme for GIGA'GAMES, by GIGA'GAMES. Branding custom, algo corrects, offline-capable, simple.
2. Stack : pourquoi ces choix
Next.js 16 (App Router + Turbopack)
- App Router : j'ai besoin d'une structure claire page → routes API → RCS (React Server Components).
- RSC : certains calculs (génération bracket, tri scores) se font côté serveur. Pas besoin d'envoyer la logique au client.
- Turbopack : build 10× plus vite que webpack. Sur une machine avec peu de ressources (mon laptop MacBook), ça compte.
Alternative ? SvelteKit aurait marché. Mais je viens de Next, la team GIGA connaît React, et l'écosystème Next est plus mature pour du SaaS.
Tailwind v4 (CSS-first @theme)
Tailwind v4 c'est une révolution : au lieu de customiser via JavaScript, tu définis les couleurs/tokens en CSS. Pour GigaBracket :
@theme {
--color-neon-cyan: #00ffff;
--color-neon-pink: #ff006e;
--font-size-display: 3rem;
}
Resultat : zéro config JS, thème GIGA'GAMES c'est en CSS. Les designers peuvent le toucher sans peur.
Alternative ? Styled-components, emotion. Mais Tailwind v4 était optimisé et le build était plus rapide.
PocketBase self-hosted
PocketBase c'est une base SQLite + une API REST. Au lieu de PostgreSQL + server code, tu as une DB avec une API.
Choix stratégique :
- Pas de serveur Node custom. PocketBase c'est un single binaire. Déploie sur Coolify (chez Dim's Info Services) et c'est fait.
- Auth intégré : JWT, user system, password reset. Gratis.
- Realtime WSS : tu subscribe à une collection et tu reçois les updates push (quand un match est entré, tous les spectateurs reçoivent live).
- SQLite : léger, pas besoin de gérer une DB compliquée. Pour 500 tournois et 5k+ joueurs, SQLite c'est assez.
- Self-hosted : les données GIGA restent en Guadeloupe (compliance, pas de tiers).
Alternative ? Supabase (trop cher pour la scale), Firebase (pas self-hosted), prisma + database custom (trop de friction).
Radix UI + Lucide
- Radix : composants non-stylisé (buttons, modals, tabs sans design). Je stylise tout via Tailwind.
- Lucide : icons gratuites et cohérentes.
Avantage : zéro deps CSS, full Tailwind. Build est plus léger.
next-intl (multilingue)
Formats de tournois existent en FR et EN. Les joueurs peuvent choisir leur langue.
next-intl gère :
- Routing :
/fr/tournoisvs/en/tournaments. - Traductions : messages/fr.json vs messages/en.json.
- Switcher : un boutton pour passer de langue.
Simple et performant.
3. Architecture & strucure de fichiers
app/
[locale]/
(marketing)/ Landing, About, FAQ
(app)/ Dashboard, wizard, tournoi detail (auth required)
spectate/[id]/ Bracket public (pas d'auth)
components/
ui/ Boutons, inputs, cards (Radix + Tailwind)
brand/ Logo GIGA'GAMES
tournament/ Wizard, bracket visuel, score entry
formats/ Composants pour chaque format
player/ Management, import CSV
spectator/ Affichage public
shared/ Locale switcher, nav
lib/
pocketbase/ Client PocketBase + helpers auth
config/ Tokens, constantes
formats/ Algos purs (Swiss, double elim, etc.)
i18n/ Config next-intl
mock/ Données de démo
utils/ cn(), formatDate(), etc.
messages/
fr.json, en.json Traductions
4. Le défi technique : formats de tournois
C'est le cœur du produit. Les algos des formats.
Format 1 : Élimination directe (simple)
Arbre classique. N joueurs → semifinal → final. Byes auto si N impair.
Implém :
function generateSingleElimBracket(players: Player[], matchCount = 1) {
const rounds = Math.ceil(Math.log2(players.length));
// ... calcule combien de byes, crée les matchs du round 1
}
Facile, demande ~2h.
Format 2 : Double élimination
Winners bracket (pas de perte) + losers bracket (on peut revenir). À la fin, final entre le winner de winners et le survivor des losers.
Logique :
- R1 : tous jouent dans winners.
- Qui perd en winners → va en losers.
- Qui perd en losers → éliminé.
- Semifinal losers : premier perdant de la finale winners vs dernier gagnant des losers.
Implém :
function generateDoubleElimBracket(players: Player[]) {
const winnersRounds = Math.ceil(Math.log2(players.length));
const losersRounds = winnersRounds;
// ... crée deux arbres en parallèle, gère les flows
}
Plus dur : tu dois tracker qui est où dans les deux arbres. Demande ~6h.
Format 3 : Swiss
Chaque joueur joue N rondes (exemple : 7 rondes pour 128 joueurs). À chaque ronde, on appaie les joueurs intelligemment :
- Ceux avec le même score ensemble.
- Jamais deux fois le même adversaire.
L'algo de "Swiss pairing" est un problème NP-hard. Les implémentations exactes sont lentes.
Solution : algorithme glouton + validation post (si ça clash, tu permutes paires).
function generateSwissRound(standings: Standings[], round: number) {
// Trie par score
const sorted = standings.sort((a, b) => b.score - a.score);
// Coupe en deux : top half vs bottom half
const mid = Math.ceil(sorted.length / 2);
const topHalf = sorted.slice(0, mid);
const bottomHalf = sorted.slice(mid);
// Appaie sequentiellement, évite les rematches
const pairs = [];
for (let i = 0; i < topHalf.length; i++) {
const opponent = findOpponentNotPlayed(topHalf[i], bottomHalf, pairs);
pairs.push([topHalf[i], opponent]);
}
return pairs.map(p => createMatch(p[0], p[1], round));
}
Résultat pas parfait mais 95 % du temps, c'est bon. Demande ~12h (+ tester sur des cas reels).
Format 4 : Poules + Croisement
Exemple : Coupe du Monde. Groupe A 4 joueurs, Groupe B 4 joueurs. Chacun joue tous dans son groupe (round-robin). À la fin, classement : 1A vs 2B en semifinal, 2A vs 1B en l'autre semifinal.
Logique :
- Phase 1 : round-robin par poule.
- Phase 2 : knockout croisé selon les positions.
Implém : combiner round-robin + single elim.
function generatePoolsAndCrossover(players: Player[], poolSize: number) {
// Divise en poules
const pools = [];
for (let i = 0; i < players.length; i += poolSize) {
pools.push(players.slice(i, i + poolSize));
}
// Round-robin dans chaque poule
const roundRobins = pools.map(p => generateRoundRobin(p));
// Croisement : 1A vs 2B, etc.
const qualified = extractQualified(roundRobins);
const knockout = generateSingleElim(qualified);
return { poolsPhase: roundRobins, knockoutPhase: knockout };
}
Demande ~15h (complexité + tester tous les cas).
Total : ~35h sur les formats seuls
Debugging, testings, validation avec GIGA : +20h.
5. Score entry & realtime
Pour un tournoi live, les arbitres entrent les scores en temps réel. Trois besoins :
- Rapidité : entrée doit être ultra-simple (5 secondes par match).
- Realtime : spectateurs voient le score immédiatement.
- Offline : si wifi tombe, les arbitres peuvent continuer, puis sync après.
Score entry UI
Match : Player A vs Player B
Score A : [input] Score B : [input]
[Soumettre]
Rien de plus. Pas de drag-drop, pas de flashiness. Speed.
Realtime
// Arbitre soumet
const match = await pb.collection('matches').update(matchId, {
scoreA: 2, scoreB: 1, submitted: true
});
// PocketBase webhook : update standings
// Frontend subscribe à changes
const unsubscribe = pb.collection('matches').subscribe('*', (e) => {
// Update UI live
});
Offline
PocketBase a une feature : sync local ↔ serveur quand connexion revient.
Alternative fallback : localStorage + manual sync. Mais PocketBase le gère.
6. Design & branding
GIGA'GAMES a un branding établi : couleurs Neon (cyan, rose, orange), mascotte "Mr Giguane", énergie esports/arcade.
GigaBracket suit ce branding :
- Palette Tailwind v4 : cyan = primary, rose = accent.
- Fonts : Space Mono (mono) + Poppins (sans-serif), standard esports.
- Icons : Lucide (customisé).
- Animations : motion pour le click feedback (pas d'animation inutile).
Design était fait en Figma par la team GIGA, puis j'ai implémenté en React + Tailwind.
7. Déploiement & ops
Frontend (Next.js)
Déployé sur Vercel (CDN + serverless). Build ~45 sec. Redeploy auto sur push main.
Backend (PocketBase)
Déployé sur Coolify (chez Dim's Info Services).
- Docker container.
- Volume pour la DB SQLite.
- Reverse proxy Nginx.
- SSL auto (Letsencrypt via Coolify).
Database backups
PocketBase peut exporter la DB en JSON. Script cron chaque soir → backup sur OneDrive.
Restore time : < 5 min.
8. Résultats & leçons
Launch : juillet 2026
Beta avec GIGA'GAMES. ~10 tournois de test. Version 1.0 : juin 2026 (oui, je l'ai déjà lancée).
Metrics actuels
- ~500 tournois créés (6 mois).
- ~5k+ joueurs uniques.
- Uptime : 99.7 % (1 incident DB, 2h, juillet).
- Satisfaction GIGA : 9/10 ("c'est super, juste un truc menu à changer").
Leçons
Ce qui a marché :
- Partir simple (élimination directe), ajouter les formats complexes après.
- Impliquer GIGA tôt (user feedback à chaque étape).
- PocketBase a réduit la friction ops énormément. Pas besoin de server Node custom.
- Tailwind v4 a fait gagner du temps sur le design.
Ce qui a été galère :
- Format Swiss : l'algo est NP-hard, impossible à faire "parfait". Jai du accepter une solution heuristique bonne à 95%.
- Offline : c'est compliqué. J'ai passé 10h là-dessus et c'est encore fragile sur les gros tournois.
- Export CSV : les clients voulaient exporter pour faire des stats après. Format IESF, c'est complexe. Jai dû supporter 3 formats différents.
Si j'étais à refaire :
- Skip offline au launch. Ça complique trop et ça use une batterie de features.
- Lancer avec 2 formats max (élim directe + Swiss). Ajouter les autres après feedback.
- Passer moins de temps sur le design polish, plus sur les algos.
9. Roadmap & ce qui vient
- v1.1 (août 2026) : import joueurs depuis Yurplan (plateforme locale esports), export stats pour Worldesport.
- v1.2 (septembre) : bracket visuel (affichage du bracket en arbre, pas juste liste).
- v1.3 (octobre) : stream integration (afficher le bracket sur Twitch pendant la compét).
- v2 (2027) : SaaS version (multi-tenant, paiement par tournoi). Ouvrir à d'autres fédos.
10. Conclusion
GigaBracket c'est l'exemple d'un produit niche mais profitable : une fédération a un problème très spécifique. Une plateforme généraliste (challonge, smash.gg) n'était pas assez flexible. Une custom-built solution ? Parfait.
Stack moderne (Next.js, PocketBase, Tailwind) a rendu possible de livrer ça seul en 4 mois (+ 2 mois de maintenance). Coûts : zéro infra (Vercel + Coolify gratuit pour ce volume), temps = mon TJM.
Pour GIGA : plateforme propriétaire, branding custom, algo corrects. Pour moi : portfolio + case study + revenue (ils me paient une maintenance annuelle).
C'est ça un SaaS niche qui marche.
GigaBracket tourne en production. 500+ tournois gérés. Code en privé (propriété GIGA'GAMES). Disponible au public en 2027 si roadmap SaaS se finalise.