Introduction
Navigation is the skeleton of your app. Get it right, and users flow effortlessly through your product. Get it wrong, and they get lost, frustrated, and eventually leave.
Cross-platform frameworks add complexity: you need navigation that feels native on both iOS and Android while sharing as much code as possible. React Native Navigation and Flutter’s Navigator have matured significantly, but choosing the right patterns for your app still requires careful thought.
This guide covers navigation patterns that work across platforms, from basic stack navigation to complex authentication flows and deep linking.
Navigation Architecture Overview
The Navigation Stack Model
┌─────────────────────────────────────────────────────┐
│ Root Navigator │
│ ┌───────────────────────────────────────────────┐ │
│ │ Auth Navigator │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │ │
│ │ │ Sign In │→│ Sign Up │→│ Forgot Password │ │ │
│ │ └─────────┘ └─────────┘ └─────────────────┘ │ │
│ └───────────────────────────────────────────────┘ │
│ ↓ │
│ ┌───────────────────────────────────────────────┐ │
│ │ Main Navigator (Tabs) │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │ │
│ │ │ Home │ │ Search │ │ Profile │ │ │
│ │ │ Stack │ │ Stack │ │ Stack │ │ │
│ │ └─────────┘ └─────────┘ └─────────────────┘ │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
React Native Navigation with React Navigation

