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
17 min read
How Instagram, WhatsApp, Uber & Netflix Would Be Built Today Using Expo Router
S
I write code , that run in the browser and someone else's machine. And sometimes I also write articles

You've built a to-do app. Maybe a weather app. Your folder structure was simple: a screens/ folder, a components/ folder, and everything just worked. Then you try to build something real — something with authentication, real-time data, offline support, nested tabs, and fifty-plus screens — and that simple structure collapses under its own weight.

This article isn't about cloning UI. It's about architecture thinking. We'll use Instagram, WhatsApp, Uber, and Netflix as learning vehicles to understand how production-grade React Native apps would be structured today using Expo Router, and why the decisions behind the folder structure matter far more than the folder structure itself.


Why Simple Folder Structures Fail at Scale

Every React Native tutorial teaches you this:

src/
├── screens/
├── components/
├── utils/
├── hooks/
└── services/

This works until it doesn't. Here's when it breaks:

  • 50+ screens: Your screens/ folder becomes a flat list of unrelated files. HomeScreen.tsx sits next to PaymentScreen.tsx sits next to ChatDetailScreen.tsx. No context. No grouping.

  • Multiple developers: Two engineers editing the same navigation.tsx file creates constant merge conflicts.

  • Cross-cutting features: Where does the "share post" logic live? It's used by Feed, Profile, and Explore. Does it go in utils/? hooks/? services/?

  • Testing and refactoring: Deleting a feature means hunting across six different folders to find every related file.

Production apps need feature-based architecture — where everything related to a feature lives together, and navigation structure reflects the actual user experience.


The Production Architecture Mental Model

Before we dive into specific apps, let's establish the architectural pattern that scales. Modern large-scale React Native apps with Expo Router follow this structure:

app/                          → Navigation layer (Expo Router)
├── _layout.tsx
├── (auth)/
├── (app)/
│   ├── (tabs)/
│   └── ...feature routes

features/                     → Business logic layer
├── feed/
├── chat/
├── player/
│   ├── components/
│   ├── hooks/
│   ├── services/
│   ├── stores/
│   └── types.ts

shared/                       → Shared infrastructure
├── components/
├── hooks/
├── services/
├── stores/
└── utils/

Key principle: The app/ directory handles where things are (navigation). The features/ directory handles what things do (business logic). The shared/ directory handles what everyone needs (common infrastructure).

This separation means a route file in app/ is thin — it imports a feature's screen component and renders it. The route doesn't contain business logic. The feature doesn't contain navigation logic. Each layer has one job.


Instagram: Feeds, Media, and Content Discovery

Instagram is architecturally fascinating because it combines a content feed, stories, reels (short video), direct messaging, search/explore, and a complex profile system — all behind a deceptively simple tab bar.

app/
├── _layout.tsx                      → Root: auth guard + global providers
├── (auth)/
│   ├── _layout.tsx
│   ├── login.tsx
│   ├── register.tsx
│   └── forgot-password.tsx
├── (app)/
│   ├── _layout.tsx                  → App-level stack (modals, shared routes)
│   ├── (tabs)/
│   │   ├── _layout.tsx              → Bottom tab navigator
│   │   ├── (home)/
│   │   │   ├── _layout.tsx          → Home stack
│   │   │   ├── index.tsx            → Main feed
│   │   │   └── comments/[postId].tsx
│   │   ├── (search)/
│   │   │   ├── _layout.tsx
│   │   │   ├── index.tsx            → Explore grid
│   │   │   └── results.tsx
│   │   ├── (reels)/
│   │   │   ├── _layout.tsx
│   │   │   └── index.tsx
│   │   ├── (shop)/
│   │   │   ├── _layout.tsx
│   │   │   ├── index.tsx
│   │   │   └── product/[id].tsx
│   │   └── (profile)/
│   │       ├── _layout.tsx
│   │       ├── index.tsx            → Own profile
│   │       └── edit.tsx
│   ├── user/[username].tsx          → Any user's profile (shared route)
│   ├── post/[id].tsx                → Single post view (shared route)
│   ├── stories/[userId].tsx         → Story viewer (modal)
│   └── camera.tsx                   → Create content (modal)

Why This Structure Works

