Technical Documentation
How StreamLog is built β architecture, data model, API, and operations. StreamLog is a free shared movie, TV, and anime watchlist; this page documents the system for developers and contributors.
1. Overview & tech stack
StreamLog is a server-rendered Next.js application. Pages render on the server (mostly static or short-revalidate), while the authenticated app talks to a REST API under /api backed by PostgreSQL through Prisma. Expensive third-party media lookups are cached in memory and in a database table, and every mutating endpoint sits behind a distributed rate limiter.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) Β· React 19 Β· TypeScript 5 |
| Styling | Tailwind CSS v4 Β· shadcn/ui primitives Β· @base-ui/react Β· Phosphor icons |
| Database | PostgreSQL via Prisma 7 (@prisma/adapter-pg) |
| Auth | Firebase Authentication (email/password) + HMAC-signed session cookies |
| Rate limiting | Upstash Redis sliding window (@upstash/ratelimit), fail-open |
| Media data | TMDB Β· OMDb Β· AniList Β· Jikan |
| Testing | Vitest (unit) |
| Hosting | Vercel Β· Vercel Analytics |
2. Getting started
Prerequisites: Node.js 20+, a PostgreSQL database, a Firebase project with email/password auth enabled, and TMDB/OMDb API keys.
# install dependencies
pnpm install
# apply database migrations
pnpm db:migrate
# start the dev server
pnpm dev
# other scripts
pnpm lint # eslint
pnpm test # vitest run
pnpm build # prisma generate && next build
pnpm db:studio # prisma studioEnvironment variables
| Variable | Required | Purpose |
|---|---|---|
| DATABASE_URL | Yes | PostgreSQL connection string used by Prisma. |
| SESSION_SECRET | Production | HMAC-SHA256 key for signing session cookies. AUTH_SECRET is accepted as a fallback; unset in production throws at runtime. |
| TMDB_API_KEY | Yes | Primary TMDB v3 API key for search, details, discover, and watch providers. |
| TMDB_API_KEY_2 | Optional | Second TMDB key used as automatic failover when the first is missing or rate-limited. |
| TMDB_REGION | Optional | Region code for TMDB watch-provider lookups. |
| OMDB_API_KEY | Optional | OMDb key for IMDb ratings and the movie/TV search fallback path. |
| NEXT_PUBLIC_FIREBASE_API_KEY | Yes | Firebase Web SDK config (all six NEXT_PUBLIC_FIREBASE_* values come from the Firebase console). |
| NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN | Yes | Firebase auth domain. |
| NEXT_PUBLIC_FIREBASE_PROJECT_ID | Yes | Firebase project id. |
| NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET | Yes | Firebase storage bucket. |
| NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID | Yes | Firebase sender id. |
| NEXT_PUBLIC_FIREBASE_APP_ID | Yes | Firebase app id. |
3. Project structure
src/
ββ app/ # App Router pages & API routes
β ββ (app)/ # Authenticated shell (dashboard, lists)
β ββ api/ # REST endpoints (see Β§6)
β ββ about|blog|docs|terms|privacy/ # Public static pages
β ββ layout.tsx # Root layout, fonts, theme init script
β ββ sitemap.ts # Generated sitemap
β ββ robots.ts
ββ components/
β ββ ui/ # Design-system primitives (button, dialogβ¦)
β ββ *.tsx # Feature components (board, dialogs, feedβ¦)
ββ lib/
β ββ access.ts # Membership/auth guards for routes
β ββ api.ts # Typed client-side fetch wrapper
β ββ cache.ts # memoWithDb: memory + MediaCache two-tier cache
β ββ discovery.ts # Discover layout & genre configuration
β ββ http.ts # JSON helpers, error envelope, zod parsing
β ββ lists.ts # List queries & cache invalidation
β ββ media-details.ts # Upstream detail merging (per-source)
β ββ rate-limit.ts # Upstash sliding-window limiter (fail-open)
β ββ search-order.ts # Cross-source result preference logic
β ββ session.ts # getCurrentUser/requireUser + profile caches
β ββ theme.ts # Client theme store (system/light/dark)
β ββ title-dedupe.ts # Title identity keys
β ββ firebase/ # Client SDK + server token verification
ββ generated/prisma/ # Generated Prisma client (do not edit)4. Data model
The Prisma schema lives at prisma/schema.prisma. All child rows cascade on delete, so removing a list or user cleans up everything underneath it.
User
Account record. tokenVersion is bumped on logout so every previously issued cookie invalidates immediately β server-side revocation without a session table.
List
A watchlist. type is PERSONAL or SHARED; ownerId names the creator. Max 8 lists per account (enforced in routes).
ListMember
Membership join with role OWNER | MEMBER and a per-member pinnedAt for pinning. Unique on (listId, userId); shared lists cap at 3 members.
Invite
Single-use invite tokens, optionally locked to one email. Expires after 7 days; revokedAt supports manual cancellation.
Title
A tracked title in a list. Identity is (listId, source, externalId) β enforced by a unique index and mirrored by titleIdentity() in lib. status uses the WatchStatus enum; position orders rows.
WatchLink
Streaming links per title. auto=true links are fetched from TMDB (max 2); users can add up to 3 manual links. Unique on (titleId, label, url).
TitleProgress
Per-user season/episode progress for series and anime, plus totals cached from details and a rewatch flag. Unique on (userId, titleId).
Rating
Per-user 1β10 rating per title. Shared lists display the average of member ratings. Unique on (userId, titleId).
Note
Per-user free-text note per title (max 2,000 characters). Unique on (userId, titleId).
MediaCache
Durable cache for slow upstream responses. Written best-effort, never blocking the request path; indexed on expiresAt.
5. Authentication & sessions
- 1. The client authenticates against Firebase with email/password and receives an ID token.
- 2.
POST /api/auth/sessionverifies the token server-side (RSA-SHA256 against Google's public keys), upserts the user row, and sets an HTTP-only cookie containing the user id and their currenttokenVersion, signed with HMAC-SHA256 usingSESSION_SECRET. - 3. Every request resolves the user via
getCurrentUser(): verify the HMAC, then confirm the cookie's tokenVersion still matches the database. - 4. Signing out bumps
tokenVersion, instantly invalidating every cookie ever issued for that account β no session table required.
To keep request handling cheap, profiles are cached in-memory for 60 seconds and token versions for 5 seconds, so a logout propagates across serverless instances within seconds. Password resets go through Firebase; changing passwords re-authenticates before updating.
6. API reference
All endpoints are JSON-in/JSON-out under /api, require the session cookie unless noted, and return errors in a common envelope ({ error: string }). The Group column is the rate-limit bucket (Β§8).
| Route | Methods | Group | Purpose |
|---|---|---|---|
| /api/auth/session | POST Β· DELETE | session | Exchange a Firebase ID token for an HTTP-only session cookie; delete it to sign out (bumps tokenVersion). |
| /api/me | GET Β· PATCH | me | Read and update the caller's profile (name, image URL). |
| /api/search | GET | search | Unified title search across TMDB/OMDb (movies, TV) and AniList/Jikan (anime). |
| /api/media/details | GET | details | Rich metadata for one title: rating, release date, seasons/episodes, runtime, genres, overview, cast, trailer key. |
| /api/discover | GET | discover | Genre-driven discovery feed backed by TMDB trending/discover endpoints. |
| /api/lists | GET Β· POST | lists | List the caller's lists (with posters and counts) or create a new personal/shared list. |
| /api/lists/[id] | GET Β· PATCH Β· DELETE | lists | Fetch one list board, rename it, or delete it (owner only). |
| /api/lists/[id]/titles | POST | titles | Add a title (deduped by source+externalId) or a custom LINK card. |
| /api/lists/[id]/bulk | POST | titles | Add multiple titles in one call. |
| /api/lists/[id]/duplicate | POST | lists | Copy a list into a new personal list owned by the caller; titles/statuses/positions carry over, per-member state does not. |
| /api/lists/[id]/pin | PATCH | lists | Toggle the caller's per-member pin. |
| /api/lists/[id]/invites | POST | invites | Create a single-use invite link (owner only), optionally restricted to an email. |
| /api/lists/[id]/invites/[inviteId] | DELETE | invites | Revoke a pending invite. |
| /api/lists/[id]/members/[userId] | PATCH Β· DELETE | lists | Transfer ownership or remove a member (owner only). |
| /api/titles/[id] | PATCH Β· DELETE | titles | Change status / move metadata, or remove the title from its list. |
| /api/titles/[id]/rating | POST | titles | Set or clear the caller's 1β10 rating. |
| /api/titles/[id]/note | POST | titles | Save or clear the caller's note. |
| /api/titles/[id]/progress | PATCH | titles | Save season/episode progress for series and anime. |
| /api/titles/[id]/watch-links | POST Β· DELETE | titles | Add or remove manual watch links (max 3 per title). |
7. Upstream APIs & caching
Search
Movies and TV prefer TMDB results and fall back to OMDb when TMDB returns nothing (pickSearchResults in lib/search-order.ts). Anime queries AniList first, then Jikan. A title's identity is always the pair (source, externalId), so the same movie added twice resolves to one row.
Details
getMediaDetails merges per-source payloads into one shape: TMDB supplies genres/runtime/trailer and optionally an IMDb rating via OMDb cross-lookup; AniList supplies anime scores and episode counts. Upstream failures are retried with exponential backoff and then remembered for 5 minutes to avoid hammering a struggling provider.
Two-tier cache
memoWithDb checks an in-process Map first, then the durable MediaCache table (which survives cold starts and is shared across instances), then hits the provider. Writes are best-effort and never block the response. Detail payloads live for 24 hours; watch-provider links attach automatically (up to 2) when a title is added.
8. Rate limiting
Every API request passes through a distributed sliding-window limiter backed by Upstash Redis, keyed by user id (or IP pre-auth). Limits are global across all serverless instances. The limiter is fail-open: if Redis is unreachable the request proceeds, because availability outranks hardening.
| Group | Limit | Covers |
|---|---|---|
| session | 10 / min | Login attempts (brute-force guard) |
| search | 30 / min | /api/search |
| details | 40 / min | /api/media/details |
| discover | 60 / min | /api/discover |
| lists | 30 / min | List CRUD, duplicate, pin, members |
| invites | 10 / min | Invite create/revoke |
| titles | 60 / min | Title add/update/remove, rating, note, progress, links |
| me | 30 / min | Profile read/update |
When a client exceeds a limit, the API responds 429 with a Retry-After header; the UI surfaces the friendly "You're moving too fast" dialog.
9. Theming
The UI ships light and dark palettes as CSS variables (:root and .dark) consumed by Tailwind v4 semantic tokens. Users pick System default, Light, or Dark from the avatar menu; the choice persists in localStorage["streamlog-theme"].
- An inline script in the root layout applies the resolved theme class to
<html>before first paint, preventing flashes. lib/theme.tsexposes auseSyncExternalStore-based store; OS scheme changes are followed live in system mode and other tabs sync via the storage event.- Surfaces use semantic tokens (
bg-surface,border-border,ring-border) rather than hardcoded colors; scrims over poster imagery intentionally stay dark in both themes.
10. Testing
Unit tests run with Vitest (pnpm test). Coverage focuses on pure decision logic that is easy to get subtly wrong:
search-orderβ cross-source result preference and dedupe identityrate-limitβ pathname-to-bucket mapping and rule coveragediscoveryβ genre layout invariantsblogβ article integrity and canonical metadatahttp,cache,watch-providersβ envelopes, TTL behavior, provider filtering
11. SEO
app/sitemap.tsemits every public page; blog slugs are derived fromBLOG_ARTICLESso new posts are included automatically.app/robots.tsallows all crawlers and points at the sitemap.- Auth-gated routes (
/login,/app,/lists,/join) emit noindex metadata and stay out of the sitemap. - Marketing pages ship canonical URLs, Open Graph and Twitter cards, and JSON-LD structured data.
12. Deployment
- 1. Provision PostgreSQL (Neon, Supabase, RDSβ¦) and set
DATABASE_URLin the hosting dashboard. - 2. Configure every environment variable from Β§2 β secrets stay out of the repo.
- 3. Apply migrations with
pnpm db:migrate(Prisma migrate deploy) before shipping a release that changes the schema. - 4. Deploy to Vercel;
pnpm buildruns Prisma generate automatically. Add the Upstash Redis REST credentials to enable rate limiting (it fails open without them). - 5. Verify
/sitemap.xmland/robots.txtafter the first deploy and submit the sitemap in Google Search Console.