Skip to main content

Command Palette

Search for a command to run...

How Instagram, WhatsApp, Uber & Netflix Would Be Built Today Using Expo Router

Updated
20 min read

Architecture is not about how things look. It's about how they survive.


Introduction: The Day Your Folder Structure Betrays You

Every React Native project starts the same way. You create components/, screens/, utils/, maybe hooks/ if you're feeling fancy. It works. You ship. Life is good.

Then the product grows. A new feature. Then another. A junior joins the team. Then two more. Three months later, screens/ has 47 files and no one remembers what HomeScreen2Final_v3.tsx does.

This isn't a cleanliness problem. It's an architecture problem — and the difference between a startup mobile app and a production system used by hundreds of millions of people comes down entirely to how decisions like these were made early on.

In this article, we're not going to clone Instagram or Uber. We're going to think like the engineers who built them — and explore how Expo Router gives us the primitives to architect mobile applications that actually scale.


Why Simple Folder Structures Fail at Scale

Let's be honest about what most tutorials teach:

/src
  /components
    Button.tsx
    Card.tsx
    Avatar.tsx
  /screens
    HomeScreen.tsx
    ProfileScreen.tsx
    SettingsScreen.tsx
  /utils
    formatDate.ts
    api.ts
  /hooks
    useAuth.ts

This is perfectly fine for a to-do app. But now imagine you're Instagram and HomeScreen.tsx needs:

  • Feed rendering with infinite scroll

  • Stories bar with circular avatars

  • Reels entry point

  • Notification badge

  • Search shortcut

  • Live activity indicators

Suddenly your "screen" file is 800 lines. Your api.ts is 3,000 lines. Your components/ folder has 90 files with zero context about which feature they belong to.

The failure modes are predictable:

Problem Root Cause
Merge conflicts on every sprint Everyone touches the same files
"I don't know what breaks if I change this" No feature boundaries
Slow CI builds Imports chain across everything
Impossible onboarding No local coherence
Fear of refactoring No encapsulation

Scale punishes shared global structure. What you need instead is feature locality — the idea that everything related to a feature lives together, so the feature can be understood, tested, and replaced in isolation.


The Production Mindset Shift

Before diving into Expo Router specifics, internalize this shift:

Small App Thinking Production Engineering Thinking
"Where do files go?" "Who owns this code?"
Flat folder structure Feature-based domain separation
One global state store Scoped state per domain
Fetch from any component Centralized, typed API layer
Navigate anywhere Defined navigation contracts
Bundle everything Lazy-load aggressively

Production apps at Instagram's scale have teams of 8–12 engineers who own a single feature vertical (e.g., "Reels" or "Stories"). Their code must be completely isolated from other teams' code. This is not optional — it's what keeps 600+ engineers from destroying each other's work.


Expo Router: File-Based Navigation at Scale

Expo Router brings the Next.js mental model to React Native — your file system is your navigation tree. This is a profound shift from React Navigation's imperative stack definitions.

/app
  _layout.tsx          ← Root layout (fonts, providers, splash)
  index.tsx            ← Entry point / redirect
  (auth)/
    _layout.tsx        ← Auth group layout (no tabs)
    login.tsx
    signup.tsx
    forgot-password.tsx
  (app)/
    _layout.tsx        ← Tab layout (authenticated shell)
    (feed)/
      _layout.tsx
      index.tsx        ← Feed home
      [postId].tsx     ← Dynamic post detail
    (search)/
      index.tsx
      results.tsx
    (reels)/
      index.tsx
    (notifications)/
      index.tsx
    (profile)/
      _layout.tsx
      index.tsx
      [userId].tsx     ← Public profile
      edit.tsx
  +not-found.tsx

Three things to notice here:

  1. Route groups (auth) and (app) let you apply different layouts to different navigation contexts without polluting the URL/path

  2. Dynamic routes [postId].tsx are typed and composable

  3. Layout nesting means your tab bar, header, and providers are applied declaratively — no more wrapping 12 providers around your navigator

Authentication Flow with Protected Routes

// app/(app)/_layout.tsx
import { Redirect } from 'expo-router';
import { useAuthStore } from '@/features/auth/store';

export default function AppLayout() {
  const { isAuthenticated, isLoading } = useAuthStore();

  if (isLoading) return <SplashScreen />;
  if (!isAuthenticated) return <Redirect href="/login" />;

  return <TabLayout />;
}
// app/(auth)/_layout.tsx
import { Redirect } from 'expo-router';
import { useAuthStore } from '@/features/auth/store';

