What EpisodeTracker is actually built from
A full tour of the stack — the API, the database, the services, and the words for all of it
Yes — this is a full-stack app, not a frontend
A lot of small web projects are just a frontend calling somebody else's API. This one is not.
There is a Fastify server written in TypeScript that you control, sitting in front of your own PostgreSQL database, on your own DigitalOcean droplet. Every account, tracked show, watched episode and notification lives in tables you designed.
The only things you do not own are the TV data (TVmaze), the model that suggests shows (Groq, then Gemini), and mail delivery (Brevo). All three are wrapped in your own code, so the rest of the app never touches them directly.
| JSON API endpoints | 33 |
| Database tables | 8 |
| Screens in the app | 14 |
| Lines of your code | ~12,500 |
| Commits | 153 |
That line count excludes dependencies and build output: 4,588 lines of API, 7,196 of web, 496 of shared types, 199 of database schema.
The stack, layer by layer
Read this top to bottom and you have the whole thing. Nothing else is in play.
| Layer | Technology | What it does here |
|---|---|---|
| Language | TypeScript 5.7 | Both sides. One language front to back, with types shared between them. |
| UI | React 19 | Every screen. No UI kit, no Tailwind, no component library — the CSS is hand-written. |
| Routing (web) | react-router-dom 7 | URLs like /watchlist render without a page reload. |
| Build tool | Vite 6 | Dev server on :5173 and the production bundle in apps/web/dist. |
| Installable app | vite-plugin-pwa | Generates the manifest and service worker, so the site installs to a phone home screen. |
| Server | Fastify 5 | The backend. Handles every /api/* request and also serves the built React app. |
| Database access | Prisma 6 | Turns prisma/schema.prisma into SQL migrations plus a typed query client. |
| Database | PostgreSQL 16 | A Docker container locally; installed directly on the droplet in production. |
| Auth | Node crypto.scrypt |
No auth library. Passwords hashed by hand, sessions stored as database rows. |
| Scheduling | node-cron | Runs the daily "airing today" job inside the server process. |
| Push | web-push | Sends browser notifications signed with VAPID keys. |
| Brevo HTTP API | Plus nodemailer and Resend as alternate paths, chosen by which env vars are set. | |
| Tests | Node --test |
Built into Node — no Jest, no Vitest. Seven test files over the fiddliest logic. |
| Edge | Cloudflare + Nginx | DNS, TLS and proxying in front of the app. |
This is a deliberately small dependency list. The API has 10 runtime dependencies and the web app has 3. Fewer libraries means fewer things that break on upgrade, and it is the reason a project this size still typechecks and builds in under a minute.
What happens when someone opens the site
This is a genuine sequence — each hop hands off to the next. Knowing it is how you debug anything.
- Cloudflare — DNS, TLS termination, proxy. It forwards to the droplet and injects
its analytics beacon, which is why the app's Content-Security-Policy explicitly
allows
static.cloudflareinsights.com. - Nginx, port 443 on the droplet — reverse-proxies everything to
127.0.0.1:3001. The app binds to loopback only, so nothing on the internet can reach the Node process directly; it has to come through Nginx. - Fastify,
apps/api/src/index.ts— one single Node process. If the path starts with/apiit is the JSON API; otherwise it serves files fromapps/web/dist, and for a known app route like/historyit hands backindex.htmlso React takes over. - Session lookup — before any route runs,
loadCurrentUserreads thesidcookie, finds the matchingSessionrow and attaches the user to the request. Protected routes then simply check that it is there. - Route handler, e.g.
shows/routes.ts— the actual logic: reading tracked shows, computing how many episodes behind you are, calling TVmaze if the cache is stale. - Prisma to Postgres — Prisma turns the typed query into SQL. Postgres runs on the same droplet and listens on localhost, so the database is not exposed to the network at all.
- JSON back to React,
apps/web/src/api/client.ts— one fetch wrapper handles every call. It always sendscredentials: "include"so the session cookie travels, and throwsApiRequestErroron any non-2xx so screens can show a real message.
The design decision behind this. Web and API share one origin — one process serves both. That is why every API route lives under
/api(so it can never collide with a page URL like/settings), and why the session cookie can staysameSite: laxinstead of needing looser cross-site settings.
Three workspaces in one repository
It is an npm-workspaces monorepo: one npm install at the root wires all three
together.
| Path | Package | Contents |
|---|---|---|
apps/web/ |
@episode-tracker/web |
React app. 14 screens under src/pages/, shared parts in src/components/, one fetch wrapper in src/api/client.ts. Biggest file: ShowDetailPage.tsx, 639 lines. |
apps/api/ |
@episode-tracker/api |
Fastify server, split by feature: auth/, shows/, push/, public/, admin/, plus tvmaze/, ai/, email/, cron/. Biggest file: shows/routes.ts, 907 lines. |
packages/shared/ |
@episode-tracker/shared |
37 TypeScript types both sides import, so the API and the UI cannot disagree about a shape. Also holds the two pure watch-history functions. |
prisma/ |
— | The database: schema.prisma plus 6 migrations, the SQL history of every table change since June. |
The rule that keeps both sides honest
Because both apps import the same types, changing an API response shape without
updating the UI is a compile error, not a runtime surprise. Two conventions make that
work: dates are always ISO string in shared types (JSON has no date type), and the
shared User type deliberately omits passwordHash, so it is impossible to
accidentally send a hash to the browser.
All 33 API routes
Everything is mounted under /api. Anything marked session needs the sid
cookie — that is 27 of the 33.
Accounts and sessions
| Method | Path | Access | What it does |
|---|---|---|---|
POST |
/auth/signup |
public | Create an account, hash the password, start a session. |
POST |
/auth/login |
public | Verify the password, set the httpOnly sid cookie. |
POST |
/auth/logout |
public | Delete the session row and clear the cookie. |
POST |
/auth/change-password |
session | Checks the old password first, then revokes every other session. |
GET |
/auth/me |
session | Who am I? What the UI calls on load to know if you are signed in. |
PATCH |
/auth/preferences |
session | Username, public-profile toggle, email and push switches. |
Finding shows
| Method | Path | Access | What it does |
|---|---|---|---|
GET |
/shows/search |
session | Search TVmaze by name. |
GET |
/shows/schedule |
session | Everything airing on a given date, ranked by popularity — the Discover screen. |
GET |
/shows/trending |
session | What is popular right now. |
GET |
/shows/suggestions |
session | AI picks from your library. Never fails — falls back to popular shows. |
POST |
/shows/suggestions/dismiss |
session | "Not interested" — excluded from the prompt and every future response. |
GET |
/shows/:id |
session | Full detail for one show, including its episode list. |
GET |
/shows/:id/similar |
session | Related shows. |
Your library
| Method | Path | Access | What it does |
|---|---|---|---|
POST |
/shows/track |
session | Cache the show from TVmaze, then record that you track it. |
DELETE |
/shows/tracked/:id |
session | Untrack. |
GET |
/shows/tracked |
session | The dashboard. Excludes watchlisted shows. |
GET |
/shows/watching |
session | Started but not finished. |
GET |
/shows/watchlist |
session | Saved for later, not started. |
GET |
/shows/completed |
session | Finished, with your rating. |
GET |
/shows/history |
session | Everything marked watched, newest first. |
PUT |
/shows/tracked/:id/watchlist |
session | Move to or off the watchlist. |
PUT |
/shows/tracked/:id/watched |
session | Set the "latest episode watched" marker. |
PUT |
/shows/tracked/:id/episode |
session | Mark one specific episode watched or unwatched. |
PUT |
/shows/tracked/:id/season |
session | Mark a whole season at once. |
PUT |
/shows/tracked/:id/completed |
session | Finish the show and rate it. |
PUT |
/shows/tracked/:id/notify |
session | Per-show notification opt-out. |
POST |
/shows/tracked/email |
session | Email yourself your tracked-shows table on demand. |
Push, public and operations
| Method | Path | Access | What it does |
|---|---|---|---|
GET |
/push/vapid-public-key |
public | The browser needs this key before it can subscribe. |
POST |
/push/subscribe |
session | Register this device. Keyed on endpoint, so re-subscribing updates in place. |
POST |
/push/unsubscribe |
session | Remove this device. |
GET |
/public/profile/:username |
public | The shareable profile at /u/<username>, only if the user turned it on. |
GET |
/health |
public | Returns {status:"ok"}. Touches no database — the right target for an uptime monitor. |
POST |
/admin/run-cron |
admin token | Manually fire the daily notification job. Needs the x-admin-token header, and is disabled entirely if ADMIN_TOKEN is unset. |
Eight tables, and what each one is for
Defined in prisma/schema.prisma. Every table except ShowCache uses a cuid string as
its primary key.
| Table | Scope | Purpose |
|---|---|---|
User |
one per account | Email, password hash, optional public username, and the two master notification switches — email on by default, push off. |
Session |
many per user | A login. The row id is what sits in the sid cookie. Logging out deletes the row, which is what makes sessions genuinely revocable. |
TrackedShow |
per user + show | The core join. Watchlist state, completion date, your rating, the per-show notify flag, and a denormalised "latest watched" marker for fast dashboard rendering. |
WatchedEpisode |
per user + episode | One row per episode watched. The real source of truth — it is what allows out-of-order watching, accurate behind-counts and watch-time stats. |
ShowCache |
global | One row per show, shared by everyone. Caches next-episode info, genres, runtime and the entire episode list as JSON, so the API computes your progress without calling TVmaze again. |
NotificationLog |
per user + episode | The dedupe guard. A unique key on (user, episode, kind, channel) is what makes the daily job safe to run twice without spamming anyone. |
PushSubscription |
many per user | One per browser or device. Auto-pruned when the push service reports it expired. Never sent back to the client. |
DismissedSuggestion |
per user + show | The "not interested" list, kept out of the AI prompt and filtered from every response. |
The one structural quirk to remember
TrackedShow and ShowCache have no database foreign key between them, on
purpose — you can track a show before it has been cached. The dashboard joins them in
application code (loadTrackedShows()), not in SQL. If a tracked show ever renders
with missing artwork or a blank next episode, that join is where to look.
The four services it depends on
Each one is isolated behind a single file, so the rest of the app never sees their raw responses.
TVmaze — all the TV data
Free, no API key. Wrapped in apps/api/src/tvmaze/client.ts, which exposes six
functions and maps everything into the app's own types. Show details are fetched with
?embed[]=nextepisode&embed[]=episodes so one call gets both.
Groq, then Gemini — the suggestions engine
Both are OpenAI-compatible chat endpoints, so ai/client.ts is one generic caller with
an ordered provider chain: try Groq, retry a couple of times on rate limits, then fall
through to Gemini. Two things make this safe. Every AI pick is resolved against
TVmaze by name, so hallucinated shows drop out. And the whole path never throws —
if AI is down you get popular-airing-today picks, and the response says
source: "popular" so the UI can be honest about it.
Brevo — email delivery
An HTTPS API rather than SMTP, and that is the point: most VPS providers block outbound
mail ports, and this one does. The mailer is pluggable — getEmailer() picks the first
configured option in order (Brevo, then Gmail SMTP, then Resend, then console), so
local development can just print emails to the terminal.
Cloudflare — DNS, TLS, proxy
Sits in front of the droplet, and is the reason the CSP has that one allowance for the analytics beacon it injects at the edge.
Cost. TVmaze, Groq and Gemini are free tiers. Brevo is free to roughly 300 emails a day. Cloudflare is the free plan. The only thing being paid for is the droplet.
Running it, testing it, shipping it
Local development
npm install # installs all three workspaces
npm run db:up # Postgres in Docker (Docker Desktop must be running)
npm run dev # API on :3001 + web on :5173, together
npm test # 7 test files, Node's built-in runner
npm run db:studio # browse the database in a GUI
npm run db:migrate -- --name add_thing
Deploying
Deployment is a manual command, never an automatic push. Work happens on develop;
when a change is confirmed it is fast-forwarded to main, and then deploy-oyware is
run from the Mac. Pushing to main on its own changes nothing on the live site. The
deploy script backs up the database before it touches anything, then runs
prisma migrate deploy on the droplet.
What runs on its own
One thing: the daily notification job, scheduled by node-cron inside the running
server at 0 12 * * * in Asia/Amman. It refreshes the show caches, finds episodes
airing today, and emails or pushes each tracker once per episode.
Because it lives in the process, a deploy restart across that exact minute can skip a day — which is the one gap the (currently disabled) GitHub Action was kept around to cover.
What GitHub checks
On every push to develop or main, CI installs, generates the Prisma client, runs
the tests, typechecks all three workspaces and builds the web app. No secrets and no
database needed — they are all offline checks.
Terms you will hit in your own codebase
Coming from mobile development, most of this maps onto something already familiar.
| Term | What it means here |
|---|---|
| Fastify | The web server framework. Roughly what Ktor is on the JVM: build an app object, register routes, listen on a port. |
| Prisma | An ORM. You describe tables in one schema file; it generates both the SQL migrations and a fully typed query client, so a typo in a column name fails at compile time. |
| migration | A numbered SQL file recording one change to the database structure. Applied in order, they rebuild the schema from scratch on any machine. There are 6. |
| cuid | The random string ID format used for primary keys here, instead of auto-incrementing numbers. Safe to generate anywhere, and it does not leak how many rows exist. |
| preHandler | A Fastify hook that runs before a route handler. This one resolves the session cookie into a user, so every route can just read request.currentUser. |
| scrypt | A deliberately slow, memory-hungry hashing function. The slowness is the feature — it makes brute-forcing a stolen password hash expensive. |
| httpOnly cookie | A cookie JavaScript cannot read, which is why a stolen session cannot be lifted out of the page by injected script. |
| CORS | The browser rule about which origins may call your API. In production it is not needed at all — web and API share one origin — so it is not even registered. |
| CSP | Content-Security-Policy. A header listing exactly what the page may load. This one is built at boot from the shipped HTML, so it cannot go stale. |
| SPA | Single-page app. The server sends one HTML shell and React swaps screens without full page loads. It is why the server keeps an explicit list of app routes to serve that shell for. |
| PWA | The bundle of manifest, service worker and icons that makes a website installable to a home screen, like a lightweight app install. |
| service worker | A background script in the browser. This one does two jobs: caching the app shell for instant repeat loads, and receiving push notifications when the tab is closed. |
| VAPID | The keypair that proves push messages genuinely came from your server. The public half is handed to the browser; the private half stays in the droplet's .env. |
| ESM | Modern JavaScript modules. The quirk here: imports are written with a .js extension even though the file on disk is .ts. |
| monorepo | Several packages in one repository, installed together. This one has three, linked by npm workspaces. |
| reverse proxy | Nginx taking public requests and forwarding them to the app on localhost. It holds the certificates and shields the Node process from direct traffic. |
One thing that has drifted
The repository's CLAUDE.md still says "No Web Push yet (email is still the
notification path)". That is out of date. Web Push shipped: there is a
PushSubscription table, a 20260620130000_web_push migration, three push endpoints,
a push-sw.js service worker, and per-channel dedupe in NotificationLog.
Worth correcting, because that file is what any future session reads first.