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

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.tsxsits next toPaymentScreen.tsxsits next toChatDetailScreen.tsx. No context. No grouping.Multiple developers: Two engineers editing the same
navigation.tsxfile 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.
Navigation Architecture
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.
Navigation Architecture
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.
Navigation Architecture
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.
Navigation Architecture
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:
Cached home screen: On first load, show the cached version of the home screen from the last session. Update in the background.
Progressive loading: Load the first 3 rows immediately. Load remaining rows as the user scrolls.
Image prioritization: Load artwork for visible titles first. Use low-quality placeholders for off-screen titles.
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.