export default function AuthLayout() {
  const { isAuthenticated } = useAuthStore();

  // Already logged in? Kick them to the app
  if (isAuthenticated) return <Redirect href="/(app)/(feed)" />;

  return <Stack />;
}

This pattern creates a hard navigation boundary between authenticated and unauthenticated states. No manual route guards. No runtime navigation calls in useEffect. The file system enforces it.


Feature-Based Architecture: The Real Structure

The /app folder only defines routes. The actual product logic lives in /features. This separation is critical.

/src
  /features
    /auth
      components/
        LoginForm.tsx
        OAuthButtons.tsx
      hooks/
        useLogin.ts
        useOAuthFlow.ts
      store/
        authStore.ts       ← Zustand slice
      api/
        authApi.ts
      types/
        auth.types.ts
      index.ts             ← Public barrel export

    /feed
      components/
        FeedList.tsx
        PostCard.tsx
        StoryBar.tsx
        StoriesReel.tsx
      hooks/
        useFeed.ts
        useInfiniteScroll.ts
        useLikePost.ts
      store/
        feedStore.ts
      api/
        feedApi.ts
      types/
        feed.types.ts
      index.ts

    /messaging          ← WhatsApp-like
    /maps               ← Uber-like
    /player             ← Netflix-like
    /notifications
    /search
    /profile

  /shared
    /components
      /ui               ← Design system primitives
        Button.tsx
        Avatar.tsx
        Badge.tsx
        Sheet.tsx
      /layout
        SafeArea.tsx
        KeyboardAware.tsx
    /hooks
      useDebounce.ts
      useAppState.ts
      usePrevious.ts
    /lib
      queryClient.ts    ← TanStack Query setup
      socket.ts         ← Socket.io singleton
      storage.ts        ← MMKV setup
      analytics.ts
    /types
      global.d.ts
      api.types.ts

  /constants
    routes.ts
    config.ts
    endpoints.ts

The key rule: a feature can import from /shared, but never from another feature directly. If two features need to share something, it moves to /shared. This creates explicit dependency management without a monorepo toolchain.


The Navigation Hierarchy

Root Stack
├── (auth) Group
│   ├── /login
│   ├── /signup
│   └── /forgot-password
│
└── (app) Group — Tab Navigator
    ├── Tab: (feed)
    │   ├── /              ← Feed index
    │   ├── /[postId]      ← Post detail (pushes onto stack)
    │   └── /comments/[postId]
    │
    ├── Tab: (search)
    │   ├── /              ← Search home
    │   └── /results       ← Results with params
    │
    ├── Tab: (reels)       ← Full-screen vertical scroll
    │
    ├── Tab: (notifications)
    │
    └── Tab: (profile)
        ├── /              ← Own profile
        ├── /edit
        └── /[userId]      ← Others' profiles

Shared Layouts and Nested Routing

Expo Router's nested layouts are what make complex UIs manageable:

// app/(app)/_layout.tsx — The app shell with tabs
export default function AppTabsLayout() {
  return (
    <Tabs
      screenOptions={{ headerShown: false }}
      tabBar={(props) => <CustomTabBar {...props} />}
    >
      <Tabs.Screen name="(feed)" options={{ title: 'Home' }} />
      <Tabs.Screen name="(search)" options={{ title: 'Search' }} />
      <Tabs.Screen name="(reels)" options={{ title: 'Reels' }} />
      <Tabs.Screen name="(notifications)" options={{ title: 'Activity' }} />
      <Tabs.Screen name="(profile)" options={{ title: 'Profile' }} />
    </Tabs>
  );
}
// app/(app)/(feed)/_layout.tsx — Feed's own stack
export default function FeedLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ headerShown: false }} />
      <Stack.Screen name="[postId]" options={{ presentation: 'card' }} />
      <Stack.Screen
        name="comments/[postId]"
        options={{ presentation: 'modal' }}
      />
    </Stack>
  );
}

Each tab maintains its own navigation stack, just like the actual Instagram app — going deep into a post and back doesn't reset your tab position.


API Handling and Networking Layer

Every production app at scale has an API layer that is completely decoupled from UI components. Components never call fetch(). Ever.

The Three-Layer API Architecture

Component → Hook (TanStack Query) → API Module → HTTP Client
// src/shared/lib/apiClient.ts
import axios from 'axios';
import { tokenStore } from '@/features/auth/store';

