<?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: Focss</title>
    <description>The latest articles on DEV Community by Focss (@focss).</description>
    <link>https://dev.to/focss</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3752556%2F4475335a-07f0-4195-9d20-2a55bbabbd2e.png</url>
      <title>DEV Community: Focss</title>
      <link>https://dev.to/focss</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/focss"/>
    <language>en</language>
    <item>
      <title>YouTube Playlist to a 100% Static</title>
      <dc:creator>Focss</dc:creator>
      <pubDate>Tue, 18 Aug 2026 06:55:04 +0000</pubDate>
      <link>https://dev.to/focss/youtube-playlist-to-a-100-static-18b6</link>
      <guid>https://dev.to/focss/youtube-playlist-to-a-100-static-18b6</guid>
      <description>&lt;p&gt;Every so often you find a mobile game you enjoy but can't finish. &lt;strong&gt;Food Hunt&lt;/strong&gt; is one of those match-3-style puzzle games where you clear colored tiles by tapping — and the difficulty climbs fast. The content lives on YouTube as a long playlist of level-by-level walkthrough videos, one video per level, each titled &lt;code&gt;Food Hunt level N walkthrough solution&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I wanted to turn that playlist into a proper website: a place where players can &lt;strong&gt;search, filter, and jump straight to the exact level they're stuck on&lt;/strong&gt;, with an embedded video, a difficulty label, and even adjacent-level navigation.&lt;/p&gt;

&lt;p&gt;What I didn't want: a slow CMS, a monthly server bill, or a fragile scraper that breaks every time a video title changes.&lt;/p&gt;

&lt;p&gt;The result is &lt;a href="https://food-hunt.org/" rel="noopener noreferrer"&gt;Food Hunt&lt;/a&gt; — a site serving &lt;strong&gt;585 level pages&lt;/strong&gt;, generated entirely at build time, deployed on Cloudflare's edge, with content refreshed weekly by an automated pipeline. No web server, no runtime database connection, no per-page API calls.&lt;/p&gt;

&lt;p&gt;Here's the full technical breakdown: the data pipeline, the static frontend, and the SEO machinery — including the patterns I'd reuse (or avoid) next time.&lt;/p&gt;




&lt;h2&gt;
  
  
  System architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    YT[YouTube Playlist&amp;lt;br/&amp;gt;Food Hunt level N walkthrough] --&amp;gt; CRAWL[yt-dlp&amp;lt;br/&amp;gt;extract_flat metadata]
    CRAWL --&amp;gt; VIDEO[JSON: video links + level numbers&amp;lt;br/&amp;gt;regex-parsed from titles]
    VIDEO --&amp;gt; THUMB[Thumbnail download&amp;lt;br/&amp;gt;i.ytimg.com, concurrent]
    THUMB --&amp;gt; WEBP[Pillow → WebP]
    WEBP --&amp;gt; R2[Cloudflare R2&amp;lt;br/&amp;gt;img.mobilecasualgames.com]
    CRAWL --&amp;gt; DB[(Neon Postgres&amp;lt;br/&amp;gt;serverless)]
    DB --&amp;gt; SNAP[Build-time snapshot&amp;lt;br/&amp;gt;download-data.mjs → JSON]
    SNAP --&amp;gt; NEXT[Next.js static export&amp;lt;br/&amp;gt;generateStaticParams]
    NEXT --&amp;gt; SEO[sitemap.xml + robots.txt&amp;lt;br/&amp;gt;IndexNow submission]
    SEO --&amp;gt; PAGES[Cloudflare Pages&amp;lt;br/&amp;gt;global edge]
    PAGES --&amp;gt; USR[Visitors]
    R2 --&amp;gt; USR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole thing is a &lt;strong&gt;one-way data flow&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Python pipeline&lt;/strong&gt; (runs weekly) pulls the YouTube playlist, parses it, downloads thumbnails, uploads them to R2, and upserts records into Neon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build step&lt;/strong&gt; (runs on deploy) downloads the database into local JSON snapshots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; statically generates every page from those snapshots and emits sitemaps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Pages&lt;/strong&gt; serves the exported &lt;code&gt;out/&lt;/code&gt; directory from the edge.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let me walk through each layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: The data pipeline (Python)
