04Aug. 2026Sole developerShipped
MyMDB
Your watch history, not everyone else's.
A private watch log for films and television built on Next.js 16 and Supabase. Search against TMDB, log viewings with ratings and reviews, and build a library, diary and watchlist that stay yours. Postgres row level security enforces isolation in the database rather than in application code.
Demo account
- demo@mymdb.app
- Password
- watchlog-demo-2026
- Next.js 16
- TypeScript
- Supabase
- PostgreSQL
- Tailwind CSS v4
- TMDB API
The problem
I wanted a record of what I had watched. Not recommendations, not a feed, not what my friends thought. A log.
Letterboxd does this well and is built around a social layer I did not want. Most alternatives are catalogues with a "watched" checkbox bolted on, where the app is really a browsing interface for someone else's database and your history is an afterthought stored against it.
The distinction I kept coming back to is whose data the product is. In a catalogue app, the catalogue is the product and your marks on it are metadata. I wanted the inverse. My viewing history is the thing; the film metadata is plumbing that makes it readable.
That sounds like a philosophical point and it turned out to be an architectural one.
The constraints
Self-imposed, mostly, which meant I had to be honest about them rather than pointing at someone else.
No paid services. TMDB has a free tier with a licence permitting this use, and Supabase and Vercel both have free tiers that cover a personal app. That ruled out any design requiring sustained infrastructure spend.
TMDB's terms require attribution and prohibit presenting their data as your own. That is a licence condition and not a design preference, which meant the attribution had to be in the product rather than in a credits page nobody opens.
I also wanted this to be genuinely multi-user rather than a single-user app with a login screen. Not because I expected users, but because "private data, multiple accounts" is a problem worth solving properly once.
And I gave myself a rule before starting: the metadata provider should be replaceable. If TMDB changed its terms, I wanted that to be a contained problem.
What I built
A Next.js 16 application on the App Router with TypeScript in strict mode, backed by Supabase.
You search a title live against TMDB and open its detail page. You log a viewing with a half-star rating, a date, an optional review and a rewatch flag. Over time that builds three views: a library of everything watched, watching or dropped with sorting and filtering in grid or list layout; a diary of viewings grouped by month, newest first; and a watchlist with a runtime filter and a random picker for when you cannot decide.
The home dashboard is built entirely from your own data. A stat strip, continue watching, a watchlist preview, recently logged. No trending row, no discovery feed, nothing pulled from TMDB that is not about something you have already touched.
It shipped in six stages, all complete.
What this is and is not
It is genuinely multi-user. Public signup works and every account's data is isolated by row level security in Postgres, not by application logic remembering to filter.
It is not social and will not become one. No public profiles, no follows, no feed. That is a decision recorded in the project's architecture document rather than a feature that has not been built yet.
It is not a streaming availability tracker. It will not tell you where to watch something.
TV is tracked at title level only. There are no episode checkboxes in v1, though the schema
carries unused season_number and episode_number columns so episode tracking can ship
without a migration.
And there is no CSV import, which is the honest barrier to anyone else using it. Someone with years of Letterboxd history cannot bring it, so the app is only useful to a person starting from zero. I know this and deferred it anyway, because it is the highest-effort item in the backlog and it needs careful title matching to be worth having at all.
The technical decisions
| Decision | Reasoning |
|---|---|
| TMDB is server-side only, always | Every call happens in a Server Component, Server Action or Route Handler. The token is never prefixed NEXT_PUBLIC_. A credential in the browser is a credential you have published |
All TMDB access flows through lib/tmdb/ |
One module is the only public surface. This is the seam for replacing the provider, and a seam only exists if nothing bypasses it |
| TMDB shapes never leave the adapter | mappers.ts converts raw responses into my own domain types first. Application code does not know what poster_path means, which is what makes the seam above worth anything. Without this rule, TMDB's schema leaks into every component and the adapter is decorative |
Ratings stored as smallint 1 to 10 |
Half-stars are a presentation concern. Storing 4.5 as a float invites equality and comparison bugs that surface later in a sort order nobody can explain. Conversion happens only at the render boundary |
Composite key (tmdb_id, media_type) everywhere |
Films and television are numbered in separate sequences, so the same integer can identify one of each. Treating the id alone as unique is a data corruption bug waiting for the right collision, and it was written down as a rule before anything was built rather than discovered afterwards |
| Cache on demand, not a catalogue sync | The first time a title is touched it is upserted into a local media table. List pages then join locally and make zero API calls. A library page with 60 posters would otherwise be 60 requests per render |
Writes to media use the service-role client |
media is a cache shared by all users, so authenticated users get read-only access and upserts run server-side with admin credentials |
| Two tables for the log, not one | user_media holds the durable relationship, one row per user per title. watch_entries holds each viewing. A rewatch is a new row, not an edit, which is the only way a diary can be truthful |
RLS on every user-owned table, scoped to auth.uid() |
Isolation belongs in the database. If it lives in my where clauses, then one forgotten clause is a data breach rather than a bug |
The hardest thing
Moving the front door after the house was finished.
Through the whole build, / was the authenticated dashboard. You logged in and landed
there. That is the obvious layout for an app whose entire purpose is showing you your own
data, and it was the assumption baked into stages two through six: the middleware, the
post-login redirect, the nav shell, the route group everything lived in.
Then v1 was complete and the app had no public face at all. Every route required auth, so there was nothing to link to, nothing indexable, and no way to show anyone what it was without handing them an account. An app nobody can see is a hard thing to share.
The fix sounds like a small change and is not. / became a public marketing landing page
in its own (marketing) route group with its own header, and the authenticated dashboard
moved to /home. That meant the middleware needed an exemption for / rather than a
blanket auth check, plus a rule sending a logged-in visitor on / onward to /home, and
another sending a logged-out visitor on a gated route to /login?next=, and another
sending an already-authenticated user away from /login and /signup. Four redirect rules
that have to agree with each other, where getting any one wrong produces either a loop or a
leak.
What made it manageable was that route groups meant the marketing chrome and the app shell could have genuinely separate layouts rather than one layout with conditionals in it. The part I would not repeat is leaving the question until the end. "Does this need a public page" is a five minute conversation at the start and a routing refactor at the finish, and nothing about the answer required the app to be built first.
The same thing happened smaller with naming. The grid was called "Films" until stage five, when it became "Library", because it holds anything with a status other than watchlist and half of that is television. The route changed with it. Both changes were late, both were obvious in hindsight, and both were cheap to decide and expensive to move.
What I would do differently
I would add a refresh path for cached metadata from the start. Right now a title upserted on first touch keeps whatever TMDB returned that day, forever. Posters get replaced, runtimes get corrected, and nothing here notices. A staleness timestamp and a periodic refresh is not difficult; it is just not there, and it will become visible to a user before it becomes visible to me.
I would settle earlier what a title's headline rating means when watch_entries holds
several viewings of it at different ratings. Latest, first or mean is a product decision,
and a schema makes it for you silently if you do not make it yourself.
And I would build CSV import earlier. It is the difference between an app one person uses and an app someone else can adopt, and every month it stays undone is a month the answer to "can I switch to this" stays no.
What came of it
v1 is complete and I use it. All six stages shipped: scaffold and schema, auth, search and detail, logging, library and diary, watchlist and dashboard.
The thing I actually took from it is the discipline of writing decisions down while making them. The project keeps a document of settled decisions, deferred features and non-negotiable rules, with the reasoning attached. Six months later that document is the reason I can explain why ratings are integers rather than trying to reconstruct it, and it is the format I now start projects with.