How Instagram, WhatsApp, Uber & Netflix Would Be Built Today Using Expo Router
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:
Route groups
(auth)and(app)let you apply different layouts to different navigation contexts without polluting the URL/pathDynamic routes
[postId].tsxare typed and composableLayout 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.
Navigation Architecture for Scalable Apps
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:
Served from a CDN closest to the user
Progressively loaded (blur hash → thumbnail → full)
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
FlashListwith a discriminated union item typeEach 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 → completedETA 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