&lt;/h2&gt;

&lt;p&gt;The source of truth is a YouTube playlist. I used &lt;strong&gt;yt-dlp&lt;/strong&gt; with &lt;code&gt;extract_flat&lt;/code&gt; — which returns playlist metadata &lt;em&gt;without downloading any video&lt;/em&gt;, making it fast and cheap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ydl_opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no_warnings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extract_flat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_playlist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;yt_dlp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;YoutubeDL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ydl_opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ydl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ydl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PLAYLIST_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;download&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Parsing structure from video titles
&lt;/h3&gt;

&lt;p&gt;The playlist is a flat list of videos; the only structure is encoded in the titles (&lt;code&gt;Food Hunt level 1&lt;/code&gt;, &lt;code&gt;Food Hunt level 2&lt;/code&gt;, ...). So the "schema" is a pair of regexes. I classify every entry into three buckets — single level, level range, or unknown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Returns (kind, level_or_None, start_or_None, end_or_None)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RANGE_REGEX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&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="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LEVEL_REGEX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;single&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;unknown&lt;/code&gt; is a first-class bucket.&lt;/strong&gt; Unparseable titles (private videos, renamed videos) don't crash the pipeline — they land in a report you can eyeball.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video IDs are extracted defensively&lt;/strong&gt; from multiple URL formats (&lt;code&gt;?v=&lt;/code&gt;, &lt;code&gt;youtu.be/&lt;/code&gt;, &lt;code&gt;/embed/&lt;/code&gt;), because playlist entries don't always use the same URL shape.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Thumbnails: concurrent download + WebP
&lt;/h3&gt;

&lt;p&gt;Each level page needs an image. YouTube serves thumbnails at &lt;code&gt;i.ytimg.com/vi/&amp;lt;id&amp;gt;/...&lt;/code&gt;, so I download them concurrently with a &lt;code&gt;ThreadPoolExecutor&lt;/code&gt;, then convert everything to &lt;strong&gt;WebP&lt;/strong&gt; with Pillow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;concurrent.futures&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;as_completed&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;futures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch_one&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;fut&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;as_completed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;futures&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# save {index:03d}_{video_id}.png → .webp
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why WebP? It's typically &lt;strong&gt;25–35% smaller&lt;/strong&gt; than PNG for these screenshot-style thumbnails, and the site is configured to only emit WebP anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage: Cloudflare R2 + Neon Postgres
&lt;/h3&gt;

&lt;p&gt;Two storage targets, two very different access patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Images → Cloudflare R2.&lt;/strong&gt; Uploaded with &lt;code&gt;boto3&lt;/code&gt; (R2 speaks the S3 API), and served through the CDN domain &lt;code&gt;img.mobilecasualgames.com&lt;/code&gt;. The upload step is &lt;strong&gt;idempotent&lt;/strong&gt;: it skips objects that already exist, so re-running the pipeline never re-uploads 585 images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Records → Neon.&lt;/strong&gt; A serverless Postgres database accessed with &lt;code&gt;psycopg2&lt;/code&gt;. There are three tables: &lt;code&gt;games&lt;/code&gt; (game metadata), &lt;code&gt;game_levels&lt;/code&gt; (one row per level + video), and &lt;code&gt;game_guides&lt;/code&gt; (auto-generated walkthrough text + FAQ content).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The pattern that saved me: incremental merge
&lt;/h3&gt;

&lt;p&gt;The pipeline's most important rule is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Existing levels are never overwritten. Only missing levels are added.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The script reads the levels already in the database, then upserts &lt;em&gt;only&lt;/em&gt; the ones that don't exist yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_existing_levels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;game_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# from Neon
&lt;/span&gt;&lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lv&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;lv&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;parsed_levels&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert_level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;game_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# INSERT ... ON CONFLICT DO NOTHING
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why does this matter?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Video titles occasionally change or get removed. Re-running the crawl must not silently clobber a previously correct record.&lt;/li&gt;
&lt;li&gt;The YouTube playlist is &lt;em&gt;append-only&lt;/em&gt; in practice — new levels get added at the end. So the diff is almost always &lt;code&gt;new_levels = all_levels - existing&lt;/code&gt;, which is exactly the merge behavior you want.&lt;/li&gt;
&lt;li&gt;It makes the pipeline &lt;strong&gt;safe to run weekly, unattended&lt;/strong&gt;, which is what the automation is built for.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Automation
&lt;/h3&gt;

