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.
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.
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 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.
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 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.
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()
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 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.
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.
Your splash screen is not just branding. It's a window to do work before the user sees the app. The sequence matters:
Show splash screen
Restore auth token from secure storage
Preload critical cached data
Prefetch above-the-fold content
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.
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'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'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'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.
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.
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.
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.
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.
0
1
0