export const apiClient = axios.create({
  baseURL: process.env.EXPO_PUBLIC_API_URL,
  timeout: 10_000,
});

// Request interceptor — attach auth token
apiClient.interceptors.request.use((config) => {
  const token = tokenStore.getState().accessToken;
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

// Response interceptor — handle 401, token refresh
apiClient.interceptors.response.use(
  (res) => res,
  async (error) => {
    if (error.response?.status === 401) {
      await tokenStore.getState().refreshTokens();
      return apiClient.request(error.config); // Retry
    }
    return Promise.reject(error);
  }
);
// src/features/feed/api/feedApi.ts
import { apiClient } from '@/shared/lib/apiClient';
import type { FeedPost, PaginatedResponse } from '@/shared/types';

export const feedApi = {
  getFeed: (cursor?: string) =>
    apiClient.get<PaginatedResponse<FeedPost>>('/feed', {
      params: { cursor, limit: 12 },
    }),

  likePost: (postId: string) =>
    apiClient.post(`/posts/${postId}/like`),

  getPost: (postId: string) =>
    apiClient.get<FeedPost>(`/posts/${postId}`),
};
// src/features/feed/hooks/useFeed.ts
import { useInfiniteQuery } from '@tanstack/react-query';
import { feedApi } from '../api/feedApi';

export function useFeed() {
  return useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) => feedApi.getFeed(pageParam),
    getNextPageParam: (lastPage) => lastPage.data.nextCursor,
    staleTime: 1000 * 30, // 30 seconds
  });
}

Components stay clean. They use hooks. Hooks use the API layer. The API layer uses the HTTP client. Every concern has exactly one home.


State Management Strategies

There is no single right answer here, but there is a clear pattern used by production teams:

State Type Tool Example
Server state (async) TanStack Query Feed posts, user profiles
UI state (local) useState / useReducer Modal open/close, form input
Shared client state Zustand Auth session, theme, cart
Persistent state MMKV + Zustand persist Token, user preferences
Navigation state Expo Router Current route, params

The mistake most teams make: using a global store for everything. Server state (your posts, your messages) belongs in a cache manager like TanStack Query, not in Redux or Zustand. It knows about staling, refetching, deduplication, and background updates. Your Zustand store does not.

// src/features/auth/store/authStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { storage } from '@/shared/lib/storage'; // MMKV

interface AuthState {
  accessToken: string | null;
  user: User | null;
  isAuthenticated: boolean;
  setTokens: (access: string) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      accessToken: null,
      user: null,
      isAuthenticated: false,
      setTokens: (access) => set({ accessToken: access, isAuthenticated: true }),
      logout: () => set({ accessToken: null, user: null, isAuthenticated: false }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => storage), // MMKV adapter
    }
  )
);

Realtime Systems

This is where the four apps diverge most dramatically.

WhatsApp: Chat Architecture

WhatsApp's realtime model is built on three principles: local optimism, server reconciliation, and delivery receipts.

// src/shared/lib/socket.ts
import { io, Socket } from 'socket.io-client';

let socket: Socket | null = null;

export function getSocket(): Socket {
  if (!socket) {
    socket = io(process.env.EXPO_PUBLIC_WS_URL!, {
      auth: { token: tokenStore.getState().accessToken },
      transports: ['websocket'],
      reconnectionAttempts: Infinity,
      reconnectionDelay: 1000,
    });
  }
  return socket;
}
// src/features/messaging/hooks/useMessages.ts
export function useMessages(conversationId: string) {
  const queryClient = useQueryClient();
  const socket = getSocket();

  useEffect(() => {
    socket.emit('join:conversation', conversationId);

    socket.on('message:new', (message: Message) => {
      // Optimistically update the cache
      queryClient.setQueryData(
        ['messages', conversationId],
        (old: Message[]) => [...(old ?? []), message]
      );
    });

    return () => {
      socket.emit('leave:conversation', conversationId);
      socket.off('message:new');
    };
  }, [conversationId]);

  return useInfiniteQuery({
    queryKey: ['messages', conversationId],
    queryFn: ({ pageParam }) => messagingApi.getMessages(conversationId, pageParam),
    getNextPageParam: (first) => first.data.prevCursor,
  });
}