&lt;p&gt;Everything above runs on a schedule (a weekly cron in my case): crawl → download → integrate → rebuild the site. Because each step is idempotent and produces a JSON report, a failed run can be re-run without any manual cleanup.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: The frontend (Next.js)
&lt;/h2&gt;

&lt;p&gt;The site itself is deliberately boring in the best way. &lt;strong&gt;Next.js 14 (App Router) + TypeScript + Tailwind CSS&lt;/strong&gt;, with one crucial setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;export&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// no Node server — emit pure static HTML&lt;/span&gt;
  &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;unoptimized&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// static export can't use the image optimizer at runtime&lt;/span&gt;
    &lt;span class="na"&gt;formats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Build-time data, not runtime data
&lt;/h3&gt;

&lt;p&gt;There is &lt;strong&gt;no database connection and no API at runtime&lt;/strong&gt;. Instead, a small script (&lt;code&gt;download-data.mjs&lt;/code&gt;, using the &lt;code&gt;postgres&lt;/code&gt; package) pulls the whole dataset from Neon into versioned JSON snapshots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/data/
├── games.json     # game metadata
└── levels.json    # 585 levels: id, name, image, videoId, embedUrl, watchUrl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the data layer (&lt;code&gt;levels.ts&lt;/code&gt;) reads those JSON files directly. This is the key trade-off: &lt;strong&gt;you trade real-time data for zero runtime cost and instant scaling.&lt;/strong&gt; For a walkthrough site that updates weekly, that's the right trade.&lt;/p&gt;

&lt;p&gt;A nice side effect: the data snapshot lives in the repo, so deploys are reproducible and the build fails loudly if the data is malformed.&lt;/p&gt;

&lt;h3&gt;
  
  
  One static page per level
&lt;/h3&gt;

&lt;p&gt;Every level gets its own URL (&lt;code&gt;/levels/1&lt;/code&gt;, &lt;code&gt;/levels/2&lt;/code&gt;, ...) via &lt;code&gt;generateStaticParams&lt;/code&gt;, and its own &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; via &lt;code&gt;generateMetadata&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;levelsData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;levels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Food Hunt &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; Walkthrough`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Watch the full video walkthrough for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;difficulty&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; strategy...`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/levels/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video.other&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;absoluteUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/levels/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;watchUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;720&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;summary_large_image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;output: "export"&lt;/code&gt; produces plain HTML files, every one of the 585 pages is a &lt;strong&gt;crawlable, indexable, fast-loading static document&lt;/strong&gt; — no client-side rendering wall between Googlebot and the content.&lt;/p&gt;

&lt;p&gt;A few component-level details I'm happy with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;YouTubeEmbed&lt;/code&gt;&lt;/strong&gt; — a wrapper around the standard iframe embed, with lazy loading and a consistent 16:9 aspect ratio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;LevelsBrowser&lt;/code&gt;&lt;/strong&gt; — a client component that does search + difficulty filter + pagination entirely in the browser over the preloaded JSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adjacent-level navigation&lt;/strong&gt; — every level page links to the previous/next level, which is both good UX and good internal linking for SEO.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Part 3: SEO &amp;amp; operations
&lt;/h2&gt;

&lt;p&gt;For a content site whose entire purpose is being found on Google for queries like &lt;em&gt;"food hunt level 240 walkthrough"&lt;/em&gt;, SEO isn't an afterthought — it's the product.&lt;/p&gt;