Notice that user/[username].tsx and post/[id].tsx live outside the tabs. This is intentional. In Instagram, you can view a user's profile from the home feed, from search, from a comment — from anywhere. If the profile screen lived inside (home)/, navigating to it from (search)/ would be awkward.

Expo Router's route groups (parenthesized folders) solve this elegantly. Shared routes sit at the (app) level, accessible from any tab without breaking the tab state.

Feature Layer

features/
├── feed/
│   ├── components/
│   │   ├── PostCard.tsx
│   │   ├── PostActions.tsx
│   │   ├── FeedList.tsx
│   │   └── StoryBar.tsx
│   ├── hooks/
│   │   ├── useFeed.ts               → Infinite scroll + pagination
│   │   ├── useLikePost.ts
│   │   └── useBookmark.ts
│   ├── services/
│   │   └── feedApi.ts                → API calls for feed
│   ├── stores/
│   │   └── feedStore.ts              → Feed state (Zustand/Jotai)
│   └── types.ts
├── stories/
│   ├── components/
│   │   ├── StoryViewer.tsx
│   │   └── StoryProgressBar.tsx
│   ├── hooks/
│   │   └── useStories.ts
│   └── services/
│       └── storiesApi.ts
├── media/
│   ├── components/
│   │   ├── ImagePicker.tsx
│   │   ├── VideoRecorder.tsx
│   │   └── MediaEditor.tsx
│   ├── hooks/
│   │   └── useMediaUpload.ts         → Chunked upload with progress
│   └── services/
│       └── mediaApi.ts

Key Architectural Decisions

Feed Performance: Instagram's feed is an infinite scroll of images and videos. In React Native, this means using FlashList (not FlatList) for virtualized rendering, aggressive image caching with expo-image, and prefetching the next page of content before the user reaches the bottom.

State Management: The feed doesn't need global state. It's server-driven data that changes constantly. This is a perfect use case for React Query (TanStack Query) — it handles caching, background refetching, pagination, and optimistic updates for likes/bookmarks.

// features/feed/hooks/useFeed.ts
export function useFeed() {
  return useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) => feedApi.getFeed(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    staleTime: 1000 * 60 * 2, // 2 minutes before refetch
  });
}

Media Upload: Uploading a photo or video should happen in the background. The user shouldn't wait on a screen watching a progress bar. A background upload service runs independently of the navigation state — this lives in shared/services/backgroundUpload.ts, not in any feature.


WhatsApp: Real-Time Messaging at Scale

WhatsApp's challenge is fundamentally different from Instagram's. Every piece of data is real-time. Messages arrive at any moment. Read receipts update asynchronously. Online status changes constantly. And everything must work offline.

app/
├── _layout.tsx                      → Root layout + auth
├── (auth)/
│   ├── _layout.tsx
│   ├── phone-verify.tsx
│   └── profile-setup.tsx
├── (app)/
│   ├── _layout.tsx
│   ├── (tabs)/
│   │   ├── _layout.tsx              → Top tab navigator (WhatsApp style)
│   │   ├── (chats)/
│   │   │   ├── _layout.tsx
│   │   │   └── index.tsx            → Chat list
│   │   ├── (status)/
│   │   │   ├── _layout.tsx
│   │   │   └── index.tsx
│   │   └── (calls)/
│   │       ├── _layout.tsx
│   │       └── index.tsx
│   ├── chat/[conversationId].tsx    → Individual chat screen
│   ├── contact/[userId].tsx         → Contact info
│   ├── group/
│   │   ├── create.tsx
│   │   └── [groupId]/
│   │       ├── info.tsx
│   │       └── settings.tsx
│   └── call/[callId].tsx            → Active call screen (modal)

The Real-Time Layer

WhatsApp's core challenge is maintaining a persistent connection to the server. This is where architecture decisions have the most impact.

features/
├── messaging/
│   ├── components/
│   │   ├── ChatBubble.tsx
│   │   ├── MessageInput.tsx
│   │   ├── ChatList.tsx
│   │   └── TypingIndicator.tsx
│   ├── hooks/
│   │   ├── useMessages.ts           → Load + subscribe to messages
│   │   ├── useTypingStatus.ts
│   │   └── useSendMessage.ts
│   ├── services/
│   │   ├── messageApi.ts            → REST for history
│   │   └── messageSocket.ts         → WebSocket for realtime
│   ├── stores/
│   │   └── messageStore.ts          → Local message state
│   └── types.ts

