I spent the last three months building footykitsbattle.com — a World Cup 2026 kit voting site that's now sitting at ~700 static pages, 42 team pages, 25 three-way compare pages, 86 blog posts, and a live Supabase-backed leaderboard showing which kits fans are voting for.
I'm 16. I'm based in the UK. I did this around school.
This post is the actually-useful version of a "how I built it" article — real stack choices, real trade-offs, the parts that broke, and the parts that scaled surprisingly well on zero budget.
The stack
-
Next.js 14.2.5 in static-export mode (
output: 'export') - Vercel for hosting + CI
- Supabase for the vote counter and live leaderboard
- Gemini 2.5 Flash image-to-image for lifestyle photography generation
- Sharp + Pillow for WebP conversion
- TypeScript everywhere
- Tailwind CSS
No CMS. No headless backend. No Wordpress. Just static HTML on a CDN with one Supabase table for live votes.
Why static export?
Because I wanted three things at once:
- Fast pages — every route pre-rendered to HTML, shipped from Vercel's edge.
- Cheap infra — static export + Vercel free tier = basically zero server cost.
- SEO-friendly — Google crawls real HTML, not a hydrated SPA shell.
Static export on Next.js 14 is the cleanest it's ever been. If your route is in app/, it builds to out/<route>/index.html. The router, the metadata API, the image component — all of it works. The only friction: no runtime API routes. Which turned out to be fine.
The voting problem
Static export says "no API routes". But I wanted global head-to-head kit voting: user clicks a kit, vote gets recorded, leaderboard updates across all sessions.
Classic static-site answer: use a third-party service. I picked Supabase because:
- Anon key is safe to ship client-side (row-level security does the actual gatekeeping)
- Realtime subscriptions mean the leaderboard can update without polling
- Postgres RPC for atomic increment + aggregate queries
The Supabase table is basically:
create table global_votes (
kit_id text primary key,
wins integer default 0,
losses integer default 0,
last_voted timestamptz default now()
);
create or replace function record_vote(winner_id text, loser_id text)
returns void
language sql
as $$
insert into global_votes (kit_id, wins)
values (winner_id, 1)
on conflict (kit_id) do update set wins = global_votes.wins + 1, last_voted = now();
insert into global_votes (kit_id, losses)
values (loser_id, 1)
on conflict (kit_id) do update set losses = global_votes.losses + 1;
$$;
RLS policy allows anon role to execute the function but not to read or write the table directly. Anon users can only vote, not read the raw table or manipulate other rows.
Client side, it's a supabase.rpc('record_vote', { winner_id, loser_id }) call. That's it.
The leaderboard page then does a supabase.from('global_votes').select('*').order('wins', { ascending: false }) at build time. Which is the part that took me a while to figure out.
The build-time leaderboard trick
Here's the thing: if the leaderboard fetches at render time client-side, Google sees an empty <div>. Bad for SEO.
So I wrote a pre-build script — scripts/fetch-kit-clash-snapshot.mjs — that runs as part of npm run build. It queries Supabase, writes the results to a TypeScript file (src/data/kit-clash-snapshot.ts), and commits that file into the bundle. The page reads from the static file, not from live Supabase.
Result: the page is fully-rendered HTML with the current leaderboard baked in. Users get instant content; Google crawls a fully-populated table.
The trade-off: leaderboard is as fresh as my last deploy. I redeploy nightly via Vercel cron, so it's never more than 24 hours stale. For a tournament build-up site, that's fine.
Gemini image-to-image for 88 lifestyle photos
The site has 88 lifestyle photos spread across 38 nations. I made zero of them with a camera.
The pipeline:
- Take one real product shot of each kit
- Feed it to Gemini 2.5 Flash image-to-image with a simple prompt
- Get three variations per kit
- Pick the best one, run through Pillow for size/quality normalisation
Cost: about $0.01 per image at Gemini's rates. 88 images = under $1.
Two things that tripped me up:
- Gemini occasionally reshapes the kit slightly. The crest might look wrong, or the sponsor logo mutates. So I always cross-check against the source shot.
- Prompt simplification helps. Complex prompts produced weirder results than simple ones.
WebP conversion saved 59 MB
172 kit images. JPG at q85. Big. Ran them all through sharp to produce WebP siblings at q80. Then every <img> became a <picture> with <source type="image/webp"> + JPG fallback.
Result: 59 MB saved across the full image payload. Mobile LCP dropped from ~3.1s to ~1.9s on Android simulation.
The stuff that surprised me
- Static export scales embarrassingly well — 697 pages, zero runtime cost, sub-100ms TTFB from the edge.
- Schema.org markup matters more than I expected — Google SERP is already showing rich results on two of the leaderboard entries.
- The hardest part isn't code — it's content.
- AI-generated lifestyle photography is genuinely useful, but you have to QA every output.
What I'd do differently
- Ship the blog first. The blog posts are what Google actually indexes and ranks. If I did it again, I'd launch with 20 strong blog posts before the shiny tools.
- Invest in image rights earlier. Adidas's CDN blocks automated scraping.
- Build a /press page from day one. Journalists look for one.
Where it goes next
More kit-of-the-day posts. A newsletter. Better comparative data. And when the tournament kicks off in June, live match coverage that weaves kit voting results into the match stories.
If any of this was useful, the site is at footykitsbattle.com and the leaderboard is at footykitsbattle.com/kit-clash-highlights. Happy to answer stack questions in the comments.
Jake runs Footy Kits Battle, a UK-based editorial site covering World Cup 2026 kits through fan voting.
Top comments (0)