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.
In 2024, Prachyam Studios needed a streaming platform. Not a web app with a mobile site — a real OTT platform: dedicated iOS and Android apps, tvOS and Android TV variants, desktop apps for Windows, Mac, and Linux, and a web frontend. Eight 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 eight separate codebases that immediately diverge."
The answer was a monorepo with a strict shared/platform boundary.
| Platform | Framework | Distribution | |----------|-----------|--------------| | Web | Next.js (App Router) | CDN | | iOS | React Native | App Store | | Android | React Native | Play Store | | tvOS | React Native (TV) | App Store | | Android TV | React Native (TV) | Play Store | | macOS Desktop | Tauri | Direct download | | Windows Desktop | Tauri | Direct download | | Linux Desktop | Tauri | Direct download / AUR |
Eight 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 eight 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 tvOS remote control requires a completely different focus model than touch or mouse.
The monorepo structure enforces this separation:
apps/
web/ → Next.js app
mobile/ → React Native (iOS + Android)
tv/ → React Native TV (tvOS + Android TV)
desktop/ → Tauri (macOS + Windows + Linux)
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 flagsKaranveer 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?
The 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 eight 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 tvOS and Android TV apps in apps/tv/ share most of their code with apps/mobile/. The key differences:
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. React Native TV provides Touchable components that automatically handle focus, but you need to design your layouts so focus movement is predictable.
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 apps in apps/desktop/ are structurally different from the React Native apps — they're Tauri applications with a web frontend (SvelteKit) and a Rust backend.
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 (play/pause/skip on the physical keyboard).
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 Turborepo for build orchestration. The turbo.json dependency graph means building apps/web automatically builds packages/core first, in the right order.
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"test": {
"dependsOn": ["^build"]
}
}
}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/. 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 we added Android TV support after initially shipping tvOS, the incremental work was mostly the Android-specific build configuration and platform-level focus management. The content catalog, API integration, playback state, and most UI components came from apps/tv/ for free.
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.
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.