How I architected a full-stack OTT streaming platform for web, mobile, four TV OSes, and a desktop admin panel — solo, in one Nx TypeScript monorepo — and what I'd do differently.
In November 2025, I started a ground-up rewrite of an OTT streaming platform. Nine target surfaces: web, iOS, Android, Apple TV, Android TV, Samsung Tizen, LG webOS, a Tauri desktop admin, and a docs site. One developer. Five months.
This is not a post about how everything went smoothly. It's an account of the specific decisions I made, the constraints I was working under, and the places where I'd do things differently if I started today.
Prachyam Studios had an OTT platform. It had been touched by 18 developers over its lifetime and showed every sign of it: compromised SSH keys, a Sanity CMS that had become a ceiling rather than a tool, and no path to TV support without a near-total rewrite. Three ThemeForest OTT products were evaluated and purchased. All three under-delivered on TV support and admin completeness in ways that only became visible after purchase.
At that point the options were: buy a fourth template and hope, or build from scratch with full control.
I chose to build. It was probably the more expensive decision in the short term. It was also the only one that could actually hit the full target platform list.
Partway through, I discovered the company was in acquisition talks with a media house. The project shifted — I transitioned to building it for an independent TV-producer and director partnership instead. The technical scope didn't change. The clock got tighter.
The argument for a monorepo when you're targeting 9 surfaces is simple: business logic doesn't care what screen it runs on. Auth, payment processing, subscription state, content metadata schemas — these need to be the same everywhere. If they're not, you're not building one product, you're building nine products that happen to share a brand.
The alternative I was replacing made this concrete. The legacy codebase had duplicated auth logic in at least four places. A subscription-state fix in one place didn't propagate to the mobile app because mobile had its own copy of the same function, slightly different, written by a different developer two years prior.
With 21 shared packages handling everything from @repo/auth (Better Auth sessions) to @repo/payments (Razorpay) to @repo/streaming (HLS transcode workers), a change in one place affects everything that depends on it, and you know immediately because Nx's project graph tells you which apps are affected before you push.
I evaluated Turborepo first. The executor model in Nx mapped better to my build targets — specifically the Tauri desktop build, which needs its own toolchain and doesn't fit neatly into a Turborepo pipeline. Nx's affected command also integrates with the project graph in a way that meant I could run nx affected --target=build after touching @repo/auth and watch only the apps that actually import it get rebuilt, rather than triggering a full-repo cascade.
Karanveer Singh Shaktawat
Full Stack Engineer & Infrastructure Architect
Building portfolio, contributing to open source, and seeking remote full-time roles with significant technical ownership.
Pick what you want to hear about — I'll only email when it's worth it.
Did this resonate?
How I designed the code structure for a streaming platform targeting Next.js web, React Native iOS/Android, tvOS, Android TV, and Tauri desktop — shared business logic, platform-specific UI, one repo.
How I architected an OTT streaming platform that ships to web, mobile, TV, and desktop from one Nx monorepo.
// nx.json (excerpt)
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"cache": true
},
"lint": {
"cache": true
}
},
"namedInputs": {
"sharedGlobals": ["{workspaceRoot}/biome.json"],
"default": ["{projectRoot}/**/*", "sharedGlobals"]
}
}Individual package build time stayed under 30 seconds even as the repo reached 21 packages. That's not accidental — it's the caching doing its job.
I was issued a 16 GB / 512 GB M1 iMac. For a monorepo running 7 concurrent apps, that's marginal. Add a Docker service stack — PostgreSQL, MinIO, Dragonfly (Redis-compatible cache), Typesense, Qdrant, Soketi, BullMQ workers, Prometheus, Grafana, Varnish — and you're not fitting this on one machine without constant memory pressure and thermal throttling on the build.
I didn't have a budget for hardware upgrades. So I networked the machines I had.
Three machines on a Tailscale mesh: the iMac as the build and coding machine, two other machines running the Docker stack. dnsmasq on the iMac assigns stable local DNS entries; Caddy on each service machine handles TLS termination and reverse proxying. Every service gets a stable HTTPS subdomain — api.willmakeitsoon.com, minio.willmakeitsoon.com, and so on — resolvable from the iMac during active development.
# /etc/dnsmasq.conf (excerpt)
address=/api.willmakeitsoon.com/100.64.x.x
address=/minio.willmakeitsoon.com/100.64.x.x
address=/search.willmakeitsoon.com/100.64.x.y
address=/db.willmakeitsoon.com/100.64.x.yThe result: the iMac runs the monorepo build, the Next.js dev server, and the Expo bundler. Everything else — database, cache, object storage, search, observability — lives on the other two nodes and is accessible at a stable domain. Production-mimicking without production costs, and without spending money I didn't have on new hardware.
It worked well enough that I'd consider running a similar setup intentionally on a future project, not just as a workaround.
This section tends to get glossed over in architecture write-ups. "We support TV" usually means someone got an Apple TV app working. The actual surface area is wider and weirder than that.
Apple TV and Android TV are handled by Expo TV (Expo 54 with React Navigation's TV focus engine). That abstraction is genuinely good. The D-pad navigation model maps onto React Native's focusable components cleanly, and you get iOS and Android TV from one codebase with minimal platform-specific branching.
Samsung Tizen 4.x and LG webOS 4.x are a different story. These are Smart TV web platforms — Tizen runs a modified Chromium fork, webOS runs a different one — but "web platform" here means a constrained, often older web platform with specific SDK requirements. webOS needs webOSTVjs-1.2.10 for service access. Tizen needs its own CLI toolchain and certificate-signed packages for sideloading. Neither one can share a build with the Expo TV app.
The moment that clarified this for me: I assumed the HLS player implementation I'd built for the web app would mostly work on Tizen with minor adjustments. In practice, the input event model is completely different. On Tizen 4.x, key events aren't standard browser key events — they come through a Tizen-specific API and the numeric key codes don't match the values you'd find documented for standard remotes. The D-pad focus behavior also doesn't follow browser tab-order logic; you have to manage spatial focus entirely in JavaScript, tracking which element is focused and responding to directional key presses yourself.
That's three distinct input models across four TV platforms: Expo TV's focus engine, the Tizen key event API, and webOS's key event API (similar to Tizen but with its own quirks via the webOS SDK). Structurally separate vanilla-JS builds for Tizen and webOS, each with their own build pipeline, each needing physical or emulated devices for anything beyond basic UI testing.
The Expo TV abstraction earns its keep. The Smart TV web apps are their own maintenance surface, and I'd budget for that explicitly if I were scoping this project for a client.
The practical problem with solo development on a project this large isn't skill — it's context. By sprint 40, the monorepo has 21 packages, 7 apps, and a detailed set of decisions about why Dragonfly is used instead of Redis, why Elysia handles the API instead of Fastify, which routes are cache-swept and which ones aren't. By sprint 80, you cannot hold all of that in your head.
I built 10 custom MCP servers to address this. The one that mattered most was ott-context-mcp, which learns and persists fix patterns and architectural decisions per package across sessions. When I open a new Claude Code session on sprint 90, that server surfaces the relevant context for whatever package I'm touching — not from memory, but from a structured store that accumulated knowledge over the previous 89 sprints.
The others handle practical access: drizzle-studio-mcp for live DB inspection, infra-monitor-mcp for Docker service health, monitoring-mcp for Prometheus queries, api-test-mcp for endpoint testing, video-monitor-mcp for transcode job status. Instead of switching to a browser or terminal to check state, the information arrives in the conversation window.
It sounds like over-engineering for a solo project. In practice, the alternative is spending the first 20 minutes of each session reconstructing context that should already be there. Across 104 sprints, that adds up to a lot of lost time.
What it actually cost. 104 sprint commits, 152 total commits, roughly 971,000 lines across the monorepo by the end of active development. At handoff: 17 of 20 viewer flows demo-clean, 8 of 14 admin flows demo-clean. That gap — the incomplete flows — is where a team would have made a difference. Not in the architecture decisions or the platform abstractions, but in raw bandwidth. A team of 4–6 with clear ownership over subsystems would have shipped the remaining flows without sacrificing anything on the surfaces I prioritized.
What I'd change in the architecture. The CI pipeline. I ran nx affected locally throughout development. It worked, but branch-based affected caching in GitHub Actions with Nx Cloud would have saved several hours of full builds, especially in the middle of the project when the package count was growing and I hadn't yet locked down which packages were stable. I added CI mid-project. I should have started with it.
The zero-TypeScript-error rule. I enforced this from sprint 75 onward. I should have enforced it from sprint 1. The cost of cleaning up accumulated any types and missing generics in sprints 60–74 was real and entirely avoidable.
The TV apps. If I were scoping this today, I'd explicitly call out Tizen and webOS as separate deliverables with their own timelines, not as part of the main monorepo sprint cadence. They share almost nothing with the Expo TV build except the API client and some shared types. Treating them as parallel deliverables from the start would have made the scope more honest.
What held. The monorepo structure. Every architectural decision about shared packages paid forward. The Tailscale mesh dev environment worked well enough that I'd use a similar setup intentionally. The sprint-numbered commit discipline made the demo-readiness audit — mapping every route to its data source and readiness state — something I could write in under an hour, because every route's last relevant sprint was traceable in the git log.
One person building nine platforms is a constraint, not a feature. The decisions I made under that constraint — the monorepo, the shared package layer, the Tailscale mesh, the MCP servers — were attempts to make the constraint less limiting. Some of them worked better than expected. Some of them I'd do differently. All of them were made in the specific conditions of that project, and that's the only way I know how to evaluate them honestly.
The coming-soon site is live at prachyam.willmakeitsoon.com. Full deployment is pending the partnership handoff.