shared/
├── services/
│   ├── socketManager.ts             → Single WebSocket connection
│   └── offlineQueue.ts              → Queue messages when offline
├── stores/
│   └── connectionStore.ts           → Online/offline state

Socket Management: The entire app shares ONE WebSocket connection, managed in shared/services/socketManager.ts. Individual features subscribe to events they care about. The messaging feature listens for new messages. The calls feature listens for incoming calls. Neither knows about the other.

// shared/services/socketManager.ts
class SocketManager {
  private socket: WebSocket;
  private listeners: Map<string, Set<Function>> = new Map();

  subscribe(event: string, callback: Function) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(callback);
    return () => this.listeners.get(event)!.delete(callback);
  }

  emit(event: string, data: any) {
    this.socket.send(JSON.stringify({ event, data }));
  }
}

export const socketManager = new SocketManager();

Offline-First Architecture

WhatsApp works without an internet connection. Messages are stored locally, queued for sending, and synced when connectivity returns.

Local Database: Use expo-sqlite or WatermelonDB for local message storage. Every message is written locally first, then synced to the server.

Offline Queue: When the user sends a message without connectivity, it goes into an offline queue with a "pending" status. When the connection resumes, the queue drains automatically.

// shared/services/offlineQueue.ts
class OfflineQueue {
  private queue: PendingAction[] = [];

  enqueue(action: PendingAction) {
    this.queue.push(action);
    this.persistQueue(); // Save to AsyncStorage
  }

  async drain() {
    while (this.queue.length > 0) {
      const action = this.queue[0];
      try {
        await this.execute(action);
        this.queue.shift();
        this.persistQueue();
      } catch {
        break; // Stop if still offline
      }
    }
  }
}

This architectural pattern — optimistic local writes with background sync — is applicable far beyond chat apps. Any app that needs to feel fast and work offline benefits from it.


Uber: Maps, Live Location, and Real-Time Matching

Uber introduces a unique challenge: the entire UI is driven by a state machine. The app transitions through distinct phases — searching for a destination, requesting a ride, waiting for a driver, tracking the ride, completing the trip — and each phase has completely different UI, data needs, and real-time requirements.

app/
├── _layout.tsx
├── (auth)/
│   ├── _layout.tsx
│   ├── phone.tsx
│   └── verify.tsx
├── (app)/
│   ├── _layout.tsx                  → Contains map as persistent background
│   ├── (tabs)/
│   │   ├── _layout.tsx
│   │   ├── index.tsx                → Home (map + destination search)
│   │   ├── activity.tsx             → Ride history
│   │   └── account.tsx              → Settings
│   ├── ride/
│   │   ├── search.tsx               → Destination search (bottom sheet)
│   │   ├── options.tsx              → Ride type selection
│   │   ├── confirm.tsx              → Confirm and request
│   │   ├── matching.tsx             → Finding a driver
│   │   ├── tracking.tsx             → Live ride tracking
│   │   └── receipt/[rideId].tsx     → Trip summary
│   ├── driver/[driverId].tsx        → Driver profile
│   └── support/[rideId].tsx         → Help for a specific ride

The Persistent Map Problem

Uber's most interesting architectural challenge is that the map persists across multiple screens. As the user moves from search to ride options to tracking, the map stays visible underneath, and its state (camera position, markers, route polyline) changes.

This means the map cannot live inside a screen component. It must live in a shared layout:

// app/(app)/_layout.tsx
export default function AppLayout() {
  return (
    <View style={{ flex: 1 }}>
      <MapView style={StyleSheet.absoluteFill} />
      <Slot /> {/* Child routes render on top of the map */}
    </View>
  );
}

Expo Router's Slot component is critical here. Each child route renders as an overlay — typically a bottom sheet — on top of the persistent map. The ride/search.tsx route shows a search bottom sheet. The ride/tracking.tsx route shows the driver's live location and ETA.

Feature Layer with State Machine

