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.

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
Next.js application
App Router, built as a standalone container so the runtime carries only what it needs.
Supabase
Auth and Postgres. Your list, your ratings and the subscriptions you declared all live here, keyed to your account.
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.
Tunnel, not a port
Reached through an outbound tunnel. Nothing is opened on the network it lives on.
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.
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


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
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.
Links
Source
Not public
Not public yet.