Every developer knows the feeling — a client walks in with what sounds like a simple request: "We need a website for school sports leagues." But as you dig deeper, you realize it's anything but simple. Registration systems, tournament brackets, live standings, multi-sport support, and a CMS that non-technical staff can actually use.
This is the story of how we built Schooleague — a digital platform for school sports competitions in Georgia — using Next.js 14, TypeScript, Node.js, PostgreSQL, and Payload CMS.
The Challenge
Our client needed a platform where:
- Schools could register for tournaments across multiple sports
- Administrators could manage brackets, schedules, and results
- Students and parents could track standings in real-time
- Content editors could update news and announcements without developer help
The previous solution was a combination of spreadsheets and social media posts. Not exactly scalable.
Why We Chose This Stack
Next.js 14 (App Router)
We went with Next.js for several reasons:
- Server Components reduced our client-side JavaScript by roughly 40%
- ISR (Incremental Static Regeneration) let us serve tournament pages as static HTML while keeping data fresh
- Built-in image optimization was critical — tournament photos and team logos needed to load fast on school Wi-Fi networks
- SEO out of the box — schools needed to find their tournaments through Google
Payload CMS
We evaluated several headless CMS options (Strapi, Sanity, Contentful) and landed on Payload CMS because:
- It runs on our own server — no per-seat pricing surprises
- TypeScript-first, which matched our entire stack
- The admin panel is clean enough for non-technical staff
- Custom fields and hooks let us build complex tournament logic directly in the CMS
PostgreSQL
Tournament data is inherently relational — teams belong to schools, schools register for tournaments, tournaments have rounds, rounds have matches. PostgreSQL was the natural choice.
Architecture Overview
┌─────────────────┐ ┌──────────────────┐
│ Next.js App │────▶│ Payload CMS │
│ (App Router) │ │ (API + Admin) │
└─────────────────┘ └──────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ Static Pages │ │ PostgreSQL │
│ (ISR Cache) │ │ (Tournament │
│ │ │ Data) │
└─────────────────┘ └──────────────────┘
Key Implementation Details
1. Tournament Bracket System
The trickiest part was generating and displaying tournament brackets dynamically. We built a recursive component that renders single-elimination, double-elimination, and round-robin formats:
type Match = {
id: string
round: number
position: number
teamA: Team | null
teamB: Team | null
scoreA?: number
scoreB?: number
winner?: Team
nextMatchId?: string
}
function generateBracket(teams: Team[], format: TournamentFormat): Match[] {
// Seed teams based on ranking
const seeded = seedTeams(teams)
// Generate match tree
const rounds = Math.ceil(Math.log2(seeded.length))
const matches: Match[] = []
for (let round = 1; round <= rounds; round++) {
const matchesInRound = Math.pow(2, rounds - round)
for (let pos = 0; pos < matchesInRound; pos++) {
matches.push({
id: `r${round}-m${pos}`,
round,
position: pos,
teamA: round === 1 ? seeded[pos * 2] : null,
teamB: round === 1 ? seeded[pos * 2 + 1] : null,
nextMatchId: round < rounds ? `r${round + 1}-m${Math.floor(pos / 2)}` : undefined
})
}
}
return matches
}
2. Real-Time Standings with ISR
Instead of WebSockets (which would have been overkill), we used ISR with a short revalidation window:
// app/tournaments/[id]/standings/page.tsx
export const revalidate = 30 // Revalidate every 30 seconds
async function StandingsPage({ params }: { params: { id: string } }) {
const standings = await getStandings(params.id)
return (
<StandingsTable
data={standings}
lastUpdated={new Date().toISOString()}
/>
)
}
For admin score updates, we trigger on-demand revalidation through Payload hooks:
// Payload afterChange hook
const revalidateStandings: AfterChangeHook = async ({ doc }) => {
await fetch(`${SITE_URL}/api/revalidate`, {
method: 'POST',
headers: { 'x-secret': REVALIDATION_SECRET },
body: JSON.stringify({
path: `/tournaments/${doc.tournamentId}/standings`
})
})
}
3. Multi-Language Support
Georgian schools needed the platform in Georgian (obviously), but international schools wanted English. We used next-intl with a clean URL structure:
-
schooleague.ge/— Georgian (default) -
schooleague.ge/en/— English
4. Performance Results
After launch, here's what we measured:
| Metric | Score |
|---|---|
| Lighthouse Performance (Mobile) | 92 |
| Lighthouse SEO | 100 |
| First Contentful Paint | 0.8s |
| Largest Contentful Paint | 1.2s |
| Time to Interactive | 1.5s |
The key optimizations that got us there:
- Server Components for everything that doesn't need interactivity
- Dynamic imports for heavy components (bracket visualizer, charts)
- Proper image sizing with
next/imageand WebP - Font optimization with
next/font
Lessons Learned
1. ISR > SSR for sports data. Real-time isn't always necessary. A 30-second delay in standings is perfectly acceptable, and ISR is dramatically simpler than WebSocket infrastructure.
2. Payload CMS hooks are powerful. We handled score validation, automatic bracket advancement, and cache revalidation all through Payload hooks — no separate microservice needed.
3. Start with mobile. 80% of our users check standings on their phones during games. Mobile-first wasn't just a best practice — it was the actual use case.
4. TypeScript end-to-end pays off. Having types from the database schema through the API to the React components caught bugs before they reached production. The initial setup time was worth it.
Results
Within the first 3 months of launch:
- 50+ schools registered on the platform
- 200+ tournaments created
- 15,000+ page views per month during peak season
- Admin time for tournament management dropped from hours to minutes
We're Wevosoft, a web development agency based in Tbilisi, Georgia. We build modern web applications with Next.js, React, and Node.js. If you're looking for a development partner, let's talk.
Top comments (0)