How I designed the code structure for a streaming platform targeting Next.js web, Expo/React Native iOS/Android, Apple TV, Android TV, Samsung Tizen, LG webOS, and a Tauri desktop admin — shared business logic, platform-specific UI, one Nx repo.
In late 2025, Prachyam Studios needed a streaming platform. Not a web app with a mobile site — a real OTT platform: dedicated iOS and Android apps, Apple TV and Android TV variants, Samsung Tizen and LG webOS Smart TV apps, a Tauri desktop admin, a docs site, and a web frontend. Nine platform targets from a single engineering team (which was mostly me).
The architecture question isn't "how do you build all these" — it's "how do you build all these without maintaining nine separate codebases that immediately diverge."
The answer was an Nx monorepo with a strict shared/platform boundary.
| Platform | Framework | Distribution | |----------|-----------|--------------| | Web | Next.js (App Router) | CDN | | iOS | Expo / React Native | App Store | | Android | Expo / React Native | Play Store | | Apple TV | Expo TV (React Native) | App Store | | Android TV | Expo TV (React Native) | Play Store | | Samsung Tizen | Vanilla-JS (Tizen SDK) | Tizen Store / sideload | | LG webOS | Vanilla-JS (webOS SDK) | LG Content Store / sideload | | Desktop Admin | Tauri | Direct download | | Docs | Next.js (static) | CDN |
Nine targets. The goal: share as much as possible without compromising platform-specific behavior.
Business logic doesn't know what platform it's running on. Whether you're checking if a user is subscribed, calculating playback resume position, or filtering a content catalog, the logic is identical on all nine platforms. It's a pure function of state — input in, output out.
UI does know what platform it's running on. A navigation gesture that makes sense on mobile is wrong on TV. A sidebar layout that works on desktop is wrong on phone. The Apple TV remote control requires a completely different focus model than touch or mouse.
The monorepo structure enforces this separation:
apps/
web/ → Next.js app
mobile/ → Expo / React Native (iOS + Android)
tv-expo/ → Expo TV (Apple TV + Android TV)
tv-tizen/ → Vanilla-JS Tizen build
tv-webos/ → Vanilla-JS webOS build
admin/ → Tauri desktop admin
docs/ → Next.js static docs
packages/
core/ → Business logic, API clients, state management
ui/ → Shared primitive components (design tokens, icons)
media/ → Playback logic, DRM, subtitle handling
auth/ → Authentication flows
analytics/ → Event tracking (platform-agnostic)
config/ → Shared configuration, feature flagsThe rule: anything in packages/ has no platform-specific imports. It can't import from React Native, can't import browser APIs, can't assume a DOM exists. It's pure TypeScript — data transformations, state machines, API calls, utility functions.
Platform apps in apps/ import from packages/ freely but packages/ never imports from apps/.
This is where the real work lives. packages/core is a TypeScript library containing:
API client — wraps every backend endpoint with typed responses. All nine platforms call the same functions.
// packages/core/src/api/content.ts
export async function getCatalog(params: CatalogParams): Promise<CatalogResponse> {
return apiClient.get("/v1/catalog", { params });
}
export async function getEpisode(id: string): Promise<Episode> {
return apiClient.get(`/v1/episodes/${id}`);
}State machines — XState machines for flows that have complex state: playback (buffering, playing, paused, error, ended), authentication (unauthenticated, authenticating, authenticated, token-expired), subscription (free, trialing, active, lapsed).
// packages/core/src/machines/playback.ts
export const playbackMachine = createMachine({
id: "playback",
initial: "idle",
states: {
idle: { on: { LOAD: "loading" } },
loading: {
on: {
LOADED: "ready",
ERROR: "error",
}
},
ready: { on: { PLAY: "playing" } },
playing: {
on: {
PAUSE: "paused",
END: "ended",
BUFFER: "buffering",
ERROR: "error",
}
},
paused: { on: { PLAY: "playing" } },
buffering: { on: { RESUME: "playing", ERROR: "error" } },
ended: { type: "final" },
error: { on: { RETRY: "loading" } },
}
});This machine runs identically on web, mobile, TV, and desktop. The platform-specific part is only how you respond to state transitions — what gesture triggers PLAY, how buffering is visually indicated, where the error UI renders.
Analytics — a thin abstraction layer that records events platform-agnostically:
// packages/analytics/src/index.ts
export function trackPlay(episodeId: string, position: number) {
analytics.record("episode.play", { episodeId, position, timestamp: Date.now() });
}Each platform provides the concrete analytics implementation — Firebase for mobile, a custom endpoint for web. The call site in packages/core doesn't know which.
The Apple TV and Android TV apps in apps/tv-expo/ (built with Expo TV) share most of their code with apps/mobile/. The Samsung Tizen and LG webOS apps are a separate story — they're constrained Smart TV web platforms with their own SDK toolchains and input event models, so they get standalone vanilla-JS builds (apps/tv-tizen/, apps/tv-webos/) that share only the API client and types. For the Expo TV apps, the key differences from mobile:
Focus management. TV apps navigate via remote control — up/down/left/right/select. You need a focus model that tracks which UI element is "active" and moves focus in response to D-pad events. Expo TV (via React Navigation's TV focus engine) handles this for the Apple TV / Android TV apps, but you need to design your layouts so focus movement is predictable. The Tizen and webOS builds can't reuse this — each has its own key event API and you manage spatial focus entirely in JavaScript.
Layout. TV screens are landscape-only, viewed from 3–4 meters, running at 1080p or 4K. Text sizes that work on mobile (14–16px) are unreadable on TV. Font sizes need to be 24px+ for comfortable viewing distance.
Gestures vs remote. Swipe gestures don't exist on TV. Every interaction must work with a 5-button remote (up, down, left, right, select). Modals that require swipe-to-dismiss need an alternative dismiss mechanism.
The actual code split between mobile and TV ended up being about 30% TV-specific, 70% shared through packages/. Most of the TV-specific code is layout and focus management — the content, API calls, state machines, and business logic are all shared.
The desktop admin app in apps/admin/ is structurally different from the React Native apps — it's a Tauri application with a web frontend and a Rust backend, used by the Prachyam team for content and operations management rather than by end viewers.
The web frontend can import from packages/core, packages/ui, and packages/analytics because those packages produce standard JavaScript that works in a browser context. The Rust backend is desktop-specific — it handles file system access, native notifications, system keychain (for credential storage), and media key handling.
The DRM handling is the hardest platform-specific piece. Web and desktop have Widevine via EME (Encrypted Media Extensions). iOS has FairPlay. Android has Widevine but at a different level. The packages/media package abstracts over these with a DRMProvider interface:
// packages/media/src/drm.ts
export interface DRMProvider {
acquireLicense(contentId: string): Promise<License>;
releaseLicense(contentId: string): Promise<void>;
}Each platform provides its own implementation of DRMProvider. The playback logic calls DRMProvider methods without knowing which DRM system it's talking to.
The temptation in a cross-platform monorepo is to maximize shared UI components. In practice, this doesn't work as well as you'd hope.
Buttons, inputs, and simple primitives translate reasonably well. packages/ui has a small set of these — basic interactive elements with consistent styling via design tokens.
Content-heavy components don't translate. A video card that looks right on web (hover state, mouse-accessible, certain font sizes) looks wrong on mobile (no hover, touch targets need to be larger, different information density) and wrong on TV (needs to handle focus state, larger text, remote-compatible). Trying to make one component handle all three contexts produces a mess of platform conditionals that's harder to maintain than three separate components.
The pragmatic split: share design tokens (colors, typography scale, spacing) and utilities (formatters, validators) universally. Share simple primitives (icon components, basic buttons) across mobile and web. Write TV and desktop UI components from scratch using the design tokens.
The monorepo uses Nx for build orchestration. The Nx project graph means building apps/web automatically builds packages/core first, in the right order, and nx affected rebuilds only what a change actually touches.
// nx.json (excerpt)
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"cache": true
},
"test": {
"dependsOn": ["^build"],
"cache": true
}
}
}I evaluated Turborepo first; Nx's executor model fit the Tauri desktop build's separate toolchain better than a Turborepo pipeline did.
CI runs on every PR with matrix builds:
packages/ (no platform dependencies)The expensive part: iOS builds require macOS runners, which are 5–10x more expensive than Linux runners on GitHub Actions. The solution: run iOS builds only on main branch merges, not on every PR. PRs get type checking and web tests; full native builds happen post-merge.
Start with the core package, not the apps. I started by building the web app and then extracting shared logic into packages/core as I added platforms. The extraction process was painful — lots of dependency untangling. Starting with packages/core as a pure TypeScript library with no platform assumptions, and then building the apps on top of it, would have been cleaner.
Separate API versioning from app versioning. Native apps on App Store and Play Store have forced update lags — a user on version 1.2 may be calling your API for months while you're deploying version 1.5 to web. The API layer in packages/core should be versioned independently and backward compatible in ways that web doesn't require.
Don't share TV and mobile navigation too early. I tried to share navigation structure between apps/mobile/ and apps/tv-expo/. The mental model for TV navigation (focus-based, hierarchical, limited depth) is different enough from mobile navigation (stack-based, gesture-driven) that the shared code became a constant source of edge cases. They should have been separate from the start.
The architecture held. When Android TV support landed after the initial Apple TV target, the incremental work was mostly the Android-specific build configuration and platform-level focus management — Expo TV gave both from one codebase. The content catalog, API integration, playback state, and most UI components came from apps/tv-expo/ for free. The Tizen and webOS builds were the genuinely expensive surface, exactly because they shared so little.
The monorepo discipline — strict package boundaries, pure packages/core, no platform assumptions leaking upward — is what made that possible. In a fragmented multi-repo setup, adding a new platform would have meant forking from an existing platform and immediately diverging. In the monorepo, adding a platform is principally a UI and build problem. The business logic is already there.
That separation — business logic vs UI, shared vs platform-specific — is the core idea. Everything else is implementation detail.
Karanveer Singh Shaktawat
Full Stack Engineer & Infrastructure Architect
I build production systems across web, mobile, and infrastructure — then document what went wrong and why.
Pick what you want to hear about — I'll only email when it's worth it.
Did this resonate?
How I architected an OTT streaming platform that ships to web, mobile, four TV OSes, and a Tauri desktop admin from one Nx monorepo.
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.