&lt;h3&gt;
  
  
  sitemap.xml: generated, but never destructive
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sitemap.xml&lt;/code&gt; is generated from the data snapshot — but with a merge rule I wish more tools had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Static pages (&lt;code&gt;/&lt;/code&gt;, &lt;code&gt;/levels&lt;/code&gt;, &lt;code&gt;/download&lt;/code&gt;) and level pages (&lt;code&gt;/levels/\d+&lt;/code&gt;) are &lt;strong&gt;regenerated&lt;/strong&gt; from the data.&lt;/li&gt;
&lt;li&gt;Everything else (blog posts, custom landing pages) is &lt;strong&gt;preserved verbatim&lt;/strong&gt; from the existing sitemap.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// generate-seo.mjs (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseExistingSitemap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public/sitemap.xml&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;staticUrls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildStaticUrls&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// from levels.json&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customUrls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isStaticOrLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;staticUrls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;customUrls&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nf"&gt;writeSitemap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means the sitemap script is &lt;em&gt;safe to run on every deploy&lt;/em&gt; — it will never wipe out manually added URLs. That's the same philosophy as the database merge, applied one layer up.&lt;/p&gt;

&lt;h3&gt;
  
  
  robots.txt + IndexNow
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;robots.txt&lt;/code&gt; is generated alongside the sitemap into &lt;code&gt;public/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;After each deploy, an &lt;strong&gt;IndexNow&lt;/strong&gt; submission script pings search engines (Bing, Yandex) with the updated URL list, so new levels get indexed quickly instead of waiting for the next natural crawl.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Deployment: Cloudflare Pages
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;output: "export"&lt;/code&gt;, the &lt;code&gt;out/&lt;/code&gt; directory is a fully static site. Deploying is just a matter of pointing Cloudflare Pages at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wrangler.jsonc&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"foodhunt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"assets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"directory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./out"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"not_found_handling"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"404-page"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get global CDN caching, HTTPS, and zero infrastructure to maintain — the site is just files.&lt;/p&gt;




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

&lt;p&gt;A few honest takeaways from building this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust playlist order.&lt;/strong&gt; I initially assumed playlist order == level order. It isn't always — the regex-parsed level number is the only reliable key. Always parse and validate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency is non-negotiable for automation.&lt;/strong&gt; Every step (download, upload, upsert, sitemap) must be safe to run twice. This single property is what makes unattended weekly runs possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static export changes image strategy.&lt;/strong&gt; With &lt;code&gt;output: "export"&lt;/code&gt; you lose &lt;code&gt;next/image&lt;/code&gt;'s on-demand optimizer, so pre-optimizing to WebP in the pipeline was the right call — do image optimization &lt;em&gt;upstream&lt;/em&gt;, not at runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata is code, treat it that way.&lt;/strong&gt; Per-page &lt;code&gt;generateMetadata&lt;/code&gt; gives you canonical URLs, Open Graph video cards, and Twitter cards for free — it's the highest-leverage SEO work in a Next.js project.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Try it out
&lt;/h2&gt;

&lt;p&gt;If you're stuck on a level of Food Hunt (or just curious to see 585 static pages in the wild), check out &lt;a href="https://food-hunt.org/" rel="noopener noreferrer"&gt;&lt;strong&gt;food-hunt.org&lt;/strong&gt;&lt;/a&gt; — search for your level, watch the walkthrough, and move on.&lt;/p&gt;

&lt;p&gt;The stack in one line: &lt;strong&gt;Python + yt-dlp → Cloudflare R2 + Neon Postgres → Next.js static export → Cloudflare Pages&lt;/strong&gt;, held together by idempotent, incremental, fully automated data flows.&lt;/p&gt;

&lt;p&gt;If you're building something similar — a content site from a semi-structured source — I hope the merge-don't-overwrite pattern and the build-time-snapshot trick save you a few late nights.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>website</category>
    </item>
    <item>
      <title>Finding Pixel Flow levels with a 768-bit RGB pHash visual search</title>
      <dc:creator>Focss</dc:creator>
      <pubDate>Thu, 02 Apr 2026 06:47:36 +0000</pubDate>
      <link>https://dev.to/focss/finding-pixel-flow-levels-with-a-768-bit-rgb-phash-visual-search-2ji8</link>
      <guid>https://dev.to/focss/finding-pixel-flow-levels-with-a-768-bit-rgb-phash-visual-search-2ji8</guid>
      <description>&lt;p&gt;I’m a big fan of the game Pixel Flow, but I often find myself getting completely stuck on specific levels. Because it is a purely visual grid game, you cannot simply search for a solution using text. To solve this, I built a reverse image search engine specifically designed to find level solutions based on a screenshot.&lt;/p&gt;