features/
├── ride/
│   ├── components/
│   │   ├── DestinationSearch.tsx
│   │   ├── RideOptions.tsx
│   │   ├── DriverCard.tsx
│   │   └── LiveTracker.tsx
│   ├── hooks/
│   │   ├── useRideState.ts          → State machine for ride lifecycle
│   │   ├── useDriverLocation.ts     → Real-time driver position
│   │   └── useETA.ts
│   ├── services/
│   │   ├── rideApi.ts
│   │   └── rideSocket.ts
│   ├── machines/
│   │   └── rideMachine.ts           → XState or Zustand state machine
│   └── types.ts
├── maps/
│   ├── components/
│   │   ├── AppMap.tsx
│   │   ├── RoutePolyline.tsx
│   │   └── DriverMarker.tsx
│   ├── hooks/
│   │   ├── useUserLocation.ts
│   │   └── useGeocoding.ts
│   └── services/
│       └── mapsApi.ts

State Machine: The ride lifecycle is best modeled as a finite state machine. States like idle → searching → requesting → matched → in_progress → completed with clear transitions prevent impossible states (like showing a receipt while the ride is still in progress).

// features/ride/machines/rideMachine.ts
const rideStates = {
  idle: { on: { SEARCH: 'searching' } },
  searching: { on: { SELECT_RIDE: 'requesting' } },
  requesting: { on: { DRIVER_MATCHED: 'matched', TIMEOUT: 'idle' } },
  matched: { on: { RIDE_STARTED: 'in_progress', CANCELLED: 'idle' } },
  in_progress: { on: { RIDE_COMPLETED: 'completed' } },
  completed: { on: { DISMISS: 'idle' } },
};

Live Location Tracking: The driver's location updates every 2-3 seconds via WebSocket. The map smoothly animates the driver marker between positions using interpolation rather than jumping — this is a UI concern that lives in features/maps/components/DriverMarker.tsx.


Netflix: Heavy Content Delivery and Personalization

Netflix's architecture is dominated by content delivery. Thousands of titles, each with metadata, artwork, trailers, multiple audio tracks, and subtitles. The app must feel fast despite loading enormous amounts of data, and the UI is heavily personalized — every row on the home screen is algorithmically generated for each user.

app/
├── _layout.tsx
├── (auth)/
│   ├── _layout.tsx
│   ├── login.tsx
│   └── profile-select.tsx          → "Who's watching?"
├── (app)/
│   ├── _layout.tsx                  → Tab navigator
│   ├── (tabs)/
│   │   ├── _layout.tsx
│   │   ├── index.tsx                → Home (personalized rows)
│   │   ├── new.tsx                  → New & Popular
│   │   ├── search.tsx               → Search
│   │   └── downloads.tsx            → Offline content
│   ├── title/[id].tsx               → Title detail screen
│   ├── player/[id].tsx              → Video player (fullscreen modal)
│   ├── category/[slug].tsx          → Category browse
│   └── profile/
│       ├── index.tsx
│       └── edit.tsx

The Content-Heavy Challenge

Netflix's home screen loads dozens of horizontal rows, each containing 20-40 titles with artwork. Loading everything at once would be catastrophic for performance.

features/
├── catalog/
│   ├── components/
│   │   ├── ContentRow.tsx           → Horizontal scroll of titles
│   │   ├── TitleCard.tsx            → Individual title artwork
│   │   ├── HeroCarousel.tsx         → Featured content at top
│   │   └── CategoryList.tsx
│   ├── hooks/
│   │   ├── useCatalog.ts            → Paginated rows
│   │   ├── useTitleDetail.ts
│   │   └── useSearch.ts
│   ├── services/
│   │   └── catalogApi.ts
│   └── types.ts
├── player/
│   ├── components/
│   │   ├── VideoPlayer.tsx
│   │   ├── PlayerControls.tsx
│   │   ├── SubtitleOverlay.tsx
│   │   └── EpisodePicker.tsx
│   ├── hooks/
│   │   ├── usePlayer.ts
│   │   ├── usePlaybackProgress.ts   → Save/resume position
│   │   └── useAdaptiveStream.ts
│   └── services/
│       └── playerApi.ts
├── downloads/
│   ├── components/
│   │   ├── DownloadList.tsx
│   │   └── DownloadProgress.tsx
│   ├── hooks/
│   │   ├── useDownloads.ts
│   │   └── useDownloadManager.ts
│   └── services/
│       └── downloadService.ts       → Background download management

App Startup Optimization

