Case study
Streaming App
A self-hosted streaming interface built with Next.js, featuring persistent metadata caching, IMDb ratings, and configurable playback providers.

Technology
- Next.js
- TypeScript
- React
- Node.js
- Tailwind
- SqLite
- Role
- Sole developer
- Year
- 2026
- Status
- In active use
Context
A streaming experience I could control
I was tired of repeatedly searching for third-party streaming sites that would disappear, become overloaded with ads, or offer an inconsistent experience. Rather than replacing one temporary site with another, I wanted a personal interface that would remain familiar regardless of where the actual playback came from.
I built Streaming App as a self-hosted application for discovering and watching movies and TV shows. TMDB provides the metadata, artwork, and discovery features, while configurable external providers handle playback.
The first version already offered a polished browsing experience, but its architecture was relatively simple: a React frontend, a separate Express API, and one streaming provider configured through an environment variable. As I continued using it, I started running into the limitations of that setup. What began as a UI project gradually became an exercise in making the underlying system more flexible and reliable.
Architecture
Rebuilding around a single Next.js application
The original application used React and Vite for the frontend, with a separate Express backend responsible for communicating with TMDB and resolving playback URLs. This worked, but maintaining two application processes added unnecessary complexity to a project intended to run on my own hardware.
I rewrote the application using the Next.js App Router, bringing the frontend and API into one TypeScript project. Node.js Route Handlers now handle metadata requests, input validation, response normalization, and playback URL resolution, while the existing React components continue to provide the browsing experience.
The rewrite also introduced SQLite as a persistent data layer. Instead of treating every metadata request as a fresh call to TMDB, the server can reuse cached responses, store provider configurations, and maintain a local IMDb ratings dataset.
The result is a more cohesive architecture with one application process, a shared server-side data layer, and fewer moving parts to maintain.
- 01
Request
The React interface requests movie, TV, or playback data through a Next.js API route.
- 02
Validate
The route validates parameters and passes metadata requests to the shared server-side TMDB client.
- 03
Resolve
SQLite supplies cached metadata when available, while cache misses are fetched from TMDB. Ratings are enriched separately.
- 04
Respond
The API returns normalized data to the interface, or resolves a configured provider URL when playback is requested.
Performance
Making metadata requests cheaper and more resilient
A media interface depends on a surprising amount of external data. The homepage loads multiple discovery lists, title pages combine details with artwork and recommendations, and TV shows introduce additional requests for seasons and episodes.
Initially, these requests depended directly on TMDB. Repeated navigation could fetch information that had barely changed, introducing unnecessary latency and putting more pressure on the external API.
I introduced a persistent SQLite cache beneath the shared TMDB client. Different resources have different expiration policies: trending lists refresh frequently, while relatively static information such as movie details and actor profiles can remain cached considerably longer.
The cache also uses stale-while-revalidate behavior, allowing previously fetched data to remain available while a fresh response is retrieved. In-flight request deduplication prevents concurrent requests for the same resource from triggering identical upstream calls, while retries and concurrency limits help control failure and load.
Because the cache persists across application restarts, it remains useful beyond a single server process. The goal was not to eliminate external requests, but to avoid making them unnecessarily and reduce how much the interface depends on their immediate availability.
- Persistent SQLite cache survives restarts
- Resource-specific expiration policies
- Stale-while-revalidate and request deduplication
- Bounded cache storage and upstream concurrency
Playback architecture
From one hardcoded source to configurable providers
The original player was deliberately provider-neutral, but only supported one source at a time. Its URL was defined through an environment variable, so changing providers meant updating the server configuration and restarting the application.
That became inconvenient when a provider stopped working or when different sources supported different content. I replaced the single-source configuration with a provider management system backed by SQLite.
Providers can now be added, edited, reordered, enabled, disabled, or removed through the interface. Each provider defines its own URL templates for movies and TV episodes, using placeholders for the TMDB identifier, season, and episode. The server validates these templates and resolves the appropriate playback URL without requiring application changes.
The player also supports switching between enabled providers directly. It remembers the last selected source separately for movies and TV shows, while falling back to a compatible default when a preferred source is unavailable.
This keeps provider configuration separate from the browsing experience. Adding another source no longer means changing application code, and the player can adapt without navigating away from the current title.
- Persistent configuration
- Providers are stored in SQLite rather than environment variables, allowing changes without rebuilding or restarting the application.
- URL templates
- Each provider defines movie and TV playback URLs using validated placeholders for title, season, and episode identifiers.
- Source preferences
- The player remembers separate movie and TV selections and falls back to an enabled, compatible source when necessary.
- Provider management
- A dedicated settings interface supports adding, editing, reordering, enabling, disabling, previewing, and removing sources.
Metadata enrichment
Bringing IMDb ratings into the same experience
TMDB already provides ratings alongside its metadata, but I wanted to incorporate IMDb ratings without adding another API dependency to every page request.
Instead of scraping individual titles, I built an importer for IMDb's official non-commercial ratings dataset. The data is downloaded, validated, and stored locally in SQLite, with an automated daily refresh to keep it current.
The import runs in a separate Node.js process to avoid blocking requests. New records are staged and validated before replacing the active dataset, preserving existing ratings if a refresh fails.
Because TMDB and IMDb use different identifiers, the application maintains mappings between them. IMDb ratings are preferred where available, with TMDB as a fallback. I also extended this integration to power the Top Rated section, ranking titles using IMDb scores with minimum vote thresholds to avoid skewed results.
These rankings are prepared during the daily refresh and stored in SQLite, keeping the browsing experience responsive without additional processing or API requests.
- Official IMDb dataset with automated daily refresh
- Isolated worker with failure-safe SQLite imports
- IMDb-driven rankings with minimum vote thresholds
- Precomputed Top Rated lists with TMDB metadata fallback
Interface
Making a content-heavy interface feel cinematic
Despite the architectural changes, the original goal of the project remained the same: making discovery and playback feel like one coherent experience rather than a collection of unrelated websites.
The interface is built around artwork, with a featured-title slideshow, discovery rows, search, detailed movie and TV pages, actor profiles, recommendations, and a full-screen player. TV shows also have season and episode browsing, including search across seasons.
The main design challenge was keeping that amount of content readable. Artwork comes in different proportions, titles vary in length, and metadata can quickly overwhelm a page. I approached the interface as a consistent system of typography, spacing, image treatments, and navigation rather than designing each screen independently.
The Next.js rewrite preserved that visual direction while extending the functionality behind it. Provider selection stays inside the player, ratings appear alongside existing metadata, and caching improves how frequently requested information is retrieved.
I wanted the new technical capabilities to support the experience without making the interface feel more complicated.
Remaining limitations
More flexible, but still dependent on external services
The rewrite made the application easier to maintain and more resilient, but it does not eliminate the limitations of the services it depends on.
Caching reduces unnecessary metadata requests, but information still originates from TMDB and must eventually be refreshed. Likewise, supporting multiple playback providers makes switching sources easier, but it cannot guarantee that a particular provider will be available or support a given title.
The application is also intentionally designed for private use. Provider management is available to anyone with access to the deployment, and the SQLite-backed architecture assumes a small, continuously running server rather than a distributed hosting environment.
Those tradeoffs are acceptable for a personal project. Building a multi-user platform with accounts, distributed storage, and provider availability monitoring would introduce complexity that my current use case does not require.
- External playback
- Playback availability, compatibility, and behavior depend on the selected provider. The app does not control the underlying video service.
- Metadata freshness
- Cached TMDB information can temporarily be outdated, while IMDb ratings depend on successful dataset refreshes and identifier mappings.
- Private access model
- Provider settings are shared across the deployment, without a separate account or administrator role system.
- Single-server architecture
- SQLite and the background workers are designed for one persistent Node.js deployment, not horizontally scaled or serverless hosting.
What I learned
A polished interface needs a reliable system behind it
Streaming App started as a way to build a better interface around an experience I was already using. The first version taught me a lot about visual consistency, responsive layouts, and connecting different kinds of media content into one application.
Revisiting the project shifted my attention toward the systems supporting that interface. Migrating to Next.js was not just a framework change; it gave me an opportunity to reconsider how requests, configuration, persistence, and background tasks should fit together.
The most valuable lesson was that reliability often comes from removing unnecessary dependencies between things. Caching makes repeated navigation less dependent on TMDB, configurable providers make playback less dependent on one source, and validated background imports let ratings update without disrupting the application.
I also learned that more functionality does not necessarily require a more complicated interface. Most of the new capabilities live behind the scenes, while the experience remains focused on discovering something to watch and getting to it quickly.
The project is still intentionally small and personal, but its architecture has grown beyond the original prototype. It is now something I can continue extending without having to rebuild the foundations every time a requirement changes.