<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Footykitsbattle Team</title>
    <description>The latest articles on DEV Community by Footykitsbattle Team (@footykitsbattle).</description>
    <link>https://dev.to/footykitsbattle</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3887293%2Ffa058687-5c2a-4ae9-bbea-ced400686074.png</url>
      <title>DEV Community: Footykitsbattle Team</title>
      <link>https://dev.to/footykitsbattle</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/footykitsbattle"/>
    <language>en</language>
    <item>
      <title>How I built Footy Kits Battle with Next.js, Supabase, and Gemini image-to-image (as a 16-year-old)</title>
      <dc:creator>Footykitsbattle Team</dc:creator>
      <pubDate>Sun, 19 Apr 2026 11:49:14 +0000</pubDate>
      <link>https://dev.to/footykitsbattle/how-i-built-footy-kits-battle-with-nextjs-supabase-and-gemini-image-to-image-as-a-16-year-old-12f</link>
      <guid>https://dev.to/footykitsbattle/how-i-built-footy-kits-battle-with-nextjs-supabase-and-gemini-image-to-image-as-a-16-year-old-12f</guid>
      <description>&lt;p&gt;I spent the last three months building &lt;strong&gt;footykitsbattle.com&lt;/strong&gt; — 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.&lt;/p&gt;

&lt;p&gt;I'm 16. I'm based in the UK. I did this around school.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 14.2.5&lt;/strong&gt; in static-export mode (&lt;code&gt;output: 'export'&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel&lt;/strong&gt; for hosting + CI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; for the vote counter and live leaderboard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini 2.5 Flash image-to-image&lt;/strong&gt; for lifestyle photography generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharp + Pillow&lt;/strong&gt; for WebP conversion&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript&lt;/strong&gt; everywhere&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No CMS. No headless backend. No Wordpress. Just static HTML on a CDN with one Supabase table for live votes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why static export?
&lt;/h2&gt;

&lt;p&gt;Because I wanted three things at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fast pages&lt;/strong&gt; — every route pre-rendered to HTML, shipped from Vercel's edge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cheap infra&lt;/strong&gt; — static export + Vercel free tier = basically zero server cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEO-friendly&lt;/strong&gt; — Google crawls real HTML, not a hydrated SPA shell.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Static export on Next.js 14 is the cleanest it's ever been. If your route is in &lt;code&gt;app/&lt;/code&gt;, it builds to &lt;code&gt;out/&amp;lt;route&amp;gt;/index.html&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The voting problem
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Classic static-site answer: use a third-party service. I picked Supabase because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anon key is safe to ship client-side&lt;/strong&gt; (row-level security does the actual gatekeeping)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realtime subscriptions&lt;/strong&gt; mean the leaderboard can update without polling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres RPC&lt;/strong&gt; for atomic increment + aggregate queries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Supabase table is basically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;global_votes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;kit_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;wins&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;losses&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_voted&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;record_vote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;winner_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loser_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;global_votes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kit_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wins&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;winner_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;conflict&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kit_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;wins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;global_votes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wins&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_voted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;global_votes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kit_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;losses&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loser_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;conflict&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kit_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;losses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;global_votes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;losses&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RLS policy allows anon role to &lt;code&gt;execute&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Client side, it's a &lt;code&gt;supabase.rpc('record_vote', { winner_id, loser_id })&lt;/code&gt; call. That's it.&lt;/p&gt;

&lt;p&gt;The leaderboard page then does a &lt;code&gt;supabase.from('global_votes').select('*').order('wins', { ascending: false })&lt;/code&gt; at &lt;strong&gt;build time&lt;/strong&gt;. Which is the part that took me a while to figure out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build-time leaderboard trick
&lt;/h2&gt;

&lt;p&gt;Here's the thing: if the leaderboard fetches at render time client-side, Google sees an empty &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;. Bad for SEO.&lt;/p&gt;

&lt;p&gt;So I wrote a pre-build script — &lt;code&gt;scripts/fetch-kit-clash-snapshot.mjs&lt;/code&gt; — that runs as part of &lt;code&gt;npm run build&lt;/code&gt;. It queries Supabase, writes the results to a TypeScript file (&lt;code&gt;src/data/kit-clash-snapshot.ts&lt;/code&gt;), and commits that file into the bundle. The page reads from the static file, not from live Supabase.&lt;/p&gt;

&lt;p&gt;Result: the page is fully-rendered HTML with the current leaderboard baked in. Users get instant content; Google crawls a fully-populated table.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gemini image-to-image for 88 lifestyle photos
&lt;/h2&gt;

&lt;p&gt;The site has 88 lifestyle photos spread across 38 nations. I made zero of them with a camera.&lt;/p&gt;

&lt;p&gt;The pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take one real product shot of each kit&lt;/li&gt;
&lt;li&gt;Feed it to Gemini 2.5 Flash image-to-image with a simple prompt&lt;/li&gt;
&lt;li&gt;Get three variations per kit&lt;/li&gt;
&lt;li&gt;Pick the best one, run through Pillow for size/quality normalisation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cost: about $0.01 per image at Gemini's rates. 88 images = under $1.&lt;/p&gt;

&lt;p&gt;Two things that tripped me up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemini occasionally reshapes the kit slightly.&lt;/strong&gt; The crest might look wrong, or the sponsor logo mutates. So I always cross-check against the source shot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt simplification helps.&lt;/strong&gt; Complex prompts produced weirder results than simple ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  WebP conversion saved 59 MB
&lt;/h2&gt;

&lt;p&gt;172 kit images. JPG at q85. Big. Ran them all through &lt;code&gt;sharp&lt;/code&gt; to produce WebP siblings at q80. Then every &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; became a &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; with &lt;code&gt;&amp;lt;source type="image/webp"&amp;gt;&lt;/code&gt; + JPG fallback.&lt;/p&gt;

&lt;p&gt;Result: 59 MB saved across the full image payload. Mobile LCP dropped from ~3.1s to ~1.9s on Android simulation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stuff that surprised me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Static export scales embarrassingly well — 697 pages, zero runtime cost, sub-100ms TTFB from the edge.&lt;/li&gt;
&lt;li&gt;Schema.org markup matters more than I expected — Google SERP is already showing rich results on two of the leaderboard entries.&lt;/li&gt;
&lt;li&gt;The hardest part isn't code — it's content.&lt;/li&gt;
&lt;li&gt;AI-generated lifestyle photography is genuinely useful, but you have to QA every output.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ship the blog first.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invest in image rights earlier.&lt;/strong&gt; Adidas's CDN blocks automated scraping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a /press page from day one.&lt;/strong&gt; Journalists look for one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it goes next
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If any of this was useful, the site is at &lt;a href="https://footykitsbattle.com/" rel="noopener noreferrer"&gt;footykitsbattle.com&lt;/a&gt; and the leaderboard is at &lt;a href="https://footykitsbattle.com/kit-clash-highlights/" rel="noopener noreferrer"&gt;footykitsbattle.com/kit-clash-highlights&lt;/a&gt;. Happy to answer stack questions in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Jake runs &lt;a href="https://footykitsbattle.com/" rel="noopener noreferrer"&gt;Footy Kits Battle&lt;/a&gt;, a UK-based editorial site covering World Cup 2026 kits through fan voting.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>supabase</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