Netflix must feel instant. Users open the app and expect content immediately. This requires a deliberate startup strategy:

  1. Cached home screen: On first load, show the cached version of the home screen from the last session. Update in the background.

  2. Progressive loading: Load the first 3 rows immediately. Load remaining rows as the user scrolls.

  3. Image prioritization: Load artwork for visible titles first. Use low-quality placeholders for off-screen titles.

  4. Prefetching: When the user hovers on a title, prefetch its detail data and trailer URL.

// features/catalog/hooks/useCatalog.ts
export function useCatalog() {
  return useInfiniteQuery({
    queryKey: ['catalog', profileId],
    queryFn: ({ pageParam = 0 }) => catalogApi.getRows(pageParam),
    getNextPageParam: (last) => last.nextPage,
    // Show stale cache instantly, refresh behind the scenes
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 30,
    placeholderData: keepPreviousData,
  });
}

Offline Downloads

Netflix's download feature is an entire subsystem. It needs to manage download queues, handle partial downloads, track storage space, encrypt content for DRM, and clean up expired downloads. This complexity is why it lives in its own feature module with its own service layer.


Cross-Cutting Architectural Patterns

Across all four apps, several patterns repeat.

Authentication Flow

Every app uses the same Expo Router pattern for auth guards:

// app/_layout.tsx
export default function RootLayout() {
  const { isAuthenticated, isLoading } = useAuth();

  if (isLoading) return <SplashScreen />;

  return (
    <Stack screenOptions={{ headerShown: false }}>
      {isAuthenticated ? (
        <Stack.Screen name="(app)" />
      ) : (
        <Stack.Screen name="(auth)" />
      )}
    </Stack>
  );
}

API Layer Architecture

All four apps separate API calls from UI logic. The pattern is consistent:

shared/
├── services/
│   ├── apiClient.ts          → Axios/fetch wrapper with auth tokens
│   ├── socketManager.ts      → WebSocket for real-time apps
│   └── offlineQueue.ts       → For offline-capable apps

Each feature then has its own services/ folder that uses the shared apiClient:

// features/feed/services/feedApi.ts
import { apiClient } from '@/shared/services/apiClient';

export const feedApi = {
  getFeed: (cursor?: string) => apiClient.get('/feed', { params: { cursor } }),
  likePost: (postId: string) => apiClient.post(`/posts/${postId}/like`),
};

State Management Strategy

Not everything needs a global store. The decision tree is:

Data Type Solution Example
Server data (changes often) React Query / TanStack Query Feed posts, messages, catalog
Client-only UI state Local useState or Zustand Modals, form inputs, filters
Shared app state Zustand or Jotai Auth state, user preferences
Complex workflows State machines (XState) Ride lifecycle, payment flow
Persistent local data SQLite / MMKV Offline messages, downloads

Tradeoffs Teams Make at Scale

Convention vs Flexibility: Expo Router's file-based routing enforces conventions. At scale, this is mostly a benefit — but occasionally you'll fight the file system to achieve a specific navigation pattern. The escape hatch is always available: you can use React Navigation APIs directly inside Expo Router layouts.

Feature isolation vs Code sharing: Keeping features isolated makes them maintainable, but some logic genuinely spans features (like user profiles appearing in feeds, chats, and search). The shared/ layer handles this, but you need discipline to prevent it from becoming a dumping ground.

Performance vs Developer Experience: Using a fully typed, feature-separated architecture adds files and folders. But it reduces bugs, makes onboarding faster, and lets teams work in parallel without stepping on each other.

Offline support vs Complexity: Not every app needs offline-first architecture. Instagram without internet can show cached content. WhatsApp must queue messages. Uber can't function without connectivity. Netflix needs full offline playback. Match the complexity to the actual requirement.


The Takeaway

The architecture of a production mobile app isn't about which folders you create. It's about making decisions that keep the app maintainable when you have 100 screens, 10 developers, and millions of users.

Expo Router gives you the navigation scaffolding — file-based routes, nested layouts, type-safe linking, auth guards. But the real architecture lives in how you separate features, manage state, handle real-time data, and plan for offline usage.

Instagram teaches you about feed performance and media handling. WhatsApp teaches you about real-time systems and offline-first design. Uber teaches you about state machines and persistent UI layers. Netflix teaches you about content delivery and startup optimization.

The patterns are transferable. The architectural thinking scales. And in 2026, Expo Router makes the navigation layer — historically the most painful part — almost disappear so you can focus on what actually matters: building the product.