export function useSendMessage(conversationId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: messagingApi.sendMessage,
    onMutate: async (newMessage) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['messages', conversationId] });

      // Optimistic update with temporary ID
      const optimistic = { ...newMessage, id: `temp-${Date.now()}`, status: 'sending' };
      queryClient.setQueryData(['messages', conversationId], (old: Message[]) =>
        [...(old ?? []), optimistic]
      );

      return { optimistic };
    },
    onSuccess: (real, _, context) => {
      // Replace optimistic message with real one
      queryClient.setQueryData(['messages', conversationId], (old: Message[]) =>
        old.map((m) => (m.id === context?.optimistic.id ? real.data : m))
      );
    },
    onError: (_, __, context) => {
      // Mark as failed
      queryClient.setQueryData(['messages', conversationId], (old: Message[]) =>
        old.map((m) =>
          m.id === context?.optimistic.id ? { ...m, status: 'failed' } : m
        )
      );
    },
  });
}

The key insight: messages are sent optimistically. Your UI shows the message immediately. If the server fails, you mark it as failed and let the user retry. This is why WhatsApp feels instant even on poor connections.

Uber: Maps and Live Location

Uber's frontend challenge is rendering a live map with moving driver locations efficiently. The danger is re-rendering the entire map on every location update.

// src/features/maps/hooks/useDriverLocation.ts
export function useDriverLocation(rideId: string) {
  const locationRef = useRef<DriverLocation | null>(null);
  const socket = getSocket();

  useEffect(() => {
    socket.emit('ride:watch', rideId);

    socket.on('driver:location', (location: DriverLocation) => {
      locationRef.current = location;
      // Update map marker directly via ref — no re-render
      mapRef.current?.animateToRegion({
        latitude: location.lat,
        longitude: location.lng,
        latitudeDelta: 0.01,
        longitudeDelta: 0.01,
      });
    });

    return () => socket.emit('ride:unwatch', rideId);
  }, [rideId]);

  return locationRef;
}

The critical pattern: don't put live coordinates in React state. Use a ref and update the map imperatively. State updates trigger re-renders. At 10 location updates per second, that's 10 full component re-renders — which will visibly stutter on mid-range Android devices.

Netflix: Heavy Content Delivery

Netflix's mobile challenge isn't realtime — it's content delivery and startup performance. Their entire architecture is built around making content visible within 100ms of opening the app.

// src/features/player/hooks/useVideoPreload.ts
import { Video, ResizeMode } from 'expo-av';

export function useVideoPreload(uri: string) {
  const videoRef = useRef<Video>(null);

  useEffect(() => {
    // Preload 30 seconds of video while user is browsing
    Video.setAudioModeAsync({ playsInSilentModeIOS: true });
    videoRef.current?.loadAsync({ uri }, {}, false);

    return () => videoRef.current?.unloadAsync();
  }, [uri]);

  return videoRef;
}

Netflix also implements adaptive bitrate streaming (HLS/DASH) — the player requests lower-quality segments on poor connections and switches up transparently. On mobile, this is handled by the native player layer, not React Native JS.


Offline-First Support and Caching

The Instagram Example

Instagram works offline. You can scroll your cached feed, double-tap posts, and your likes queue up locally. When you reconnect, they sync.

// src/shared/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { storage } from './storage';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,   // 5 minutes
      gcTime: 1000 * 60 * 60 * 24, // 24 hours in cache
      retry: 3,
      retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
    },
  },
});

const persister = createAsyncStoragePersister({
  storage: {
    getItem: (key) => storage.getString(key) ?? null,
    setItem: (key, value) => storage.set(key, value),
    removeItem: (key) => storage.delete(key),
  },
});

persistQueryClient({ queryClient, persister });
// src/features/feed/hooks/useLikePost.ts — Offline-optimistic like
export function useLikePost() {
  const { isConnected } = useNetInfo();
  const pendingQueue = usePendingActionsStore();

  return useMutation({
    mutationFn: feedApi.likePost,
    onMutate: async (postId) => {
      // Optimistic UI update always
      queryClient.setQueryData(['post', postId], (old: Post) => ({
        ...old,
        liked: true,
        likeCount: old.likeCount + 1,
      }));
    },
    onError: (_, postId) => {
      if (!isConnected) {
        // Queue for later — don't revert
        pendingQueue.enqueue({ type: 'like', postId });
      } else {
        // Actually failed — revert
        queryClient.invalidateQueries({ queryKey: ['post', postId] });
      }
    },
  });
}

When the user comes back online, you drain the pending queue in order. Instagram's likes feel instant because they never wait for the network.


App Startup Optimization

