Skip to content

WhatToWatch

A trailer feed you scroll through one film at a time, filtered down to what your own subscriptions can actually play. Running on my own server, with a public URL.

production2026 – presentNext.js · TypeScript · Tailwind CSS · Supabase · TMDB
The trailer feed, one film at a time
The trailer feed, one film at a time

No film catalogue: nothing about a film is ever storedAvailability filtered across 12 streaming servicesTaste learned from three signals: genre, director, castNext.js on my own hardware, behind a tunnelZero inbound ports on the network it runs on

The problem

Deciding what to watch is a solved problem for anyone willing to accept whatever a streaming platform is currently promoting. It is unsolved for everyone else.

Two things make it worse: a synopsis tells you almost nothing about whether you will enjoy a film, and half of what you find turns out to be on a service you do not pay for.

What it does

Trailers play one at a time in a vertical feed. You scroll to the next one, save a film to your list, or mark it as seen and liked or not, which feeds back into what you get shown next.

You tell it which subscriptions you have and it stops offering films you cannot play. The filter only counts what is included in the subscription, not what a service would happily rent you for four euros.

It is also the project that proved the hosting path this whole site now uses: build, container, reverse proxy, tunnel, public URL.

Hosting
My own server, exposed through a tunnel
Auth
Supabase
Film data
TMDB, called server-side only
Availability
Subscription streaming in France, 12 services
Stored
Your list and your ratings. Never a film.

Architecture

  • Visitorany browser

    Cloudflare Tunnel

  • Cloudflare Tunneloutbound only

    WhatToWatch

  • WhatToWatchNext.js, standalone container

    Supabase, TMDB

  • Supabaseauth, Postgres, your list
  • TMDBfilms, trailers, availability
visitorthird partymy hardwarezero inbound ports
01

Next.js application

App Router, built as a standalone container so the runtime carries only what it needs.

02

Supabase

Auth and Postgres. Your list, your ratings and the subscriptions you declared all live here, keyed to your account.

03

TMDB, server-side

Films, trailers and streaming availability all come from one API, called only from the server. Availability is JustWatch data, which is why the attribution sits on the page that uses it.

04

Tunnel, not a port

Reached through an outbound tunnel. Nothing is opened on the network it lives on.

05

Taste profile

Ratings become weights per genre, director and actor, and the weights reorder the feed. It runs on the server, on the twenty films of the next page, and never leaves the request.

Built with

Client

Next.js App Router
Server components for the feed, so the API keys stay server side
TypeScript
Shared types between the feed, the list and the availability filter
Tailwind CSS
The full screen feed, with no UI library and no animation library

Data

Supabase Postgres
Accounts, saved films, ratings and declared subscriptions, one row-level policy per table
TMDB
Films, trailers and who streams what, fetched per request and never persisted

Infrastructure

Docker, standalone build
One image carrying only what the runtime needs
Reverse proxy
TLS and routing for the internal path
Outbound tunnel
Public access without opening anything inbound

Under the hood

01The recommender is a weighted count, and that is the point

A like adds one point to every genre, the director and the top three actors of that film. A dislike removes one. Ranking a film is then a sum, with the director counting double because a director match is rarer than a genre match and says more. Collaborative filtering would be better with a hundred thousand users and is worthless with the number I have, so the choice was between a model I could not feed and forty lines I can read. Being able to explain any single position in the feed has been worth more than any accuracy I gave up.

src/lib/tasteProfile.ts
export function scoreMovie(
  movie: { genreIds: number[]; director: string | null; cast: string[] },
  profile: TasteProfile,
): number {
  const genreScore = scoreGenres(movie.genreIds, profile)
  const directorScore = movie.director ? (profile.directorWeights.get(movie.director) ?? 0) * 2 : 0
  const castScore = movie.cast.reduce((sum, name) => sum + (profile.castWeights.get(name) ?? 0), 0)
  return genreScore + directorScore + castScore
}

The whole ranking function. There is no second one.

02There is no film catalogue, and it costs something

Films are fetched from TMDB per request and thrown away. Supabase holds accounts, saved films, ratings and declared subscriptions, and nothing else. That removes an entire class of work: no import job, no staleness, no availability data going quietly wrong in a table. The bill is that ranking can only see films that already made it into the request, so choosing the candidates matters more than scoring them. Sixty per cent of each page goes to the best genre scores and forty per cent stays random, because a feed that only shows you what you already liked stops being a discovery feed within a week.

03Public variables are baked at build time

Anything prefixed for the browser is compiled into the bundle, not read at boot. Changing one means rebuilding the image. Obvious in hindsight, and a genuinely confusing afternoon the first time a config change appeared to do nothing.

04The tunnel must reach the app, not the proxy

Pointing the tunnel at the reverse proxy produces an infinite redirect loop: the proxy upgrades HTTP to HTTPS, the tunnel arrives over HTTP, and they argue forever. The tunnel goes straight to the container; public TLS is the tunnel's job.

Gallery

Pick your subscriptions, see only what you can watch
Pick your subscriptions, see only what you can watch
Onboarding: scroll, save, rate
Onboarding: scroll, save, rate

Try it

It is running. Open it.

No sign-up wall to get past before seeing anything, and no demo account to hand out. The instance you would open is the one I use.

What broke

Would change

I shipped the gesture twice before admitting it was wrong

The spec had a horizontal swipe to rate and an Instagram style double tap to save, both decided before anything existed. In a feed that already owns vertical scroll, a horizontal drag fights the scroll and a double tap is indistinguishable from a mis-tap. The first attempt at a fix added buttons on desktop only, which left the app with two interaction models at once; the second removed the gestures entirely. An invisible gesture can be a shortcut for something you can also click, never the only way to do it.

Next projectSolana DeFi Trading Platform