A mobile app is not a page that happens to be narrow. It has a navigation stack, a tab bar, deep links, a keychain, background and foreground transitions, and a network that comes and goes. The client architecture has to reflect that.
This lesson builds the app shell for a MERN-backed React Native app: file-based navigation with Expo Router, authenticated route groups, server-state caching with TanStack Query, tokens in secure storage, and useful offline states.
Navigation is a tree of routes
Expo Router maps files to routes and uses native navigation primitives underneath:
app/
├── _layout.tsx # providers and root stack
├── (auth)/
│ ├── _layout.tsx
│ ├── sign-in.tsx # /sign-in
│ └── sign-up.tsx # /sign-up
├── (app)/
│ ├── _layout.tsx # requires a session
│ ├── (tabs)/
│ │ ├── _layout.tsx # tab bar
│ │ ├── index.tsx # /(tabs)
│ │ └── watchlist.tsx # /(tabs)/watchlist
│ └── movies/
│ └── [id].tsx # /movies/:id
└── +not-found.tsxRoute groups in parentheses do not appear in the URL. They are a way to apply a layout — such as “requires authentication” — to a set of screens.
// app/(app)/_layout.tsx
import { Redirect, Stack } from 'expo-router'
import { useSession } from '@/lib/auth/use-session'
export default function AppLayout() {
const { session, isLoading } = useSession()
if (isLoading) return null
if (!session) return <Redirect href="/sign-in" />
return <Stack screenOptions={{ headerShown: false }} />
}This is a client-side guard for navigation. It does not replace server authorization. The API must still reject every request that lacks a valid token, and every sensitive query must be scoped to the authenticated user.
A mobile redirect is a user-experience decision. The security boundary is the API and its database queries. Never ship an app that hides a screen and assumes the backend will trust it.
Linking to a screen
import { Link, useLocalSearchParams } from 'expo-router'
import { Text, View } from 'react-native'
export function MovieCard({ movie }: { movie: { id: string; title: string } }) {
return (
<Link href={{ pathname: '/movies/[id]', params: { id: movie.id } }} asChild>
<Text accessibilityRole="link">{movie.title}</Text>
</Link>
)
}
export default function MovieScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
return <View>{/* fetch and render movie id */}</View>
}Deep links work when the app is installed and when it is opened from a URL, provided the scheme and associated domains are configured. Test a cold start, not just navigation from inside the app.
The provider stack
A root layout composes long-lived providers once:
// app/_layout.tsx
import { Stack } from 'expo-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { SessionProvider } from '@/lib/auth/session-provider'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 2,
},
},
})
export default function RootLayout() {
return (
<SafeAreaProvider>
<QueryClientProvider client={queryClient}>
<SessionProvider>
<Stack />
</SessionProvider>
</QueryClientProvider>
</SafeAreaProvider>
)
}Keep the query client outside the component or in a stable ref. Creating a new QueryClient on every render destroys the cache and refetches everything.
Server state with TanStack Query
Mobile apps need explicit server-state handling: cached lists, background refresh, retries, and mutations that survive a slow connection. TanStack Query is a strong fit and works the same way as on the web, with two mobile-specific integrations.
// lib/query.ts
import NetInfo from '@react-native-community/netinfo'
import { AppState } from 'react-native'
import { focusManager, onlineManager } from '@tanstack/react-query'
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(Boolean(state.isConnected))
})
})
focusManager.setEventListener((handleFocus) => {
const subscription = AppState.addEventListener('change', (status) => {
handleFocus(status === 'active')
})
return () => subscription.remove()
})Without these, TanStack Query thinks the app is always online and always focused. It will not pause retries when the device is offline, and it will not refresh stale data when the user returns to the app.
A query and a mutation
// lib/api/movies.ts
import { apiFetch } from '@/lib/api/client'
export type Movie = { id: string; title: string; year: number }
export async function fetchMovies(): Promise<Movie[]> {
const response = await apiFetch('/api/movies')
if (!response.ok) throw new Error('Could not load movies')
return response.json()
}
export async function saveMovie(movie: Movie): Promise<Movie> {
const response = await apiFetch('/api/movies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(movie),
})
if (!response.ok) throw new Error('Could not save movie')
return response.json()
}import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { FlatList, Text } from 'react-native'
import { fetchMovies, saveMovie } from '@/lib/api/movies'
export function MoviesScreen() {
const queryClient = useQueryClient()
const movies = useQuery({ queryKey: ['movies'], queryFn: fetchMovies })
const createMovie = useMutation({
mutationFn: saveMovie,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['movies'] })
},
})
if (movies.isPending) return <Text>Loading movies…</Text>
if (movies.isError) return <Text>Could not load movies.</Text>
return (
<FlatList
data={movies.data}
keyExtractor={(movie) => movie.id}
renderItem={({ item }) => <Text>{item.title}</Text>}
ListEmptyComponent={<Text>No movies yet.</Text>}
/>
)
}invalidateQueries marks the list stale and refetches it. That is usually enough. For a list that must update instantly, use optimistic updates and roll back on error:
const createMovie = useMutation({
mutationFn: saveMovie,
onMutate: async (newMovie) => {
await queryClient.cancelQueries({ queryKey: ['movies'] })
const previous = queryClient.getQueryData<Movie[]>(['movies'])
queryClient.setQueryData<Movie[]>(['movies'], (current = []) => [
...current,
{ ...newMovie, id: `optimistic-${Date.now()}` },
])
return { previous }
},
onError: (_error, _movie, context) => {
if (context?.previous) queryClient.setQueryData(['movies'], context.previous)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['movies'] }),
})Optimistic UI is not a substitute for server confirmation. It is a way to keep the interface responsive while the server is the source of truth.
Tokens belong in SecureStore
AsyncStorage is plain, unencrypted storage. Do not put access tokens or refresh tokens there.
// lib/auth/token-store.ts
import * as SecureStore from 'expo-secure-store'
const ACCESS_KEY = 'access_token'
const REFRESH_KEY = 'refresh_token'
export async function saveTokens(accessToken: string, refreshToken: string) {
await SecureStore.setItemAsync(ACCESS_KEY, accessToken)
await SecureStore.setItemAsync(REFRESH_KEY, refreshToken)
}
export async function getAccessToken() {
return SecureStore.getItemAsync(ACCESS_KEY)
}
export async function clearTokens() {
await Promise.all([
SecureStore.deleteItemAsync(ACCESS_KEY),
SecureStore.deleteItemAsync(REFRESH_KEY),
])
}SecureStore uses the iOS Keychain and Android Keystore-backed storage. It is still not a place for secrets you would not store on a device at all; any token can be used by someone who controls a compromised device. Keep token lifetimes short and rotate refresh tokens.
The API client
// lib/api/client.ts
import { getAccessToken } from '@/lib/auth/token-store'
const API_URL = process.env.EXPO_PUBLIC_API_URL
export async function apiFetch(path: string, init: RequestInit = {}) {
const token = await getAccessToken()
const headers = new Headers(init.headers)
headers.set('Accept', 'application/json')
if (token) headers.set('Authorization', `Bearer ${token}`)
return fetch(`${API_URL}${path}`, { ...init, headers })
}Do not hardcode an API URL in production. Use an Expo public environment variable for the base URL and validate that it is HTTPS outside development. The next lesson handles refresh rotation and forced logout.
Network state and offline UI
A mobile connection is not binary. It can be fast, slow, captive, or technically connected but unreachable. Show honest states:
import NetInfo from '@react-native-community/netinfo'
import { useEffect, useState } from 'react'
import { Text, View } from 'react-native'
export function OfflineBanner() {
const [online, setOnline] = useState(true)
useEffect(() => {
return NetInfo.addEventListener((state) => {
setOnline(Boolean(state.isConnected && state.isInternetReachable !== false))
})
}, [])
if (online) return null
return (
<View accessibilityRole="alert">
<Text>You are offline. Showing saved data.</Text>
</View>
)
}Pair the banner with a cached query result. Do not show a spinner forever when the device is offline and the cache has data. isPending plus data tells you whether you have something useful to render.
Native capabilities need permissions
Camera, photos, notifications, and location all require a permission prompt and a graceful denial path. Request permission at the moment of use, explain why, and keep the app usable if the user says no.
import * as ImagePicker from 'expo-image-picker'
export async function pickPoster() {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync()
if (!permission.granted) {
return { canceled: true as const, reason: 'permission' as const }
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.8,
})
if (result.canceled) return { canceled: true as const }
return { canceled: false as const, uri: result.assets[0].uri }
}Upload the file to a presigned URL or a multipart endpoint; do not base64-encode a 10 MB image into JSON. Compress before upload and show progress for large files.
Build the authenticated shell
Create an Expo Router app with:
- A root layout that provides TanStack Query, safe areas, and the session provider.
- An
(auth)group with sign-in, and an(app)group that redirects unauthenticated users. - A tab navigator with a movie list and a watchlist.
- A movie detail route using a typed dynamic parameter.
- SecureStore-backed token storage and an API client that adds the bearer token.
- An offline banner wired to NetInfo and a cached list that remains readable without a connection.
Verify that a deep link to a movie opens the correct screen after a cold start.
Mobile navigation is a route tree with platform behavior. Keep providers at the root, use query caching for server state, store tokens in SecureStore, and treat offline as a normal state rather than an error. The client can feel responsive; the server remains the authority.