&lt;p&gt;The challenge was figuring out how to do image matching quickly, accurately, and without heavy infrastructure. Here is how I approached the technical side:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Multi-Channel pHash (Per-Channel RGB)&lt;br&gt;
Most perceptual hashing (pHash) implementations convert an image to grayscale before processing. However, in a game like Pixel Flow, color is the primary piece of information. Discarding color would mean losing the ability to distinguish between levels with similar layouts but different color palettes.&lt;br&gt;
To fix this, I split the image into its Red, Green, and Blue channels and computed a 256-bit pHash for each channel independently.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Combining into a 768-Bit Fingerprint&lt;br&gt;
After generating a 256-bit hash for all 3 channels, I concatenated them. This creates a combined fingerprint of 768 bits (256 * 3). This captures both the structural layout and the color distribution of the game level perfectly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Hexadecimal Compression&lt;br&gt;
Storing and querying a raw 768-bit binary string isn't the most efficient approach for database queries. To reduce the data footprint, I convert the binary string into a hexadecimal string. This brings the size down to just 192 characters while retaining all the precision.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Fast Hamming Distance Matching&lt;br&gt;
When a user uploads a screenshot, the system generates its 768-bit hash on the fly. It then compares this hash against the saved database of known level hashes. By calculating the Hamming distance (the number of positions at which the corresponding bits are different), it can rapidly find the closest match.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Displaying the Top 6 Matches&lt;br&gt;
Finally, the system pulls the 6 records with the smallest Hamming distance and displays them to the user. Showing a small cluster of top matches instead of just one gives the user fallback options in case there are minor discrepancies due to image cropping, phone UI overlays, or screen dimming.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'd love to hear your feedback on this! Are there more efficient ways to handle color-sensitive image hashing for grid-based games?&lt;/p&gt;

&lt;p&gt;Link to the site: &lt;a href="https://pixelflowonline.net/" rel="noopener noreferrer"&gt;https://pixelflowonline.net/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>programming</category>
      <category>showdev</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>From Zero to Ranked: Building a High-Performance NBA Player Randomizer with Next.js 14 &amp; Cloudflare</title>
      <dc:creator>Focss</dc:creator>
      <pubDate>Sun, 15 Feb 2026 02:47:35 +0000</pubDate>
      <link>https://dev.to/focss/from-zero-to-ranked-building-a-high-performance-nba-player-randomizer-with-nextjs-14-cloudflare-5f1i</link>
      <guid>https://dev.to/focss/from-zero-to-ranked-building-a-high-performance-nba-player-randomizer-with-nextjs-14-cloudflare-5f1i</guid>
      <description>&lt;p&gt;As a developer and an NBA fan, I noticed a recurring problem in communities like r/NBA2K: players often get "choice paralysis" when starting a new MyLeague rebuild or a Blacktop 1v1 session. Static lists are boring, and existing randomizers are often cluttered with ads or lack specific filters.&lt;br&gt;
I decided to build a high-performance solution: . Here’s how I leveraged Next.js 14 and Cloudflare to create a tool that is not only fast but also highly optimized for SEO.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Stack: Why Next.js and Cloudflare?
&lt;/h2&gt;

&lt;p&gt;For a tool-based website, performance is the primary SEO signal. I chose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 14 (App Router): For its superior handling of Server Components and built-in Image Optimization.&lt;/li&gt;
&lt;li&gt;Cloudflare Pages: To deploy at the edge, ensuring the generator loads instantly regardless of the user's location.&lt;/li&gt;
&lt;li&gt;Tailwind CSS: For a "dark mode" aesthetic that resonates with the gaming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Data Strategy: JSON vs. CSV
&lt;/h2&gt;

&lt;p&gt;While many data-heavy sites rely on databases, I opted for a JSON-driven architecture.&lt;br&gt;
Performance: Next.js can import JSON files directly, allowing the JS engine to parse data with zero runtime overhead compared to CSV.&lt;br&gt;
Filtering Logic: Storing player data in a structured JSON format allowed for instant client-side filtering by Team, Position, and Country without a single API call.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Core Logic: Ensuring True Randomness
&lt;/h2&gt;