The first 2 seconds of your app's launch determine whether users trust it. Every millisecond of white screen is brand damage.

The Startup Lifecycle

Native Boot
  └─ JS Bundle Evaluation  ← Minimize this
       └─ Root Layout Mount
            ├─ Font Loading (preloaded natively)
            ├─ Token Check (MMKV sync — 0ms)
            ├─ Auth Gate Evaluation
            └─ First Screen Render
                 └─ Prefetched Data Available  ← Show this fast

Key Techniques

1. Synchronous token check with MMKV

// MMKV reads are synchronous — no async waterfall
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();
const token = storage.getString('access_token'); // ~0.1ms

AsyncStorage reads are async — they add 5–20ms of latency on the critical path. MMKV eliminates this.

2. Prefetch critical data before navigation

// app/_layout.tsx
export default function RootLayout() {
  useEffect(() => {
    // Warm up the feed cache in the background
    // By the time the user reaches the feed tab, data is ready
    queryClient.prefetchInfiniteQuery({
      queryKey: ['feed'],
      queryFn: () => feedApi.getFeed(),
    });
  }, []);
}

3. Expo Router's expo-splash-screen integration

// app/_layout.tsx
import * as SplashScreen from 'expo-splash-screen';

SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    Promise.all([
      Font.loadAsync({ ... }),       // fonts
      Asset.loadAsync([...]),        // critical images
      checkAuthStatus(),             // token validation
    ]).then(() => {
      setReady(true);
      SplashScreen.hideAsync();
    });
  }, []);

  if (!ready) return null;
  return <Stack />;
}

4. Lazy-load heavy feature modules

const ReelsPlayer = lazy(() => import('@/features/reels/components/ReelsPlayer'));
const MapView = lazy(() => import('@/features/maps/components/MapView'));

// These are NOT in your initial bundle
// They load on first navigation to the relevant tab

Performance Considerations in Production

The FlashList Problem

FlatList re-renders every visible cell on scroll. At Instagram's feed density, this destroys frame rate on mid-range devices. The solution is Shopify's FlashList:

import { FlashList } from '@shopify/flash-list';

export function FeedList({ posts }: { posts: Post[] }) {
  return (
    <FlashList
      data={posts}
      renderItem={({ item }) => <PostCard post={item} />}
      estimatedItemSize={400}           // Critical — prevents layout thrash
      keyExtractor={(item) => item.id}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
      removeClippedSubviews                // Unmount off-screen cells
      maxToRenderPerBatch={5}
      windowSize={10}
    />
  );
}

Image Optimization

Instagram's images load fast because they're:

  1. Served from a CDN closest to the user

  2. Progressively loaded (blur hash → thumbnail → full)

  3. Prefetched for the next 3–4 scroll positions

import { Image } from 'expo-image';

<Image
  source={post.imageUrl}
  placeholder={post.blurHash}        // Show placeholder immediately
  contentFit="cover"
  transition={200}                   // Smooth fade-in
  cachePolicy="memory-disk"          // Cache aggressively
/>

Hermes + React Compiler

