DEV Community

Focss
Focss

Posted on

YouTube Playlist to a 100% Static

Every so often you find a mobile game you enjoy but can't finish. Food Hunt 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 Food Hunt level N walkthrough solution.

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

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

The result is Food Hunt — a site serving 585 level pages, 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.

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.


System architecture

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

The whole thing is a one-way data flow:

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

Let me walk through each layer.


Part 1: The data pipeline (Python)

The source of truth is a YouTube playlist. I used yt-dlp with extract_flat — which returns playlist metadata without downloading any video, making it fast and cheap:

ydl_opts = {"quiet": True, "no_warnings": True, "extract_flat": "in_playlist"}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
    data = ydl.extract_info(config.PLAYLIST_URL, download=False)
Enter fullscreen mode Exit fullscreen mode

Parsing structure from video titles

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

def parse_entry(title):
    """Returns (kind, level_or_None, start_or_None, end_or_None)"""
    t = title or ""
    m = re.search(config.RANGE_REGEX, t, re.IGNORECASE)
    if m:
        return "range", None, int(m.group(1)), int(m.group(2))
    m = re.search(config.LEVEL_REGEX, t, re.IGNORECASE)
    if m:
        return "single", int(m.group(1)), None, None
    return "unknown", None, None, None
Enter fullscreen mode Exit fullscreen mode

Two details worth copying:

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

Thumbnails: concurrent download + WebP

Each level page needs an image. YouTube serves thumbnails at i.ytimg.com/vi/<id>/..., so I download them concurrently with a ThreadPoolExecutor, then convert everything to WebP with Pillow:

from concurrent.futures import ThreadPoolExecutor, as_completed

with ThreadPoolExecutor(max_workers=8) as pool:
    futures = {pool.submit(fetch_one, v): v for v in videos}
    for fut in as_completed(futures):
        # save {index:03d}_{video_id}.png → .webp
Enter fullscreen mode Exit fullscreen mode

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

Storage: Cloudflare R2 + Neon Postgres

Two storage targets, two very different access patterns:

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

The pattern that saved me: incremental merge

The pipeline's most important rule is:

Existing levels are never overwritten. Only missing levels are added.

The script reads the levels already in the database, then upserts only the ones that don't exist yet:

existing = sn.read_existing_levels(game_name)   # from Neon
missing = [lv for lv in parsed_levels if lv["level"] not in existing]

for level in missing:
    sn.upsert_level(game_name, level)           # INSERT ... ON CONFLICT DO NOTHING
Enter fullscreen mode Exit fullscreen mode

Why does this matter?

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

Automation

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.


Part 2: The frontend (Next.js)

The site itself is deliberately boring in the best way. Next.js 14 (App Router) + TypeScript + Tailwind CSS, with one crucial setting:

// next.config.mjs
const nextConfig = {
  output: "export",      // no Node server — emit pure static HTML
  images: {
    unoptimized: true,   // static export can't use the image optimizer at runtime
    formats: ["image/webp"],
  },
};
Enter fullscreen mode Exit fullscreen mode

Build-time data, not runtime data

There is no database connection and no API at runtime. Instead, a small script (download-data.mjs, using the postgres package) pulls the whole dataset from Neon into versioned JSON snapshots:

src/data/
├── games.json     # game metadata
└── levels.json    # 585 levels: id, name, image, videoId, embedUrl, watchUrl
Enter fullscreen mode Exit fullscreen mode

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

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.

One static page per level

Every level gets its own URL (/levels/1, /levels/2, ...) via generateStaticParams, and its own <head> via generateMetadata:

export function generateStaticParams() {
  return levelsData.levels.map((l) => ({ id: String(l.id) }));
}

export async function generateMetadata({ params }) {
  const level = await getLevel(Number((await params).id));
  return {
    title: `Food Hunt ${level.name} Walkthrough`,
    description: `Watch the full video walkthrough for ${level.name}. ${level.difficulty} strategy...`,
    alternates: { canonical: `/levels/${level.id}` },
    openGraph: {
      type: "video.other",
      url: absoluteUrl(`/levels/${level.id}`),
      videos: [{ url: level.watchUrl, type: "text/html", width: 1280, height: 720 }],
    },
    twitter: { card: "summary_large_image", images: [level.image] },
  };
}
Enter fullscreen mode Exit fullscreen mode

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

A few component-level details I'm happy with:

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

Part 3: SEO & operations

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

sitemap.xml: generated, but never destructive

sitemap.xml is generated from the data snapshot — but with a merge rule I wish more tools had:

  • Static pages (/, /levels, /download) and level pages (/levels/\d+) are regenerated from the data.
  • Everything else (blog posts, custom landing pages) is preserved verbatim from the existing sitemap.
// generate-seo.mjs (simplified)
const existing = parseExistingSitemap("public/sitemap.xml");
const staticUrls = buildStaticUrls();          // from levels.json
const customUrls = existing.filter(u => !isStaticOrLevel(u));
const merged = [...staticUrls, ...customUrls];
writeSitemap(merged);
Enter fullscreen mode Exit fullscreen mode

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

robots.txt + IndexNow

  • robots.txt is generated alongside the sitemap into public/.
  • After each deploy, an IndexNow 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.

Deployment: Cloudflare Pages

With output: "export", the out/ directory is a fully static site. Deploying is just a matter of pointing Cloudflare Pages at it:

// wrangler.jsonc
{
  "name": "foodhunt",
  "assets": { "directory": "./out", "not_found_handling": "404-page" }
}
Enter fullscreen mode Exit fullscreen mode

You get global CDN caching, HTTPS, and zero infrastructure to maintain — the site is just files.


What I'd do differently next time

A few honest takeaways from building this:

  1. Don't trust playlist order. 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.
  2. Idempotency is non-negotiable for automation. Every step (download, upload, upsert, sitemap) must be safe to run twice. This single property is what makes unattended weekly runs possible.
  3. Static export changes image strategy. With output: "export" you lose next/image's on-demand optimizer, so pre-optimizing to WebP in the pipeline was the right call — do image optimization upstream, not at runtime.
  4. Metadata is code, treat it that way. Per-page generateMetadata 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.

Try it out

If you're stuck on a level of Food Hunt (or just curious to see 585 static pages in the wild), check out food-hunt.org — search for your level, watch the walkthrough, and move on.

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

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.

Top comments (0)