&lt;p&gt;A common complaint with randomizers is "repetitive results." To ensure every player in the NBA database has an equal chance, I implemented the Fisher-Yates Shuffle algorithm.&lt;br&gt;
This algorithm ensures an unbiased permutation of the player array.&lt;br&gt;
When a user applies a filter—like "Center from France"—the tool first creates a filtered subset and then applies the shuffle to provide a fresh result every click.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. SEO for Tools: Beyond the Meta Tag
&lt;/h2&gt;

&lt;p&gt;Tools often suffer from "Thin Content" issues in the eyes of Google. To break into the first page of search results, I focused on:&lt;br&gt;
Topical Authority: Instead of just a button, I added a comprehensive FAQ section explaining the tool's use cases for NBA 2K Challenges and Fantasy Basketball.&lt;br&gt;
Dynamic Metadata: Each page uses Next.js 14’s generateMetadata to ensure that keywords like "Random NBA Team" and "2K26 Randomizer" are indexed correctly.&lt;br&gt;
Internal Linking: I built a "Topic Cluster" by linking the main player generator to specialized tools like the NBA Team Generator.&lt;br&gt;
Check out the live project here: &lt;a href="https://randomnbaplayergenerator.net/" rel="noopener noreferrer"&gt;https://randomnbaplayergenerator.net/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>Random Nba Player Generator - A Free Tool</title>
      <dc:creator>Focss</dc:creator>
      <pubDate>Wed, 04 Feb 2026 10:00:16 +0000</pubDate>
      <link>https://dev.to/focss/random-nba-player-generator-a-free-tool-37g4</link>
      <guid>https://dev.to/focss/random-nba-player-generator-a-free-tool-37g4</guid>
      <description>&lt;h2&gt;
  
  
  NBA Random Player Generator – What It Is
&lt;/h2&gt;

&lt;p&gt;This free online tool instantly generates a NBA random player from both current and all-time rosters. Whether you're settling debates, playing fantasy games, or creating fun challenges, this generator gives you new players with every click.&lt;/p&gt;

&lt;h2&gt;
  
  
  How To Generate Random Nba Player
&lt;/h2&gt;

&lt;p&gt;The Random NBA Player Generator works by leveraging a comprehensive database that includes detailed information on both current and historical NBA players. This database is meticulously compiled and regularly updated to ensure it encompasses a broad spectrum of players from different eras of the NBA. When a user interacts with the Random NBA Player Generator, the tool executes a randomized selection process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is the Random NBA Players Generator free?
&lt;/h3&gt;

&lt;p&gt;Yes, the Random NBA Player Generator is 100% free to use. Our mission is to provide a high-quality, accessible tool for the global basketball community. There are no hidden subscription fees, no "premium" tiers for specific teams, and no mandatory registration or login required. You can generate an unlimited number of players, experiment with all filtering combinations, and use the tool for your personal projects or gaming sessions without ever reaching for your wallet. We believe that the love for the game should be accessible to everyone, and this tool is our contribution to that vision.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it include current NBA players?
&lt;/h3&gt;

&lt;p&gt;Yes, the generator includes active NBA players from recent seasons.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use this tool for games or challenges?
&lt;/h3&gt;

&lt;p&gt;Absolutely. Many users use it for NBA trivia, fantasy challenges, or social media content.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is each result truly random?
&lt;/h3&gt;

&lt;p&gt;Yes. Each click generates a new, random NBA player from the database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I filter players by team or position?
&lt;/h3&gt;

&lt;p&gt;Currently, the generator provides fully random results. Filters such as team, position, or era may be added in future updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why would someone use a random NBA player generator?
&lt;/h3&gt;

&lt;p&gt;People use random NBA player generators to discover new players, make unbiased selections, settle debates, or simply have fun with basketball-related challenges.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to experience it, you can click on the link
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://randomnbaplayergenerator.net/" rel="noopener noreferrer"&gt;https://randomnbaplayergenerator.net/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
  </channel>
</rss>