Enable Hermes (Meta's JS engine for React Native) in your app.json:

{
  "expo": {
    "jsEngine": "hermes"
  }
}

Hermes pre-compiles JS to bytecode at build time, reducing parse time on device by 40–60%. This is non-negotiable for production.


The Four Apps: Scalability Deep-Dive

Instagram → Feeds and Media

Core challenge: Rendering an infinite, heterogeneous feed (photos, videos, carousels, ads, suggested content) efficiently.

Architecture decisions:

  • Feed is a single FlashList with a discriminated union item type

  • Each post type renders its own component (PhotoPost, CarouselPost, ReelPreview, SuggestedUsers)

  • Media is lazy-loaded — only the currently visible item plays video

  • Stories are a separate horizontal list, independent of the feed scroll

Expo Router structure:

app/(app)/(feed)/
  index.tsx           ← FlashList of mixed post types
  [postId].tsx        ← Full post view
  stories/[userId].tsx ← Story viewer (full screen)
  comments/[postId].tsx ← Comment sheet

Tradeoff: Instagram made a controversial choice — the feed is fully server-ranked. The app has no local personalization logic. This simplifies the client enormously but means the app is useless offline.


WhatsApp → Realtime Messaging

Core challenge: Billions of messages per day with end-to-end encryption, delivery receipts, and offline reliability.

Architecture decisions:

  • Message state has three tiers: sent to server (✓), delivered to device (✓✓), read (✓✓ blue)

  • Conversation list is paginated — you don't load all conversations at boot

  • Media messages are sent via a separate upload pipeline, not through the socket

  • Unread counts are maintained locally to avoid a round-trip on every app open

Expo Router structure:

app/(app)/(messaging)/
  index.tsx           ← Conversation list
  [conversationId]/
    index.tsx         ← Chat thread
    media.tsx         ← Shared media gallery
    info.tsx          ← Conversation details

Tradeoff: WhatsApp chose client-side encryption (Signal Protocol), which means their servers cannot read messages. This eliminates spam filtering, content moderation, and AI features in messages — a significant product constraint for privacy.


Uber → Maps and Live Location

Core challenge: High-frequency location updates, complex map state, and the need to work on low-end Android devices in emerging markets.

Architecture decisions:

  • Map rendering is fully native (Mapbox/Google Maps) — JavaScript never touches coordinates for smooth 60fps animation

  • Driver location updates arrive at ~10Hz over WebSocket

  • The "finding a driver" state is a state machine: idle → searching → matched → en_route → arriving → in_trip → completed

  • ETA calculations happen on the server, not the client

Expo Router structure:

app/(app)/(ride)/
  index.tsx           ← Map + booking UI
  confirm.tsx         ← Ride confirmation
  tracking/[rideId].tsx ← Live tracking
  history/index.tsx   ← Past trips
  history/[tripId].tsx ← Trip detail + receipt

Tradeoff: Uber's biggest architectural constraint is battery life. Location polling is expensive. They use a variable frequency — high frequency while a trip is active, low frequency while the app is backgrounded. Background tracking requires platform-specific native modules.


Netflix → Heavy Content Delivery

Core challenge: Delivering high-quality video to 270M+ subscribers across every network condition and device.

Architecture decisions:

  • Content metadata and the browse UI are completely separate from video playback

  • The browse UI prefetches the next row of content as you scroll down

  • Video player is instantiated early (off-screen) so playback starts without delay

  • Downloads are managed by a queue system with priority (in-progress downloads persist across app restarts)

Expo Router structure:

app/(app)/(browse)/
  index.tsx                 ← Home row grid
  [contentId]/
    index.tsx               ← Title detail page
    episodes.tsx            ← Episode list (series)
    more-like-this.tsx
  search/index.tsx
  downloads/index.tsx       ← Offline content
app/(player)/
  [contentId].tsx           ← Full-screen player (separate layout)

The player lives in its own route group with no tabs, headers, or status bar — a completely immersive shell.

Tradeoff: Netflix uses DRM (Widevine on Android, FairPlay on iOS) for protected content. This requires platform-specific native modules that are not available in Expo Go — they're only supported in development builds. Any serious video app must use eas build from day one.


Tradeoffs at Scale: What Teams Actually Decide

Decision Small App Scale App Why
State management Context API Zustand + TanStack Query Context re-renders entire trees
Storage AsyncStorage MMKV MMKV is 10x faster, synchronous
Lists FlatList FlashList FlatList can't handle >500 items
Images <Image> expo-image Blurhash, disk cache, CDN-aware
Navigation React Navigation manual Expo Router file-based Type-safe params, less boilerplate
API layer fetch() in components Axios + TanStack Query Caching, retries, deduplication
Realtime Polling WebSockets + fallback Lower latency, less server load
Build Expo Go EAS Build Native modules, DRM, background tasks

Closing Thoughts: Architecture as a Long Game

The apps we discussed — Instagram, WhatsApp, Uber, Netflix — were not architected perfectly from day one. They evolved. But they evolved faster and broke less because their foundations were built around clear boundaries: feature ownership, typed contracts, layered concerns, and explicit navigation hierarchies.

Expo Router doesn't give you all of this for free. But it gives you the right primitives:

  • File-based navigation that enforces structure

  • Nested layouts that separate concerns visually and logically

  • Route groups that create navigation domains

  • Dynamic routes with type-safe params

The rest is discipline. Feature-based architecture is not a framework — it's a decision you make every time you create a new file. The question isn't "what folder does this go in?" It's "who owns this, and how does it change without breaking everything else?"

That's the difference between an app that ships once and an app that ships forever.


Written for the Web Dev Cohort 2026 — Mobile Development Module

Topics covered: Expo Router, React Native architecture, feature-based folder structure, navigation patterns, authentication flow, state management, realtime systems, offline-first, performance optimization, app startup, scalable mobile engineering