DEV Community

Cover image for How We Built a Football league Platform with Next.js and Payload CMS
Shota Sabashvili
Shota Sabashvili

Posted on

How We Built a Football league Platform with Next.js and Payload CMS

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)         │
└─────────────────┘     └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()} 
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

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` 
    })
  })
}
Enter fullscreen mode Exit fullscreen mode

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/image and 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)