Pratik Jadhav

May 17, 2026 • 14 min read

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

A deep dive into feature-based architecture, scalable navigation, and production engineering patterns using Expo Router

Every developer has that moment. You start a React Native project, create a screens/ folder, drop in a few components, wire up React Navigation, and everything feels great. Clean. Organized. Under control.

Six months later, you have 40 screens, 3 developers fighting over the same files, a navigation file that's 600 lines long, and a codebase nobody wants to touch. You didn't write bad code. You wrote small-app code in a large-app problem.

This is not a tutorial on how to clone Instagram or Uber. This is about how engineers at that scale think about architecture before they write a single line of UI. And how Expo Router, combined with the right patterns, gives you a foundation that doesn't collapse under its own weight.


Why Simple Folder Structures Fail at Scale

Most React Native projects start with something like this:

/screens
 HomeScreen.tsx
 ProfileScreen.tsx
 ChatScreen.tsx
/components
 Button.tsx
 Avatar.tsx
/utils
 formatDate.ts

This works perfectly for two screens. It starts breaking at ten. By twenty, it's a maintenance nightmare.

The problem is that this structure is organized by file type, not by feature. When you work on the chat feature, your files are spread across screens/, components/, utils/, hooks/, and store/. Every change requires you to jump across the entire codebase. There's no clear boundary. No ownership. No way to know what's safe to delete.

Large engineering teams don't organize code this way. They organize it by domain, because that's how teams own it, ship it, and scale it independently.


The Mental Shift: Small-App Thinking vs Production Engineering Thinking

Small-App Thinking Production Engineering Thinking Organize by file type Organize by feature/domain One navigation file Nested, modular navigation Global state for everything Layered state management Fetch directly in components Dedicated API and cache layers Works now Works at 10x scale

The goal of architecture is not to write more code. It's to make sure the code you write remains easy to change, delete, and hand off to another developer six months later.


Expo Router: The Architecture-First Navigation Layer

Expo Router brings file-system based routing to React Native, borrowed from the mental model of Next.js. The navigation structure is your folder structure. This is a bigger deal than it sounds.

In traditional React Navigation setups, navigation is a configuration problem. You declare routes somewhere, link them to components, and manage the relationship manually. As the app grows, that configuration grows with it and becomes its own maintenance burden.

With Expo Router, your file structure is your navigation. Add a file, get a route. Nest a folder, get nested navigation. This forces good structure because the architecture and navigation live in the same place.

app/
 (auth)/
 login.tsx
 signup.tsx
 (tabs)/
 index.tsx → Home feed
 search.tsx
 notifications.tsx
 profile.tsx
 (chat)/
 index.tsx → Conversation list
 [id].tsx → Individual chat
 _layout.tsx

The parentheses notation (auth) and (tabs) create route groups without adding to the URL path. This is how you separate authentication flows from main app flows, or tab navigation from modal flows, without polluting your route names.


Feature-Based Architecture: The Foundation Everything Else Sits On

Before thinking about navigation, think about how you split your app into independent modules.

A feature-based structure looks like this:

src/
 features/
 feed/
 components/
 FeedPost.tsx
 FeedStory.tsx
 hooks/
 useFeed.ts
 useInfiniteScroll.ts
 api/
 feedApi.ts
 store/
 feedSlice.ts
 index.ts → Public API of this feature
 
 messaging/
 components/
 hooks/
 api/
 store/
 index.ts
 
 auth/
 components/
 hooks/
 api/
 store/
 index.ts
 
 shared/
 components/
 hooks/
 utils/
 constants/
 
 app/ → Expo Router pages (thin shells only)

The key discipline here is that app/ pages are thin. They import from features. They don't contain logic. A page file should mostly look like this:

// app/(tabs)/index.tsx
import { FeedScreen } from '@/features/feed'

export default function HomeTab() {
 return <FeedScreen />
}

All the business logic, data fetching, and state management lives inside the feature module. The Expo Router file is just the routing entry point.


Authentication Flow Architecture

Authentication is where many apps have their messiest code. Mixing auth checks with navigation logic, scattered token handling, inconsistent redirect behavior. Getting this right early matters a lot.

With Expo Router, a clean auth architecture looks like this:

app/
 (auth)/
 _layout.tsx → Auth group layout (redirect if already logged in)
 login.tsx
 signup.tsx
 forgot-password.tsx
 
 (app)/
 _layout.tsx → Protected layout (redirect if not logged in)
 (tabs)/
 _layout.tsx
 index.tsx

In (app)/_layout.tsx, you check authentication state and redirect:

import { Redirect, Stack } from 'expo-router'
import { useAuth } from '@/features/auth'

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

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

 return <Stack />
}

This pattern means authentication is a routing concern handled in layout files, not scattered across individual screens. Every protected route automatically inherits the auth check. Adding a new protected screen requires zero additional auth logic.


State Management at Scale

The mistake most apps make is reaching for a single global store for everything. Redux or Zustand for the entire application state. This works until you have 15 features each dumping into the same store, and debugging requires understanding the entire application to change one thing.

A more scalable approach uses layered state:

Server state — Data that lives on your backend and needs to be cached, synchronized, and refetched. React Query (TanStack Query) or SWR handles this. This layer manages loading states, caching, background refetching, and optimistic updates without you writing that logic yourself.

Global client state — Authentication status, user preferences, theme. Zustand is excellent here. Small, minimal, not overused.

Local component state — UI state that doesn't need to leave the component. useState and useReducer. Prefer this by default and only lift state when you genuinely need to share it.

URL state — Filters, tabs, selected items. Expo Router lets you read and write search params. Underused but powerful for state that should survive navigation and deep linking.

// Server state - React Query
const { data: feed, isLoading } = useQuery({
 queryKey: ['feed', userId],
 queryFn: () => feedApi.getFeed(userId),
 staleTime: 30_000,
})

// Global state - Zustand
const theme = useAppStore(state => state.theme)

// URL state - Expo Router
const { filters } = useLocalSearchParams()

API Handling and the Networking Layer

Direct fetch calls in components are a trap. When your base URL changes, when you need to add auth headers globally, when you want to add retry logic or request interceptors, you end up touching every component.

A dedicated API layer separates these concerns:

src/
 lib/
 api/
 client.ts → Base axios/fetch instance, interceptors, auth headers
 types.ts → Shared API response types
 
 features/
 feed/
 api/
 feedApi.ts → Feed-specific endpoints, typed responses

Your client.ts handles the cross-cutting concerns once:

// lib/api/client.ts
const apiClient = axios.create({
 baseURL: process.env.EXPO_PUBLIC_API_URL,
 timeout: 10_000,
})

apiClient.interceptors.request.use(async (config) => {
 const token = await getAuthToken()
 if (token) config.headers.Authorization = `Bearer ${token}`
 return config
})

apiClient.interceptors.response.use(
 response => response,
 async error => {
 if (error.response?.status === 401) {
 await refreshToken()
 return apiClient(error.config)
 }
 return Promise.reject(error)
 }
)

Feature-specific API files just call the client:

// features/feed/api/feedApi.ts
export const feedApi = {
 getFeed: (userId: string) =>
 apiClient.get<FeedResponse>(`/feed/${userId}`),
 
 likePost: (postId: string) =>
 apiClient.post(`/posts/${postId}/like`),
}

This is maintainable. This is testable. This scales.


Realtime Systems: Chat, Live Updates, and Ride Tracking

Realtime is where apps like WhatsApp and Uber have their hardest architectural problems. WebSocket connections that need to survive app backgrounding, reconnect after network drops, and deliver messages reliably.

Chat architecture (WhatsApp model)

The key insight is separating the WebSocket connection lifecycle from the UI. The connection should live at the application level, not inside a chat screen component that unmounts when the user navigates away.

features/
 messaging/
 lib/
 socketManager.ts → Singleton WebSocket manager
 hooks/
 useSocket.ts → Subscribe to events in components
 useMessages.ts → Message state with optimistic updates
 store/
 messagesStore.ts → Zustand store for message cache

The socket manager is initialized when the app loads and stays alive. Components subscribe to events through hooks and receive updates reactively.

Optimistic updates are critical for perceived performance in chat. When a user sends a message, show it immediately in the UI with a "sending" state. Confirm or rollback based on server response. This is the difference between an app that feels instant and one that feels sluggish.

Live location tracking (Uber model)

Ride tracking has unique constraints: high-frequency location updates, battery efficiency, background location permissions, and a map that needs to reflect the driver's position smoothly.

