Back to Projects

Bayflix

Bayflix started as a TMDB-powered movie browser and grew into a three-service streaming platform — a Cloudflare Worker running semantic search and recommendations on Vectorize, multi-profile watchlists backed by D1, and a custom HLS player — all running on free-tier infrastructure.

Next.jsTypeScriptFirebaseCloudflare Workers
Bayflix

From Movie Search App to Bayflix

This project started as a weekend TMDB browser called Movie Search App — a portfolio piece for practicing API integration and Firebase auth. It's since been rewritten from the ground up and rebranded as Bayflix: a Netflix-style browsing experience with real authentication, AI-powered semantic search, multi-profile watchlists, and a custom video player, spread across three independently deployed services. One thing worth saying up front, because the app says it too: Bayflix doesn't host or distribute licensed film content. The in-app player streams a single demo asset over HLS — every title plays the same file, and the movie ID only drives navigation and metadata. Everything else — the search, the recommendations, the auth, the data model — is real, production-shaped engineering built on free-tier infrastructure.

Stack

  • Next.js 16 (App Router), React 19, TypeScript — deployed on Vercel
  • Cloudflare Workers, D1, and Vectorize, plus Workers AI (bge-base-en-v1.5) for embeddings
  • Firebase Authentication — email/password, Google, and magic-link sign-in
  • TMDB for catalog metadata, reverse-proxied server-side so the API key never reaches the browser
  • OMDb for IMDb / Rotten Tomatoes / Metacritic ratings, cached in D1
  • Tailwind CSS v4, Framer Motion, and Lenis for UI and motion
  • hls.js for playback, served from a dedicated Cloudflare Worker backed by R2

Three Services, Not One App

Bayflix isn't a single deployment. It's three: a Next.js app on Vercel that renders the UI, handles Firebase auth client-side, and reverse-proxies every TMDB request through its own API route so the TMDB key never reaches the browser; a Cloudflare Worker called bayflix-api that owns everything stateful — profiles, watchlists, ratings, semantic search, and recommendations — backed by D1 and Vectorize; and a separate r2-video-worker that only serves HLS manifests and segments out of R2. The video worker is split out on purpose. Video serving and app-data serving have nothing to do with each other, and keeping them as separate deployable units means a bad deploy to one can't take down the other. The browser talks to bayflix-api directly, not through the Next.js app, using the user's Firebase ID token as a bearer token.

Verifying a Firebase Token Without firebase-admin

The Worker can't use firebase-admin — it's a Node-only package, and Workers don't run Node. So token verification is hand-rolled: fetch Google's public JWKS, cache it in memory for an hour, import the matching key with the Web Crypto API, and verify the RS256 signature with crypto.subtle.verify. Then check the expiry, issued-at, audience, issuer, and subject claims by hand. Any failure throws, and every caller treats a throw as an automatic 401 — there's no fallback path that trusts the token anyway. It's more code than pulling in an SDK would have been, but it forced an actual understanding of what a Firebase ID token verification step is supposed to check, rather than trusting a library to check it correctly.

Multiple Profiles, One Account

Bayflix supports up to five profiles per account — a 'Who's Watching' picker, same as the app it's inspired by. Under the hood, every watchlist, watched, and rating row is keyed by profile_id, not just the account's user ID, and the active profile is passed as an X-Profile-Id header on every request. Older clients that don't send one still work: the Worker falls back to treating the account's own ID as an implicit default profile, so adding multi-profile support didn't require breaking anything that shipped before it.

The Player Is Real. The Catalog Isn't.

The video player is a genuine custom build on hls.js, not an embedded iframe — full-screen, its own controls, its own bug history. One fix worth mentioning: an early version wrapped the video in a gradient overlay for the UI chrome, and that overlay was silently swallowing clicks meant for the video element underneath. The fix was making the overlay pointer-events-none so clicks pass through to the actual video tag. What the player doesn't do is stream a real catalog. Every title plays the exact same demo HLS asset — the movie or show ID is used for navigation and metadata, never to pick a different file on the backend. That's a deliberate, disclosed limitation, not a bug: licensing a real streaming catalog isn't in scope for a portfolio project, but building a real player, with real playback engineering, is.

Bugs That Taught Me Something

  • YouTube's JS Player API breaks under rapid mount/unmount — hovering quickly over multiple movie cards to trigger trailer previews threw "Failed to execute postMessage on DOMWindow" and left previews stuck. The fix was switching hover previews to a plain declarative iframe, which has no persistent JS lifecycle to break, while keeping the real Player API only for the Hero banner and detail-page trailers that mount once.
  • Hover-expanded movie cards were getting visually cropped by the next card in the row, because two elements at the same z-index resolve stacking order by DOM position. Bumping the whole card's z-index while its preview is expanded let its own popup escape the sibling's stacking context.
  • A jose version bump broke firebase-admin's auth module in production on Vercel. The fix was pinning jose to v4 via an npm override — a small, easy-to-miss dependency incident that only showed up after deploying.

Built to Degrade, Not Break

OMDb's free tier caps out at 1,000 requests a day, so ratings get cached in D1, keyed by title rather than by user. Complete rows — IMDb, Rotten Tomatoes, and Metacritic scores all present — cache for 30 days. Rows missing RT or Metacritic, which is common right after a title's release, get a shorter 3-day TTL instead of being stuck looking incomplete for a month. Titles OMDb can't find get cached too, so an unmatched title isn't re-queried on every request. The same philosophy runs through the client. The entire personalization layer — watchlists, AI search, recommendations — is wrapped so that if the Bayflix API Worker isn't configured or unreachable, the UI just hides those features instead of throwing errors. With three independently deployed services, any one of them being temporarily down shouldn't take the other two with it.

Discussion

Share your feedback or ask a question. You can also @mention someone.