Installation and Setup
// Install dependencies
// npm install @react-navigation/native @react-navigation/native-stack
// npm install @react-navigation/bottom-tabs
// npm install react-native-screens react-native-safe-area-context
// navigation/types.ts
export type RootStackParamList = {
Auth: undefined;
Main: undefined;
ProductDetail: { productId: string };
Checkout: { cartId: string };
};
export type AuthStackParamList = {
SignIn: undefined;
SignUp: undefined;
ForgotPassword: { email?: string };
};
export type MainTabParamList = {
Home: undefined;
Search: undefined;
Cart: undefined;
Profile: undefined;
};
export type HomeStackParamList = {
HomeScreen: undefined;
Category: { categoryId: string; title: string };
ProductList: { query: string };
};
// Typed navigation hooks
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}
Root Navigator
// navigation/RootNavigator.tsx
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useAuth } from '../hooks/useAuth';
const Stack = createNativeStackNavigator<RootStackParamList>();
export function RootNavigator() {
const { isAuthenticated, isLoading } = useAuth();
const linking = useLinking();
if (isLoading) {
return <SplashScreen />;
}
return (
<NavigationContainer
linking={linking}
fallback={<LoadingScreen />}
onStateChange={(state) => {
// Analytics tracking
const currentRoute = getActiveRoute(state);
analytics.trackScreen(currentRoute.name, currentRoute.params);
}}
>
<Stack.Navigator screenOptions={{ headerShown: false }}>
{isAuthenticated ? (
<>
<Stack.Screen name="Main" component={MainNavigator} />
<Stack.Screen
name="ProductDetail"
component={ProductDetailScreen}
options={{
headerShown: true,
headerTransparent: true,
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="Checkout"
component={CheckoutScreen}
options={{
presentation: 'modal',
gestureEnabled: false,
}}
/>
</>
) : (
<Stack.Screen name="Auth" component={AuthNavigator} />
)}
</Stack.Navigator>
</NavigationContainer>
);
}
// Helper to get active route for analytics
function getActiveRoute(state: NavigationState): Route {
const route = state.routes[state.index];
if (route.state) {
return getActiveRoute(route.state as NavigationState);
}
return route;
}
Tab Navigator
// navigation/MainNavigator.tsx
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const Tab = createBottomTabNavigator<MainTabParamList>();
export function MainNavigator() {
const insets = useSafeAreaInsets();
const cartItemCount = useCartItemCount();
return (
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: false,
tabBarIcon: ({ focused, color, size }) => {
const icons: Record<keyof MainTabParamList, string> = {
Home: 'home',
Search: 'search',
Cart: 'shopping-cart',
Profile: 'user',
};
return (
<Icon
name={icons[route.name]}
size={size}
color={color}
filled={focused}
/>
);
},
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: '#8E8E93',
tabBarStyle: {
paddingBottom: insets.bottom,
height: 49 + insets.bottom,
},
})}
>
<Tab.Screen name="Home" component={HomeStack} />
<Tab.Screen name="Search" component={SearchStack} />
<Tab.Screen
name="Cart"
component={CartStack}
options={{
tabBarBadge: cartItemCount > 0 ? cartItemCount : undefined,
}}
/>
<Tab.Screen name="Profile" component={ProfileStack} />
</Tab.Navigator>
);
}
Deep Linking Configuration
// navigation/linking.ts
import { LinkingOptions } from '@react-navigation/native';
import { Linking } from 'react-native';
export function useLinking(): LinkingOptions<RootStackParamList> {
return {
prefixes: [
'myapp://',
'https://myapp.com',
'https://www.myapp.com',
],
config: {
screens: {
Auth: {
screens: {
SignIn: 'login',
SignUp: 'signup',
ForgotPassword: 'reset-password',
},
},
Main: {
screens: {
Home: {
screens: {
HomeScreen: '',
Category: 'category/:categoryId',
ProductList: 'products',
},
},
Search: 'search',
Cart: 'cart',
Profile: {
screens: {
ProfileScreen: 'profile',
Settings: 'settings',
Orders: 'orders',
},
},
},
},
ProductDetail: {
path: 'product/:productId',
parse: {
productId: (productId: string) => productId,
},
},
Checkout: 'checkout/:cartId',
},
},
async getInitialURL() {
// Handle deep link that opened the app
const url = await Linking.getInitialURL();
if (url != null) {
return url;
}
// Handle push notification deep link
const notification = await getInitialNotification();
if (notification?.data?.deepLink) {
return notification.data.deepLink;
}
return null;
},
subscribe(listener) {
// Handle links while app is running
const linkingSubscription = Linking.addEventListener('url', ({ url }) => {
listener(url);
});
// Handle push notification links
const notificationSubscription = onNotificationOpenedApp((notification) => {
if (notification.data?.deepLink) {
listener(notification.data.deepLink);
}
});
return () => {
linkingSubscription.remove();
notificationSubscription();
};
},
};
}
Flutter Navigation
with GoRouter
Setup and Configuration
// lib/navigation/router.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'router.g.dart';
@riverpod
GoRouter router(RouterRef ref) {
final authState = ref.watch(authProvider);
return GoRouter(
initialLocation: '/',
debugLogDiagnostics: true,
refreshListenable: authState,
redirect: (context, state) {
final isAuthenticated = authState.isAuthenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isAuthenticated && !isAuthRoute) {
// Redirect to login, preserving intended destination
return '/auth/login?redirect=${state.matchedLocation}';
}
if (isAuthenticated && isAuthRoute) {
// Already logged in, go to intended destination or home
final redirect = state.uri.queryParameters['redirect'];
return redirect ?? '/';
}
return null; // No redirect needed
},
routes: [
// Auth routes
GoRoute(
path: '/auth',
redirect: (_, __) => '/auth/login',
routes: [
GoRoute(
path: 'login',
builder: (context, state) => const SignInScreen(),
),
GoRoute(
path: 'signup',
builder: (context, state) => const SignUpScreen(),
),
GoRoute(
path: 'forgot-password',
builder: (context, state) {
final email = state.uri.queryParameters['email'];
return ForgotPasswordScreen(initialEmail: email);
},
),
],
),
// Main app with shell (tabs)
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [
GoRoute(
path: '/',
pageBuilder: (context, state) => const NoTransitionPage(
child: HomeScreen(),
),
routes: [
GoRoute(
path: 'category/:categoryId',
builder: (context, state) {
final categoryId = state.pathParameters['categoryId']!;
final title = state.uri.queryParameters['title'] ?? 'Category';
return CategoryScreen(
categoryId: categoryId,
title: title,
);
},
),
],
),
GoRoute(
path: '/search',
pageBuilder: (context, state) => const NoTransitionPage(
child: SearchScreen(),
),
),
GoRoute(
path: '/cart',
pageBuilder: (context, state) => const NoTransitionPage(
child: CartScreen(),
),
),
GoRoute(
path: '/profile',
pageBuilder: (context, state) => const NoTransitionPage(
child: ProfileScreen(),
),
routes: [
GoRoute(
path: 'settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: 'orders',
builder: (context, state) => const OrdersScreen(),
),
],
),
],
),
// Modal routes (outside shell)
GoRoute(
path: '/product/:productId',
builder: (context, state) {
final productId = state.pathParameters['productId']!;
return ProductDetailScreen(productId: productId);
},
),
GoRoute(
path: '/checkout/:cartId',
pageBuilder: (context, state) {
final cartId = state.pathParameters['cartId']!;
return MaterialPage(
fullscreenDialog: true,
child: CheckoutScreen(cartId: cartId),
);
},
),
],
errorBuilder: (context, state) => ErrorScreen(error: state.error),
);
}
Shell with Bottom Navigation
// lib/navigation/main_shell.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class MainShell extends StatelessWidget {
final Widget child;
const MainShell({required this.child, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: const MainBottomNav(),
);
}
}
class MainBottomNav extends StatelessWidget {
const MainBottomNav({super.key});
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
final cartItemCount = context.watch<CartProvider>().itemCount;
return NavigationBar(
selectedIndex: _calculateSelectedIndex(location),
onDestinationSelected: (index) => _onItemTapped(context, index),
destinations: [
const NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
const NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
label: 'Search',
),
NavigationDestination(
icon: Badge(
isLabelVisible: cartItemCount > 0,
label: Text('$cartItemCount'),
child: const Icon(Icons.shopping_cart_outlined),
),
selectedIcon: Badge(
isLabelVisible: cartItemCount > 0,
label: Text('$cartItemCount'),
child: const Icon(Icons.shopping_cart),
),
label: 'Cart',
),
const NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Profile',
),
],
);
}
int _calculateSelectedIndex(String location) {
if (location.startsWith('/search')) return 1;
if (location.startsWith('/cart')) return 2;
if (location.startsWith('/profile')) return 3;
return 0; // Home
}
void _onItemTapped(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/');
break;
case 1:
context.go('/search');
break;
case 2:
context.go('/cart');
break;
case 3:
context.go('/profile');
break;
}
}
}
Deep Linking in Flutter
// lib/main.dart
import 'package:app_links/app_links.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Handle deep link that opened the app
final appLinks = AppLinks();
final initialUri = await appLinks.getInitialLink();
runApp(
ProviderScope(
overrides: [
initialDeepLinkProvider.overrideWithValue(initialUri),
],
child: const MyApp(),
),
);
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
return MaterialApp.router(
routerConfig: router,
title: 'My App',
theme: AppTheme.light,
darkTheme: AppTheme.dark,
);
}
}
// Deep link handler
@riverpod
class DeepLinkHandler extends _$DeepLinkHandler {
late final AppLinks _appLinks;
StreamSubscription<Uri>? _subscription;
@override
void build() {
_appLinks = AppLinks();
_subscription = _appLinks.uriLinkStream.listen((uri) {
_handleDeepLink(uri);
});
ref.onDispose(() {
_subscription?.cancel();
});
}
void _handleDeepLink(Uri uri) {
final router = ref.read(routerProvider);
// Convert URI to app route
final path = uri.path;
final queryParams = uri.queryParameters;
// Handle special cases
if (path.startsWith('/share/')) {
_handleShareLink(path, queryParams);
return;
}
// Default: navigate to the path
router.go(uri.toString());
}
void _handleShareLink(String path, Map<String, String> params) {
// Custom handling for share links
final productId = params['product'];
if (productId != null) {
ref.read(routerProvider).go('/product/$productId');
}
}
}
Authentication Flow Patterns
React Native Auth Flow
// hooks/useAuth.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
isAuthenticated: boolean;
signIn: (email: string, password: string) => Promise<void>;
signUp: (data: SignUpData) => Promise<void>;
signOut: () => Promise<void>;
refreshToken: () => Promise<void>;
}
export const useAuth = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isLoading: true,
isAuthenticated: false,
signIn: async (email, password) => {
set({ isLoading: true });
try {
const { user, token } = await authApi.signIn(email, password);
set({ user, token, isAuthenticated: true, isLoading: false });
} catch (error) {
set({ isLoading: false });
throw error;
}
},
signUp: async (data) => {
set({ isLoading: true });
try {
const { user, token } = await authApi.signUp(data);
set({ user, token, isAuthenticated: true, isLoading: false });
} catch (error) {
set({ isLoading: false });
throw error;
}
},
signOut: async () => {
try {
await authApi.signOut();
} finally {
set({ user: null, token: null, isAuthenticated: false });
}
},
refreshToken: async () => {
const currentToken = get().token;
if (!currentToken) return;
try {
const { token } = await authApi.refreshToken(currentToken);
set({ token });
} catch {
// Token refresh failed, sign out
get().signOut();
}
},
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
onRehydrateStorage: () => (state) => {
state?.set({ isLoading: false });
},
}
)
);
// Protected route wrapper
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const navigation = useNavigation();
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
navigation.reset({
index: 0,
routes: [{ name: 'Auth' }],
});
}
}, [isAuthenticated, isLoading, navigation]);
if (isLoading) {
return <LoadingScreen />;
}
if (!isAuthenticated) {
return null;
}
return <>{children}</>;
}
Flutter Auth Flow
// lib/providers/auth_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'auth_provider.g.dart';
@riverpod
class Auth extends _$Auth {
@override
Future<AuthState> build() async {
// Load stored auth state
final storage = ref.watch(secureStorageProvider);
final token = await storage.read(key: 'auth_token');
final userJson = await storage.read(key: 'user');
if (token != null && userJson != null) {
try {
// Validate token
final user = await ref.read(authApiProvider).validateToken(token);
return AuthState.authenticated(user: user, token: token);
} catch (e) {
// Token invalid, clear storage
await storage.deleteAll();
}
}
return const AuthState.unauthenticated();
}
Future<void> signIn(String email, String password) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final response = await ref.read(authApiProvider).signIn(email, password);
// Store credentials
final storage = ref.read(secureStorageProvider);
await storage.write(key: 'auth_token', value: response.token);
await storage.write(key: 'user', value: jsonEncode(response.user.toJson()));
return AuthState.authenticated(
user: response.user,
token: response.token,
);
});
}
Future<void> signOut() async {
try {
await ref.read(authApiProvider).signOut();
} finally {
// Clear storage
final storage = ref.read(secureStorageProvider);
await storage.deleteAll();
state = const AsyncValue.data(AuthState.unauthenticated());
}
}
}
@freezed
class AuthState with _$AuthState {
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.authenticated({
required User user,
required String token,
}) = _Authenticated;
const AuthState._();
bool get isAuthenticated => this is _Authenticated;
User? get user => mapOrNull(
authenticated: (state) => state.user,
);
}
Navigation Patterns for Complex Flows
Multi-Step Form Navigation
// React Native: Checkout flow with step tracking
type CheckoutStep = 'address' | 'shipping' | 'payment' | 'review';
export function CheckoutNavigator() {
const [currentStep, setCurrentStep] = useState<CheckoutStep>('address');
return (
<View style={styles.container}>
<StepIndicator
steps={['Address', 'Shipping', 'Payment', 'Review']}
currentStep={stepIndex(currentStep)}
/>
<Stack.Navigator
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
gestureEnabled: false, // Prevent swipe back during checkout
}}
>
<Stack.Screen name="Address">
{() => (
<AddressStep
onNext={() => {
setCurrentStep('shipping');
navigation.navigate('Shipping');
}}
/>
)}
</Stack.Screen>
<Stack.Screen name="Shipping">
{() => (
<ShippingStep
onBack={() => {
setCurrentStep('address');
navigation.goBack();
}}
onNext={() => {
setCurrentStep('payment');
navigation.navigate('Payment');
}}
/>
)}
</Stack.Screen>
<Stack.Screen name="Payment">
{() => (
<PaymentStep
onBack={() => {
setCurrentStep('shipping');
navigation.goBack();
}}
onNext={() => {
setCurrentStep('review');
navigation.navigate('Review');
}}
/>
)}
</Stack.Screen>
<Stack.Screen name="Review">
{() => (
<ReviewStep
onBack={() => {
setCurrentStep('payment');
navigation.goBack();
}}
onComplete={handleOrderComplete}
/>
)}
</Stack.Screen>
</Stack.Navigator>
</View>
);
}
Modal Stack Pattern
// Flutter: Nested modal navigation
class ProductDetailScreen extends StatelessWidget {
final String productId;
const ProductDetailScreen({required this.productId, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: ProductImageGallery(productId: productId),
),
),
SliverToBoxAdapter(
child: ProductInfo(productId: productId),
),
],
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => _showSizeSelector(context),
child: const Text('Select Size'),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton(
onPressed: () => _addToCart(context),
child: const Text('Add to Cart'),
),
),
],
),
),
),
);
}
void _showSizeSelector(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.5,
minChildSize: 0.25,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) => SizeSelector(
productId: productId,
scrollController: scrollController,
onSelect: (size) {
Navigator.pop(context);
// Size selected
},
),
),
);
}
void _addToCart(BuildContext context) {
// Add to cart logic
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Added to cart'),
action: SnackBarAction(
label: 'View Cart',
onPressed: () => context.go('/cart'),
),
),
);
}
}
Platform-Specific Adaptations
Adaptive Navigation
// React Native: Platform-specific navigation
import { Platform } from 'react-native';
import { createDrawerNavigator } from '@react-navigation/drawer';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
const Drawer = createDrawerNavigator();
const Tab = createBottomTabNavigator();
export function MainNavigator() {
// Use drawer on tablets, tabs on phones
const isTablet = useIsTablet();
if (Platform.OS === 'ios') {
return <TabNavigator />;
}
if (Platform.OS === 'android' && isTablet) {
return <DrawerNavigator />;
}
return <TabNavigator />;
}
function DrawerNavigator() {
return (
<Drawer.Navigator
screenOptions={{
drawerType: 'permanent',
drawerStyle: { width: 280 },
}}
>
<Drawer.Screen name="Home" component={HomeStack} />
<Drawer.Screen name="Search" component={SearchStack} />
<Drawer.Screen name="Cart" component={CartStack} />
<Drawer.Screen name="Profile" component={ProfileStack} />
</Drawer.Navigator>
);
}
// Flutter: Adaptive layout
class AdaptiveNavigationShell extends StatelessWidget {
final Widget child;
const AdaptiveNavigationShell({required this.child, super.key});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.sizeOf(context).width;
final isWide = screenWidth > 600;
if (isWide) {
return Row(
children: [
const NavigationRail(
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: Text('Home'),
),
// ... other destinations
],
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: child),
],
);
}
return Scaffold(
body: child,
bottomNavigationBar: const MainBottomNav(),
);
}
}
Testing Navigation
React Native Navigation Tests
// __tests__/navigation.test.tsx
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { NavigationContainer } from '@react-navigation/native';
describe('Navigation', () => {
it('navigates to product detail when product is tapped', async () => {
const { getByTestId, getByText } = render(
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
);
// Find and tap a product
const product = await waitFor(() => getByTestId('product-1'));
fireEvent.press(product);
// Should be on product detail screen
await waitFor(() => {
expect(getByText('Product Details')).toBeTruthy();
});
});
it('redirects to login when accessing protected route', async () => {
// Clear auth state
useAuth.getState().signOut();
const { getByText } = render(
<NavigationContainer
initialState={{
routes: [{ name: 'Main' }],
}}
>
<RootNavigator />
</NavigationContainer>
);
// Should redirect to auth
await waitFor(() => {
expect(getByText('Sign In')).toBeTruthy();
});
});
it('handles deep link correctly', async () => {
const { getByText } = render(
<NavigationContainer
linking={{
...linking,
getInitialURL: () => 'myapp://product/123',
}}
>
<RootNavigator />
</NavigationContainer>
);
await waitFor(() => {
expect(getByText('Product 123')).toBeTruthy();
});
});
});
Conclusion
Navigation is foundational to mobile app architecture. The patterns covered here provide a solid starting point:
- Type-safe navigation prevents runtime errors and improves developer experience
- Nested navigators organize complex app structures
- Deep linking enables marketing, sharing, and notification flows
- Auth-aware routing protects sensitive screens
- Platform adaptations respect user expectations on each platform
Start simple with stack navigation. Add tabs as your app grows. Implement deep linking early—retrofitting is painful. Test navigation flows thoroughly, as navigation bugs frustrate users more than most other issues.
Both React Navigation and GoRouter are mature, well-documented solutions. Choose based on your team’s familiarity and specific requirements. The patterns transfer between frameworks.
Building a cross-platform app with complex navigation requirements? We have shipped apps with intricate navigation flows serving millions of Australian users. Contact us to discuss your architecture.