features/
 ride/
 lib/
 locationStream.ts → WebSocket subscription for driver updates
 hooks/
 useDriverLocation.ts
 useRideTracking.ts
 components/
 RideMap.tsx → Animated marker, route polyline

The location stream receives driver coordinates and updates the map marker with animation. The smoothness comes from interpolating between received coordinates rather than jumping the marker to each new position.

For the driver app side, location is sent using a background task that runs even when the app is backgrounded, combined with throttling to balance accuracy against battery drain.


Offline-First Support and Caching

Instagram works on a slow connection. WhatsApp delivers your messages even when the recipient is offline. These are not accidents. They're architectural decisions made early.

Offline-first means designing your data flow to work from a local cache first, and sync with the server when possible.

React Query gives you caching out of the box, but for true offline support you need persistence. MMKV (fast key-value store) or SQLite-backed persistence means cached data survives app restarts.

// Persist React Query cache to MMKV
const mmkvStorage = new MMKV()

const queryClient = new QueryClient({
 defaultOptions: {
 queries: {
 staleTime: 5 * 60 * 1000, // 5 minutes
 cacheTime: 24 * 60 * 60 * 1000, // 24 hours
 }
 }
})

persistQueryClient({
 queryClient,
 persister: createMMKVStoragePersister({ storage: mmkvStorage }),
})

For actions taken offline (sending a message, liking a post), you need an outbox pattern: queue actions locally, process them when connectivity returns.


App Startup Optimization

Your splash screen is not just branding. It's a window to do work before the user sees the app. The sequence matters:

  1. Show splash screen

  2. Restore auth token from secure storage

  3. Preload critical cached data

  4. Prefetch above-the-fold content

  5. Hide splash screen

This means the home feed has data ready before the user sees it. The perceived load time drops to near zero.

// app/_layout.tsx
export default function RootLayout() {
 const [appReady, setAppReady] = useState(false)

 useEffect(() => {
 async function prepare() {
 await SplashScreen.preventAutoHideAsync()
 await restoreAuthState()
 await prefetchCriticalData()
 setAppReady(true)
 await SplashScreen.hideAsync()
 }
 prepare()
 }, [])

 if (!appReady) return null
 return <Stack />
}

Beyond startup, lazy loading screens that aren't immediately needed reduces the initial bundle. Expo Router supports lazy loading by default for routes not in the critical path.


How Each App Maps to These Patterns

Instagram: Feeds and Media

Instagram's core challenge is rendering a high-performance feed of mixed media (images, videos, carousels) while maintaining smooth scroll, handling infinite pagination, and running story preloading in the background.

The feed feature would use React Query's useInfiniteQuery for pagination, FlashList instead of FlatList for significantly better performance with large lists, and an image/video preloader that loads the next few items before the user scrolls to them.

The architecture separates stories, feed posts, and reels into their own feature modules, each with independent data fetching and caching. The tab layout in Expo Router keeps the home tab mounted (preventing re-fetches on tab switch) while lazy loading search, notifications, and profile.

WhatsApp: Realtime Messaging

WhatsApp's architecture is driven by messaging reliability. Messages must be delivered exactly once, in order, even across reconnections.

The socket manager maintains a persistent connection and implements a message acknowledgment system. Unacknowledged messages are retried. Delivered messages are never sent twice. The conversation list uses Zustand to maintain message state in memory, synchronized to SQLite for offline access.

Expo Router's dynamic routes handle individual conversations cleanly:

app/(chat)/[conversationId].tsx

Each conversation screen mounts, subscribes to that conversation's message stream, and unmounts cleanly when the user navigates away, while the underlying socket connection stays alive.

Uber: Maps and Live Location

Uber's mobile architecture is a coordination problem between the rider app, driver app, and backend. The rider needs to see the driver moving in real-time. The driver needs to receive and acknowledge ride requests.

The map feature wraps react-native-maps with a location stream that receives WebSocket updates and animates the driver marker. The ride request flow uses Expo Router's modal presentation to show incoming requests without losing the current map context:

app/
 (ride)/
 map.tsx → Main map view
 _layout.tsx
 request-modal.tsx → Presented as modal over map

Background location for the driver side uses Expo's TaskManager to register a background fetch task, ensuring location continues to be sent even when the driver switches apps.

