| name | rn-to-tv-quickstart |
| displayName | RN to TV Quick Start |
| description | Quick start guide for experienced React Native developers transitioning to TV app development. Covers Fire OS, Android TV, Apple TV (tvOS), Vega OS, and Web TV platforms. Focuses on what's different from mobile: spatial navigation, remote controls, focus management, and 10-foot UI patterns. Skip the basics - get your first TV app running fast. |
| keywords | ["getting-started","quickstart","mobile-to-tv","transition","first-tv-app","new-to-tv","beginner-tv","setup-guide","react-native-tv"] |
| author | Giovanni Laquidara |
React Native to TV: Quick Start Guide
You know React Native. Here's what's different for TV.
Official Docs:
The Big 5 Differences from Mobile
1. No Touch - Everything is Remote/D-pad
On mobile: Users tap buttons
On TV: Users navigate with D-pad (up/down/left/right) + select
import { SpatialNavigationNode } from 'react-tv-space-navigation';
const TVButton = ({ onPress, children }) => {
return (
<SpatialNavigationNode onSelect={onPress}>
{({ isFocused }) => (
<View style={[styles.button, isFocused && styles.focused]}>
{children}
</View>
)}
</SpatialNavigationNode>
);
};
Key library: react-tv-space-navigation
Important: v6.0.0+ uses a different API than v5.x:
- v6:
<SpatialNavigationRoot> wrapper + <SpatialNavigationNode> components
- v5:
SpatialNavigation.init() + useFocusable() hook
2. Focus is King
Every interactive element MUST have a visible focus state. Users can't tap what they can't see.
const styles = StyleSheet.create({
button: {
padding: 16,
backgroundColor: '#333',
},
focused: {
backgroundColor: '#555',
borderWidth: 3,
borderColor: '#fff',
transform: [{ scale: 1.05 }],
},
});
Focus Indicators (Use at least 2):
- ✅ Border (3px+ white/colored)
- ✅ Scale (1.05x - 1.1x)
- ✅ Background color change
- ✅ Shadow/glow effect
- ✅ Opacity change
Testing Focus:
- Navigate with D-pad/arrow keys
- Focused element should be immediately obvious
- Test in a dark room (10-foot viewing distance)
- All interactive elements must be reachable
button: {
padding: 16,
backgroundColor: '#333',
},
// CRITICAL: Always show what's focused
focused: {
backgroundColor: '#555',
borderWidth: 3,
borderColor: '#fff',
transform: [{ scale: 1.05 }],
},
});
### 3. 10-Foot UI (Bigger Everything)
Users sit 10 feet away. What works on mobile is too small.
| Element | Mobile | TV |
|---------|--------|-----|
| Body text | 14-16px | 24-32px |
| Buttons | 44px height | 60-80px height |
| Touch targets | 44x44px | 80x80px+ |
| Margins | 16px | 48px+ |
### 4. Safe Zones (TV Overscan)
TVs crop edges. Keep content away from borders.
```typescript
const safeZones = {
horizontal: 48, // Left/right padding
vertical: 27, // Top/bottom padding
};
5. Landscape Only
TV apps are always landscape. Configure in app.json:
{ "expo": { "orientation": "landscape" } }
Platform Quick Reference
| Platform | Technology | Remote Events |
|---|
| Android TV | Expo + react-native-tvos | KeyEvent |
| Apple TV | Expo + react-native-tvos | TVEventHandler |
| Fire TV FOS | Expo + react-native-tvos | KeyEvent |
| Fire TV Vega | Amazon Vega SDK | Kepler TVEventHandler |
| Web TV | React Native Web | Keyboard events |
Prerequisites
For Android TV / Fire TV (Fire OS)
- Node.js LTS (macOS or Linux)
- Android Studio Iguana or later
- Android SDK API 31+ with TV system image
- Android TV emulator configured
For Apple TV (tvOS)
- Node.js LTS on macOS
- Xcode 16+
- tvOS SDK 17+ (install via
xcodebuild -downloadAllPlatforms)
For Fire TV Vega OS
- Amazon Vega SDK installed (see Vega section below)
Complete Project Setup
This guide creates a production-ready monorepo structure supporting all TV platforms.
Claude will ask for your app name and package ID when setting up.
Step 1: Create Monorepo Structure
mkdir <PROJECT_NAME> && cd <PROJECT_NAME>
yarn init -y
yarn set version stable
mkdir -p apps packages/shared-ui/src
Step 2: Configure Root package.json
Create package.json:
{
"name": "@<PROJECT_NAME>/monorepo",
"version": "1.0.0",
"private": true,
"packageManager": "yarn@4.5.0",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "yarn workspace @<PROJECT_NAME>/expo-multi-tv start",
"dev:android": "yarn workspace @<PROJECT_NAME>/expo-multi-tv android",
"dev:ios": "yarn workspace @<PROJECT_NAME>/expo-multi-tv ios",
"dev:web": "yarn workspace @<PROJECT_NAME>/expo-multi-tv web",
"dev:vega": "yarn workspace @<PROJECT_NAME>/vega start",
"build:vega": "yarn workspace @<PROJECT_NAME>/vega build",
"lint:all": "yarn workspaces foreach -pt run lint",
"typecheck": "yarn workspaces foreach -pt run typecheck"
},
"resolutions": {
"metro-source-map": "0.80.12"
}
}
CRITICAL: The metro-source-map resolution fixes babel plugin errors in monorepos.
Step 3: Create Shared TypeScript Config
Create tsconfig.base.json:
{
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"lib": ["ES2021"],
"jsx": "react-native",
"strict": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true
}
}
Step 4: Create Root Babel Config
Create babel.config.js:
const path = require('path');
module.exports = function (api) {
api.cache(true);
let reanimatedPlugin;
try {
reanimatedPlugin = require.resolve('react-native-reanimated/plugin', {
paths: [path.join(__dirname, 'apps/expo-multi-tv/node_modules')]
});
} catch (e) {
reanimatedPlugin = null;
}
return {
presets: ['babel-preset-expo'],
plugins: reanimatedPlugin ? [reanimatedPlugin] : [],
};
};
Step 5: Create Expo TV App
cd apps
npx create-expo-app@latest expo-multi-tv -e with-tv
cd expo-multi-tv
Update apps/expo-multi-tv/package.json:
{
"name": "@<PROJECT_NAME>/expo-multi-tv",
"dependencies": {
"@<PROJECT_NAME>/shared-ui": "*"
}
}
Create apps/expo-multi-tv/metro.config.js:
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
module.exports = config;
Step 6: Add Vega App (Fire TV Vega OS)
mkdir -p apps/vega
cd apps/vega
vega project generate -n <APP_NAME> --template helloWorld
npm install
Update apps/vega/package.json:
{
"name": "@<PROJECT_NAME>/vega",
"scripts": {
"build:debug": "react-native build-kepler --build-type Debug",
"build:release": "react-native build-kepler --build-type Release"
},
"dependencies": {
"@<PROJECT_NAME>/shared-ui": "*",
"react-tv-space-navigation": "^6.0.0-beta1"
}
}
CRITICAL: Update apps/vega/manifest.toml - Add the [processes] section or the app will crash on launch:
schema-version = 1
[package]
title = "My App"
version = "0.1.0"
id = "com.mycompany.myapp"
[components]
[[components.interactive]]
id = "com.mycompany.myapp.main"
runtime-module = "/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0"
launch-type = "singleton"
categories = ["com.amazon.category.main"]
[processes]
[[processes.group]]
component-ids = ["com.mycompany.myapp.main"]
Create apps/vega/metro.config.js:
const path = require('path');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = {
watchFolders: [workspaceRoot],
resolver: {
nodeModulesPaths: [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
],
sourceExts: ['kepler.tsx', 'kepler.ts', 'tsx', 'ts', 'js', 'jsx', 'json'],
},
};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
Step 6b: Configure Spatial Navigation for Vega
react-tv-space-navigation works with Vega but requires a remote control configuration.
Create packages/shared-ui/src/app/remote-control/SupportedKeys.ts:
export enum SupportedKeys {
Up = 'up', Down = 'down', Left = 'left', Right = 'right',
Enter = 'enter', Back = 'back', PlayPause = 'playPause',
Rewind = 'rewind', FastForward = 'fastForward',
}
Create packages/shared-ui/src/app/remote-control/RemoteControlManager.kepler.ts:
import { SupportedKeys } from './SupportedKeys';
const EVENT_MAP: Record<string, SupportedKeys> = {
left: SupportedKeys.Left, right: SupportedKeys.Right,
down: SupportedKeys.Down, up: SupportedKeys.Up,
select: SupportedKeys.Enter, back: SupportedKeys.Back,
};
class RemoteControlManager {
private listeners = new Set<(e: SupportedKeys) => void>();
constructor() {
const { TVEventHandler } = require('react-native');
new TVEventHandler().enable(this, (_: any, e: any) => {
if (e.eventKeyAction === 0 || e.eventKeyAction === undefined) {
const key = EVENT_MAP[e.eventType];
if (key) this.listeners.forEach(l => l(key));
}
});
}
addKeydownListener = (l: (e: SupportedKeys) => void) => { this.listeners.add(l); return l; };
removeKeydownListener = (l: (e: SupportedKeys) => void) => { this.listeners.delete(l); };
}
export default new RemoteControlManager();
Create packages/shared-ui/src/app/configureRemoteControl.ts:
import { Directions, SpatialNavigation } from 'react-tv-space-navigation';
import { SupportedKeys } from './remote-control/SupportedKeys';
import RemoteControlManager from './remote-control/RemoteControlManager';
SpatialNavigation.configureRemoteControl({
remoteControlSubscriber: (callback) => {
const map = {
[SupportedKeys.Right]: Directions.RIGHT, [SupportedKeys.Left]: Directions.LEFT,
[SupportedKeys.Up]: Directions.UP, [SupportedKeys.Down]: Directions.DOWN,
[SupportedKeys.Enter]: Directions.ENTER,
};
return RemoteControlManager.addKeydownListener((k) => callback(map[k] ?? null));
},
remoteControlUnsubscriber: (l) => RemoteControlManager.removeKeydownListener(l),
});
Import in Vega App.tsx:
import '../../../packages/shared-ui/src/app/configureRemoteControl';
Step 7: Create Shared UI Package
Create packages/shared-ui/package.json:
{
"name": "@<PROJECT_NAME>/shared-ui",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
}
Create packages/shared-ui/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src/**/*"]
}
Create packages/shared-ui/src/index.ts:
export * from './theme';
export * from './components/FocusablePressable';
export * from './hooks/useScale';
Step 8: Create Theme System
Create packages/shared-ui/src/theme/index.ts:
export const colors = {
primary: '#E50914',
background: '#141414',
card: '#2F2F2F',
text: '#FFFFFF',
textSecondary: '#B3B3B3',
};
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
};
export const safeZones = {
horizontal: 48,
vertical: 27,
};
Step 9: Create useScale Hook
Create packages/shared-ui/src/hooks/useScale.ts:
import { Dimensions } from 'react-native';
const BASE_WIDTH = 1920;
const BASE_HEIGHT = 1080;
export const useScale = () => {
const { width, height } = Dimensions.get('window');
const scaleWidth = width / BASE_WIDTH;
const scaleHeight = height / BASE_HEIGHT;
const scale = Math.min(scaleWidth, scaleHeight);
return {
scale: (size: number) => size * scale,
width,
height,
};
};
Step 10: Create FocusablePressable Component
Create packages/shared-ui/src/components/FocusablePressable.tsx:
import React from 'react';
import { Pressable, StyleSheet, ViewStyle } from 'react-native';
import { SpatialNavigationNode } from 'react-tv-space-navigation';
interface FocusablePressableProps {
children: React.ReactNode;
onPress?: () => void;
style?: ViewStyle;
focusedStyle?: ViewStyle;
}
export const FocusablePressable: React.FC<FocusablePressableProps> = ({
children,
onPress,
style,
focusedStyle,
}) => {
return (
<SpatialNavigationNode onSelect={onPress}>
{({ isFocused }) => (
<Pressable
onPress={onPress}
style={[
styles.container,
style,
isFocused && styles.focused,
isFocused && focusedStyle,
]}
>
{children}
</Pressable>
)}
</SpatialNavigationNode>
);
};
const styles = StyleSheet.create({
container: {
padding: 16,
},
focused: {
borderWidth: 3,
borderColor: '#fff',
transform: [{ scale: 1.05 }],
},
});
Important: Pass onPress to both SpatialNavigationNode (for remote/D-pad) AND Pressable (for touch/click).
Focus Highlighting: The isFocused prop from the render function is used to apply visual feedback:
{({ isFocused }) => (
<Pressable
onPress={onPress}
style={[
styles.container,
style,
isFocused && styles.focused, // Apply focus styles
isFocused && focusedStyle, // Allow custom focus styles
]}
>
{children}
</Pressable>
)}
The default focus styles provide:
- 3px white border - Clear visual boundary
- 1.05x scale - Subtle size increase
- Custom focusedStyle prop - Override with app-specific styles
Best Practices for Focus:
- Always use high-contrast colors (white/yellow on dark backgrounds)
- Combine multiple indicators (border + scale + color)
- Test on actual TV hardware (emulators may not show true visibility)
- Ensure focus is visible from 10 feet away
Step 11: Install Dependencies and Run
cd ../..
yarn install
yarn dev:android
yarn dev:ios
yarn dev:web
yarn dev:vega
Final Project Structure
<PROJECT_NAME>/
├── apps/
│ ├── expo-multi-tv/ # Android TV, Apple TV, Fire TV FOS, Web
│ │ ├── App.tsx # Main app entry point
│ │ ├── app.json
│ │ ├── metro.config.js
│ │ └── package.json
│ └── vega/ # Fire TV Vega OS
│ ├── App.tsx
│ ├── metro.config.js
│ └── package.json
├── packages/
│ └── shared-ui/ # Shared components & utilities
│ ├── src/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── theme/
│ │ └── index.ts
│ ├── package.json
│ └── tsconfig.json
├── babel.config.js
├── package.json
├── tsconfig.base.json
└── yarn.lock
Platform-Specific File Resolution
Metro bundler automatically resolves platform files:
.kepler.ts/tsx - Fire TV Vega OS (highest priority for Vega)
.android.ts/tsx - Android TV & Fire TV Fire OS
.ios.ts/tsx - Apple TV (tvOS)
.web.ts/tsx - Web platforms
.ts/tsx - Default/shared implementation
Example:
RemoteControlManager.android.ts → Android TV / Fire TV FOS
RemoteControlManager.ios.ts → Apple TV
RemoteControlManager.kepler.ts → Fire TV Vega
Build Commands
Expo TV App (Android TV, Apple TV, Fire TV FOS, Web)
yarn dev:android
yarn dev:ios
yarn dev:web
npx expo build:android
npx expo build:ios
npx expo export --platform web
Vega App (Fire TV Vega OS)
yarn dev:vega
vega build --arch armv7 --buildType release
vega build --arch aarch64 --buildType release
vega build --arch x86_64 --buildType debug
vega device list
vega install --vpkg build/armv7-release/<APP_NAME>.vpkg
vega run --packageId <PACKAGE_ID>
Common Build Issues & Solutions
Java Version Error
Error: Android Gradle plugin requires Java 17 to run. You are currently using Java 11.
Solution: Create android/gradle.properties:
org.gradle.java.home=/path/to/java17
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m
hermesEnabled=true
android.useAndroidX=true
android.enableJetifier=true
Metro Source Map Error (Monorepo)
Error: Cannot find module 'metro-source-map/private/source-map'
Cause: Expo's metro package expects a private/ directory that doesn't exist in metro-source-map 0.80.x
Solution: Create symlinks after yarn install:
cd node_modules/metro-source-map
mkdir -p private/Consumer
ln -sf ../src/source-map.js private/source-map.js
for file in src/Consumer/*.js; do
ln -sf "../../$file" "private/Consumer/$(basename $file)"
done
Better Solution: Add to package.json scripts:
{
"scripts": {
"postinstall": "node scripts/fix-metro-source-map.js"
}
}
Create scripts/fix-metro-source-map.js:
const fs = require('fs');
const path = require('path');
const metroSourceMapPath = path.join(__dirname, '../node_modules/metro-source-map');
const privatePath = path.join(metroSourceMapPath, 'private');
const consumerPath = path.join(privatePath, 'Consumer');
if (!fs.existsSync(privatePath)) {
fs.mkdirSync(privatePath, { recursive: true });
}
if (!fs.existsSync(consumerPath)) {
fs.mkdirSync(consumerPath, { recursive: true });
}
const sourceMapSrc = path.join(metroSourceMapPath, 'src/source-map.js');
const sourceMapDest = path.join(privatePath, 'source-map.js');
if (!fs.existsSync(sourceMapDest)) {
fs.symlinkSync(path.relative(privatePath, sourceMapSrc), sourceMapDest);
}
const consumerSrcPath = path.join(metroSourceMapPath, 'src/Consumer');
const files = fs.readdirSync(consumerSrcPath).filter(f => f.endsWith('.js'));
files.forEach(file => {
const src = path.join(consumerSrcPath, file);
const dest = path.join(consumerPath, file);
if (!fs.existsSync(dest)) {
fs.symlinkSync(path.relative(consumerPath, src), dest);
}
});
console.log('✓ Fixed metro-source-map private paths');
App Entry Point Error (Monorepo)
Error: Unable to resolve "../../App" from "node_modules/expo/AppEntry.js"
Cause: In monorepo, Expo's default AppEntry.js looks for App.tsx at wrong path
Solution: Create index.js in app root:
import { registerRootComponent } from 'expo';
import App from './App';
registerRootComponent(App);
This overrides Expo's default entry point and uses the correct relative path.
react-tv-space-navigation API Error
Error: TypeError: SpatialNavigation.init is not a function (it is undefined)
Cause: react-tv-space-navigation v6.0.0+ has breaking API changes from v5.x
v5.x API (OLD - Don't use):
import { SpatialNavigation, useFocusable } from 'react-tv-space-navigation';
SpatialNavigation.init({ debug: true });
const { ref, focused } = useFocusable({ onEnterPress: onPress });
v6.0.0+ API (NEW - Use this):
import { SpatialNavigationRoot, SpatialNavigationNode } from 'react-tv-space-navigation';
<SpatialNavigationRoot>
<App />
</SpatialNavigationRoot>
<SpatialNavigationNode onSelect={onPress}>
{({ isFocused }) => (
<View style={isFocused && styles.focused}>
{children}
</View>
)}
</SpatialNavigationNode>
Migration Steps:
- Remove
SpatialNavigation.init() calls
- Wrap root component in
<SpatialNavigationRoot>
- Replace
useFocusable() with <SpatialNavigationNode> render prop
- Change
focused to isFocused in render prop
- Change
onEnterPress to onSelect
Hermes Configuration Missing
Error: Could not get unknown property 'hermesEnabled'
Solution: Add to android/gradle.properties:
hermesEnabled=true
Focus Management Troubleshooting
Focus Not Visible
Problem: Can't see which element is focused
Solutions:
- Increase border width:
borderWidth: 4 or higher
- Use high-contrast colors:
borderColor: '#FFFF00' (yellow)
- Add multiple indicators:
focused: {
borderWidth: 4,
borderColor: '#fff',
transform: [{ scale: 1.1 }],
backgroundColor: '#444',
shadowColor: '#fff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 10,
}
Focus Not Moving Between Elements
Problem: D-pad navigation doesn't move focus
Causes & Solutions:
- Missing SpatialNavigationRoot: Wrap entire app
- Elements not aligned: Ensure buttons are in proper layout (Row/Column)
- Check console: Look for spatial navigation warnings
- Test with multiple elements: Need at least 2 focusable items
Button Press Not Working
Problem: Focused button doesn't respond to Enter/Select
Solution: Ensure both onSelect AND onPress are set:
<SpatialNavigationNode onSelect={handlePress}>
{({ isFocused }) => (
<Pressable onPress={handlePress}> {/* Both needed! */}
Common Mistakes (Don't Do These)
❌ Using Pressable without Focus Management
<Pressable onPress={handlePress}><Text>Click</Text></Pressable>
<FocusablePressable onPress={handlePress}><Text>Select</Text></FocusablePressable>
❌ Forgetting Safe Zones
<View style={{ position: 'absolute', top: 0, left: 0 }}>
<View style={{ position: 'absolute', top: 27, left: 48 }}>
❌ Small Touch Targets
<View style={{ width: 44, height: 44 }} />
<View style={{ width: 80, height: 80 }} />
Vega Troubleshooting
App Crashes Immediately on Launch (Black Screen)
Symptom: App installs successfully, "Successfully launched" message appears, but app immediately crashes with black screen and no error.
Cause: Missing [processes] section in manifest.toml
Fix: Add to apps/vega/manifest.toml:
[processes]
[[processes.group]]
component-ids = ["com.yourcompany.yourapp.main"]
Running on Vega Virtual Device
vega simulator
vega device list
yarn build:release
vega run-app build/aarch64-release/<APP_NAME>_aarch64.vpkg <PACKAGE_ID>
vega device is-app-running --appName <PACKAGE_ID>
Testing Your TV App
Android TV
yarn dev:android
Apple TV
yarn dev:ios
Web (with keyboard)
yarn dev:web
Physical Devices
- Android TV: Enable developer mode, connect via ADB
- Fire TV: Enable ADB debugging in Settings
- Apple TV: Connect via Xcode
Next Steps
- Add screens - Create screens in
packages/shared-ui/src/screens/
- Add navigation - Set up React Navigation in shared-ui
- Add video player - Use react-native-video (Expo) or VideoView (Vega)
- Handle remote events - Create platform-specific RemoteControlManagers
- Test on real hardware - Emulators don't show real performance
Need More?
- Full knowledge base: Use the
multi-tv-builder skill
- Apple TV issues: Use the
apple-tv-troubleshooter skill
- Movie/TV data API: Use the
tmdb-integration skill
Reference Implementation
For a complete working example with all features implemented:
Use this as a reference to see advanced patterns like video playback, navigation, and remote control handling.
Android TV / Fire TV Runtime Troubleshooting
TypeError: Cannot read property 'displayName' of undefined
Symptoms:
- App builds successfully but crashes immediately
- Metro shows:
ERROR TypeError: Cannot read property 'displayName' of undefined
- App returns to launcher after brief flash
Common Causes & Fixes:
-
Wrong import in index.js (Most Common)
import { App } from './App';
import App from './App';
-
Metro cache corruption
pkill -f "expo" 2>/dev/null || true
rm -rf node_modules/.cache /tmp/metro-* /tmp/haste-map-*
npx expo start --clear
-
react-tv-space-navigation v6 missing configuration
import { SpatialNavigation } from 'react-tv-space-navigation';
SpatialNavigation.configureRemoteControl({
remoteControlSubscriber: (callback) => () => {},
remoteControlUnsubscriber: () => {},
});
Metro Bundler Connection Issues
Symptoms:
Couldn't connect to "ws://localhost:8081/message..."
- App shows loading screen indefinitely
Fixes:
adb reverse tcp:8081 tcp:8081
adb devices -l
adb kill-server && adb start-server
Android TV Emulator Quick Commands
emulator -avd Android_TV_720p -no-snapshot-load &
adb wait-for-device
adb shell am force-stop <package_name>
adb shell am start -n <package_name>/.MainActivity
adb logcat -d | grep -iE "(ReactNativeJS|error)" | tail -30
Expo Go vs Development Builds
Important: TV apps should use development builds, not Expo Go.
npx expo start
npx expo run:android
npx expo start --dev-client
Vega in Monorepo - Critical Setup
Port Forwarding Required
Vega virtual devices need reverse port forwarding to connect to Metro:
vega device start-port-forwarding --port 8081 --forward false
Without this, the app shows a black screen because it can't fetch the JS bundle.
React Version Conflicts (Expo + Vega)
If your monorepo has both Expo (React 19) and Vega (React 18), you'll get:
TypeError: Cannot read property 'ReactCurrentOwner' of undefined
Fix: Move root React packages before running Vega:
mv node_modules/react node_modules/react.bak
mv node_modules/react-native node_modules/react-native.bak
Node.js 23 Compatibility
Node 23 breaks Metro's package exports. Fix by removing exports field:
const fs = require('fs');
['metro', 'metro-source-map', 'metro-transform-worker', 'metro-runtime'].forEach(pkg => {
const p = `node_modules/${pkg}/package.json`;
if (fs.existsSync(p)) {
const json = JSON.parse(fs.readFileSync(p));
delete json.exports;
fs.writeFileSync(p, JSON.stringify(json, null, 2));
}
});
Complete Vega Launch Sequence
node fix-metro-exports.js
mv node_modules/react node_modules/react.bak
cd apps/vega && npm start
vega device start-port-forwarding --port 8081 --forward false
vega device launch-app --appName com.yourcompany.app
vega device running-apps | grep yourapp