Netflix: Content Delivery and Heavy Media

Netflix's challenge is content discoverability at scale combined with smooth video playback across network conditions. The home screen has multiple independently scrolling rows, each with its own data source. Loading all of them at once would be slow. Loading them lazily as they come into view is the right approach.

The content architecture separates discovery (browse, search, recommendations) from playback (streaming, downloads, continue watching). These are different domains with different data requirements and update frequencies.

Downloads for offline viewing need background task support and a local database to track download state. Netflix's "save for later" pattern requires a download queue, progress tracking, and storage management, all of which live in a dedicated downloads feature module.


Shared Layouts and Nested Routing

One of Expo Router's most powerful features is the ability to share layouts across routes without re-rendering them. The tab bar stays mounted. The header stays mounted. Only the content changes.

This is implemented through nested _layout.tsx files:

app/
 _layout.tsx → Root: fonts, theme, providers
 (app)/
 _layout.tsx → Auth check
 (tabs)/
 _layout.tsx → Tab bar
 index.tsx → Tab 1
 search.tsx → Tab 2
 profile/
 _layout.tsx → Profile header (shared across profile routes)
 index.tsx
 followers.tsx
 following.tsx

The profile header stays mounted as users navigate between profile, followers, and following. This is impossible to get right cleanly with React Navigation without significant workarounds. With Expo Router, it's the natural behavior.


Scalability Challenges and Tradeoffs

There is no perfect architecture. Every decision involves a tradeoff.

Monorepo vs separate apps. Instagram and WhatsApp (both Meta) can share a design system, authentication flows, and utility libraries through a monorepo. The tooling complexity increases, but the code sharing is significant. For most teams building a single app, this is premature.

Real-time WebSockets vs polling. WebSockets give you instant updates but require connection management, reconnection logic, and infrastructure that supports long-lived connections. Polling is simpler and works everywhere. Choose based on how real-time your use case actually needs to be.

Optimistic updates vs server confirmation. Optimistic updates feel fast but require rollback logic for failures. In most consumer apps, the tradeoff is worth it. In financial transactions, confirm before rendering.

Feature module boundaries. Strict feature separation means less shared code. Some features genuinely need to talk to each other. The discipline is to keep those dependencies explicit and one-directional, avoiding circular dependencies between feature modules.

TypeScript strictness. Stricter TypeScript catches more bugs at compile time but slows down initial development. At scale, the investment pays back many times over. Turn it on from day one.


The Principles That Actually Matter

When you strip away the specifics of Instagram's feed algorithm or Uber's matching system, the engineering principles are consistent:

Separation of concerns. Navigation, data fetching, business logic, and UI are different concerns. They should live in different places and not know about each other more than necessary.

Feature ownership. Code should be organized so a team can own a feature end-to-end without needing to understand the whole codebase.

Explicit dependencies. Features should expose a public API through their index.ts. Other features import from that public API, not from internal files. This is how you maintain boundaries as the codebase grows.

Defer complexity. Don't add a WebSocket layer until you need realtime. Don't add a download manager until you need offline. But structure your code so adding these later doesn't require rewriting what exists.

Performance is a feature. FlashList over FlatList. MMKV over AsyncStorage. Lazy loading, image preloading, startup optimization. These aren't premature optimizations when you know you're building at scale. They're decisions you make in the architecture phase that are expensive to retrofit.


Closing Thoughts

Instagram, WhatsApp, Uber, and Netflix were not built with perfect architecture from day one. They were built iteratively by engineers who understood their problem domain well, made deliberate tradeoffs, and refactored aggressively as they learned.

What Expo Router gives you is a foundation that naturally encourages the right patterns: file-system routing that maps directly to navigation structure, nested layouts that keep your UI efficient, and a mental model borrowed from the web's most mature routing ecosystem.

The folder structure you choose on day one will either help or fight you on day three hundred. Feature-based architecture, a clean API layer, layered state management, and Expo Router's nested layouts are the building blocks that scale.

Write the code you'd want to find when you join the team six months from now.

Join Pratik on Peerlist!

Join amazing folks like Pratik and thousands of other builders on Peerlist.

peerlist.io/

It’s available... this username is available! 😃

Claim your username before it's too late!

This username is already taken, you’re a little late.😐

0

1

0