React Native Maps with Expo: Google Maps, Markers, Clustering, and Directions

Set up Google Maps in your React Native Expo app with custom markers, location tracking, marker clustering, route directions, and geofencing — with working code for every feature.

Adding maps to a mobile app is one of those features that sounds simple enough — until you actually try to build it. Between platform-specific API keys, permission flows that differ on iOS and Android, marker rendering performance, and just figuring out which library to use in the first place, it gets complicated fast. And honestly, if you search for "React Native maps" right now, most of what you'll find is either outdated, covers only one piece of the puzzle, or glosses over the gotchas that end up costing you hours in production.

I've been through that cycle more times than I'd like to admit.

This guide covers everything you need to build a production-ready map experience in a React Native app using Expo. We'll set up Google Maps on both platforms, show the user's live location, render custom markers with callouts, cluster hundreds of pins without tanking performance, draw directions between two points, and even implement geofencing — all with working code you can drop into your project today.

Choosing a Map Library: react-native-maps vs expo-maps

Before writing any code, you need to pick a library. In the Expo ecosystem in 2026, there are really only two options worth considering.

react-native-maps is the battle-tested choice. It's been around for years, has a huge community, and supports both Apple Maps and Google Maps on iOS plus Google Maps on Android. It works inside Expo Go without any additional setup, which makes prototyping super fast. Version 1.20.x works with the New Architecture through the interop layer, and there's an ongoing effort in version 1.21.0+ to go fully New Architecture-first.

expo-maps is Expo's own native maps library. It uses Apple Maps on iOS and Google Maps on Android — but it intentionally doesn't support Google Maps on iOS. It's still in alpha, meaning breaking changes happen frequently. It also requires a development build (no Expo Go support), and certain event handlers like onMarkerClick need iOS 18.0 or later.

For most production apps in 2026, react-native-maps is the right call. It's mature, well-documented, and the ecosystem of add-on libraries (clustering, directions, heatmaps) is built around it. This guide uses react-native-maps throughout, with notes on expo-maps where relevant.

Setting Up react-native-maps with Expo

Install the library using the Expo CLI, which handles version compatibility with your current SDK automatically:

npx expo install react-native-maps

That's it for basic development. When testing inside Expo Go, the map renders using Apple Maps on iOS and Google Maps on Android — no API key required. However, deploying to the app stores does require a Google Maps API key, which we'll set up next.

Configuring Google Maps API Keys

To use Google Maps in production, you need API keys from the Google Cloud Console. Here's the step-by-step process for both platforms.

Step 1: Enable the Maps SDKs

Open the Google Cloud Console, select or create a project, then navigate to APIs & Services and enable both Maps SDK for Android and Maps SDK for iOS.

Step 2: Create API Keys

For Android, create an API key restricted to your app's package name and SHA-1 certificate fingerprint. You can find the SHA-1 in the Google Play Console under App integrity → Play app signing. One thing that trips people up: you need to upload an initial build to Google Play before this fingerprint is actually available.

For iOS, create an API key restricted to your app's bundle identifier.

Step 3: Add Keys to Your Expo Config

Add the keys through the react-native-maps config plugin in your app.json or app.config.js:

{
  "expo": {
    "plugins": [
      [
        "react-native-maps",
        {
          "androidGoogleMapsApiKey": "YOUR_ANDROID_API_KEY",
          "iosGoogleMapsApiKey": "YOUR_IOS_API_KEY"
        }
      ]
    ]
  }
}

Store your keys in environment variables or a .env file rather than hardcoding them. After adding the keys, rebuild your development binary with eas build or npx expo run:android / npx expo run:ios — config plugin changes aren't picked up by hot reload.

Displaying a Map with MapView

With the library installed, rendering a map takes just a few lines. Here's the key detail most tutorials skip over: MapView needs explicit dimensions. Without them, it renders with zero height and you'll just see a blank screen wondering what went wrong.

import MapView, { PROVIDER_GOOGLE, Marker } from 'react-native-maps';
import { StyleSheet, View } from 'react-native';

const INITIAL_REGION = {
  latitude: 37.78825,
  longitude: -122.4324,
  latitudeDelta: 0.0922,
  longitudeDelta: 0.0421,
};

export default function MapScreen() {
  return (
    <View style={styles.container}>
      <MapView
        style={styles.map}
        provider={PROVIDER_GOOGLE}
        initialRegion={INITIAL_REGION}
        showsUserLocation
        showsMyLocationButton
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  map: { width: '100%', height: '100%' },
});

Setting provider={PROVIDER_GOOGLE} forces Google Maps on both platforms. Without it, iOS defaults to Apple Maps. The showsUserLocation prop renders the familiar blue dot at the user's position, and showsMyLocationButton adds a native button to recenter the map.

Switching Map Types

Google Maps supports several map types through the mapType prop: standard (the default road map), satellite, hybrid (satellite with roads overlay), and terrain. Switching is as simple as adding mapType="hybrid" to your MapView.

Getting User Location with expo-location

While showsUserLocation shows the blue dot, you'll often need the actual coordinates — to center the map, calculate distances, or send them to a backend. For that, you need expo-location.

npx expo install expo-location

Here's a custom hook that handles permissions and returns the user's current coordinates:

import { useState, useEffect } from 'react';
import * as Location from 'expo-location';

export function useUserLocation() {
  const [location, setLocation] = useState<Location.LocationObjectCoords | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    (async () => {
      const { status } = await Location.requestForegroundPermissionsAsync();
      if (status !== 'granted') {
        setError('Location permission denied');
        return;
      }
      const current = await Location.getCurrentPositionAsync({
        accuracy: Location.Accuracy.High,
      });
      setLocation(current.coords);
    })();
  }, []);

  return { location, error };
}

Use it in your map component to center on the user automatically:

export default function MapScreen() {
  const { location } = useUserLocation();
  const mapRef = useRef<MapView>(null);

  useEffect(() => {
    if (location && mapRef.current) {
      mapRef.current.animateToRegion({
        latitude: location.latitude,
        longitude: location.longitude,
        latitudeDelta: 0.01,
        longitudeDelta: 0.01,
      }, 1000);
    }
  }, [location]);

  return (
    <MapView
      ref={mapRef}
      style={{ flex: 1 }}
      provider={PROVIDER_GOOGLE}
      initialRegion={INITIAL_REGION}
      showsUserLocation
    />
  );
}

Tracking Location in Real Time

For ride-sharing, delivery, or fitness apps, you need continuous location updates. Use Location.watchPositionAsync instead of getCurrentPositionAsync:

useEffect(() => {
  let subscription: Location.LocationSubscription;

  (async () => {
    const { status } = await Location.requestForegroundPermissionsAsync();
    if (status !== 'granted') return;

    subscription = await Location.watchPositionAsync(
      {
        accuracy: Location.Accuracy.High,
        distanceInterval: 10, // update every 10 meters
        timeInterval: 5000,   // or every 5 seconds
      },
      (newLocation) => {
        setLocation(newLocation.coords);
      }
    );
  })();

  return () => subscription?.remove();
}, []);

Adding Custom Markers and Callouts

Default red pins are fine for demos, but real apps need custom markers. You can use images, SVGs, or entirely custom React Native views as markers.

Basic Markers with Data

const places = [
  { id: '1', title: 'Golden Gate Bridge', description: 'Iconic suspension bridge', latitude: 37.8199, longitude: -122.4783 },
  { id: '2', title: 'Alcatraz Island', description: 'Former federal penitentiary', latitude: 37.8267, longitude: -122.4230 },
  { id: '3', title: 'Fisherman\'s Wharf', description: 'Waterfront dining and shopping', latitude: 37.8080, longitude: -122.4177 },
];

<MapView style={{ flex: 1 }} provider={PROVIDER_GOOGLE} initialRegion={INITIAL_REGION}>
  {places.map((place) => (
    <Marker
      key={place.id}
      coordinate={{ latitude: place.latitude, longitude: place.longitude }}
      title={place.title}
      description={place.description}
    />
  ))}
</MapView>

Custom Marker Icons

Use the image prop to replace the default pin with a custom image. Keep the image small — around 40x40 to 60x60 pixels works best for performance:

<Marker
  coordinate={{ latitude: 37.8199, longitude: -122.4783 }}
  image={require('./assets/custom-pin.png')}
  title="Golden Gate Bridge"
/>

Custom Callouts

For richer info bubbles, use the Callout component to render any React Native view when a marker is tapped:

import { Marker, Callout } from 'react-native-maps';
import { View, Text, Image, StyleSheet } from 'react-native';

<Marker coordinate={{ latitude: 37.8199, longitude: -122.4783 }}>
  <Callout tooltip>
    <View style={styles.callout}>
      <Text style={styles.calloutTitle}>Golden Gate Bridge</Text>
      <Text style={styles.calloutText}>Opened in 1937. Span: 1,280m</Text>
    </View>
  </Callout>
</Marker>

Drawing Polylines and Polygons

Polylines let you draw paths, routes, or boundaries on the map. Import Polyline and Polygon from react-native-maps and pass them as children of MapView:

import MapView, { Polyline, Polygon } from 'react-native-maps';

const routeCoords = [
  { latitude: 37.7749, longitude: -122.4194 },
  { latitude: 37.7849, longitude: -122.4094 },
  { latitude: 37.7949, longitude: -122.4294 },
];

const parkBoundary = [
  { latitude: 37.7694, longitude: -122.4862 },
  { latitude: 37.7694, longitude: -122.4530 },
  { latitude: 37.7834, longitude: -122.4530 },
  { latitude: 37.7834, longitude: -122.4862 },
];

<MapView style={{ flex: 1 }} provider={PROVIDER_GOOGLE} initialRegion={INITIAL_REGION}>
  <Polyline
    coordinates={routeCoords}
    strokeWidth={4}
    strokeColor="#4285F4"
    lineCap="round"
    lineJoin="round"
  />
  <Polygon
    coordinates={parkBoundary}
    fillColor="rgba(66, 133, 244, 0.2)"
    strokeColor="#4285F4"
    strokeWidth={2}
  />
</MapView>

Implementing Marker Clustering

Once your map has more than about 50 markers, you'll start noticing performance take a hit — especially with custom marker images. Clustering groups nearby markers into a single indicator showing a count, then expands when the user zooms in.

The most reliable Expo-compatible option is react-native-map-clustering, which wraps react-native-maps and uses Mapbox's Supercluster algorithm under the hood:

npx expo install react-native-map-clustering

Replace MapView with ClusterMap and render your markers as children. It's a surprisingly smooth swap:

import ClusterMap from 'react-native-map-clustering';
import { Marker, PROVIDER_GOOGLE } from 'react-native-maps';

const markers = generateMarkers(500); // array of { id, latitude, longitude }

export default function ClusteredMapScreen() {
  return (
    <ClusterMap
      style={{ flex: 1 }}
      provider={PROVIDER_GOOGLE}
      initialRegion={INITIAL_REGION}
      clusterColor="#4285F4"
      clusterTextColor="#ffffff"
      radius={50}
      maxZoom={16}
    >
      {markers.map((m) => (
        <Marker
          key={m.id}
          coordinate={{ latitude: m.latitude, longitude: m.longitude }}
          tracksViewChanges={false}
        />
      ))}
    </ClusterMap>
  );
}

Custom Cluster Rendering

The default cluster bubble is functional but pretty plain. You can override it with renderCluster:

<ClusterMap
  style={{ flex: 1 }}
  provider={PROVIDER_GOOGLE}
  initialRegion={INITIAL_REGION}
  renderCluster={(cluster) => {
    const { id, geometry, onPress, properties } = cluster;
    const count = properties.point_count;
    return (
      <Marker
        key={`cluster-${id}`}
        coordinate={{
          latitude: geometry.coordinates[1],
          longitude: geometry.coordinates[0],
        }}
        onPress={onPress}
      >
        <View style={[styles.cluster, { width: 30 + count, height: 30 + count }]}>
          <Text style={styles.clusterText}>{count}</Text>
        </View>
      </Marker>
    );
  }}
>
  {/* markers */}
</ClusterMap>

Clustering Performance Tips

  • Always set tracksViewChanges={false} on markers that use static images — this prevents React Native from continuously re-rendering the marker view. Seriously, this one prop change can be night and day.
  • Give each marker a stable, unique key based on an ID rather than an array index. This lets React recycle views efficiently.
  • Use the onMarkerPress callback on the MapView level instead of onPress on individual markers when dealing with large datasets.
  • Tune the radius (default 40) and maxZoom (default 20) props to control how aggressively clustering groups pins at each zoom level.

Adding Route Directions Between Two Points

Drawing a route between an origin and destination requires the Google Directions API. The easiest approach is the react-native-maps-directions library, which handles the API call and polyline rendering in one neat component:

npx expo install react-native-maps-directions

Make sure the Directions API is enabled in your Google Cloud Console project (it's a separate API from the Maps SDK — easy to miss).

import MapView, { Marker, PROVIDER_GOOGLE } from 'react-native-maps';
import MapViewDirections from 'react-native-maps-directions';

const GOOGLE_MAPS_API_KEY = 'YOUR_API_KEY';

const origin = { latitude: 37.7749, longitude: -122.4194 };
const destination = { latitude: 37.8199, longitude: -122.4783 };

export default function DirectionsScreen() {
  const mapRef = useRef<MapView>(null);

  return (
    <MapView
      ref={mapRef}
      style={{ flex: 1 }}
      provider={PROVIDER_GOOGLE}
      initialRegion={INITIAL_REGION}
    >
      <Marker coordinate={origin} title="Start" />
      <Marker coordinate={destination} title="End" />
      <MapViewDirections
        origin={origin}
        destination={destination}
        apikey={GOOGLE_MAPS_API_KEY}
        strokeWidth={5}
        strokeColor="#4285F4"
        mode="DRIVING"
        onReady={(result) => {
          mapRef.current?.fitToCoordinates(result.coordinates, {
            edgePadding: { top: 80, right: 80, bottom: 80, left: 80 },
            animated: true,
          });
        }}
      />
    </MapView>
  );
}

The onReady callback fires once the route is calculated. The result object includes distance (in km), duration (in minutes), and coordinates (the polyline points). The mode prop accepts DRIVING, WALKING, BICYCLING, or TRANSIT.

Manual Directions with the Directions API

If you want more control — say, to show turn-by-turn instructions or multiple route alternatives — you can call the Directions API directly and decode the polyline yourself:

import polyline from '@mapbox/polyline';

async function fetchRoute(origin, destination, apiKey) {
  const url = `https://maps.googleapis.com/maps/api/directions/json?origin=${origin.latitude},${origin.longitude}&destination=${destination.latitude},${destination.longitude}&key=${apiKey}`;

  const response = await fetch(url);
  const data = await response.json();

  if (data.routes.length === 0) return null;

  const route = data.routes[0];
  const points = polyline.decode(route.overview_polyline.points);
  const coordinates = points.map(([lat, lng]) => ({
    latitude: lat,
    longitude: lng,
  }));

  return {
    coordinates,
    distance: route.legs[0].distance.text,
    duration: route.legs[0].duration.text,
  };
}

Install the decoder with npx expo install @mapbox/polyline and then pass the decoded coordinates to a Polyline component.

Building a Geofence

Geofencing creates a virtual boundary around a real-world location and triggers actions when a user enters or exits that area. It's one of those features that sounds fancy but is actually pretty straightforward to implement. Think delivery apps, location-based reminders, or attendance tracking.

First, render the geofence visually using a Circle overlay:

import MapView, { Circle, Marker, PROVIDER_GOOGLE } from 'react-native-maps';

const GEOFENCE = {
  center: { latitude: 37.78825, longitude: -122.4324 },
  radius: 300, // meters
};

<MapView style={{ flex: 1 }} provider={PROVIDER_GOOGLE} initialRegion={INITIAL_REGION}>
  <Circle
    center={GEOFENCE.center}
    radius={GEOFENCE.radius}
    fillColor="rgba(66, 133, 244, 0.15)"
    strokeColor="#4285F4"
    strokeWidth={2}
  />
  <Marker coordinate={GEOFENCE.center} title="Geofence Center" />
</MapView>

Then combine it with live location tracking to detect when the user crosses the boundary:

import { getDistance } from 'geolib';

function checkGeofence(userCoords, geofence) {
  const distance = getDistance(
    { latitude: userCoords.latitude, longitude: userCoords.longitude },
    { latitude: geofence.center.latitude, longitude: geofence.center.longitude }
  );
  return distance <= geofence.radius;
}

// Inside your location watcher callback:
const isInside = checkGeofence(newLocation.coords, GEOFENCE);
if (isInside && !wasInsideRef.current) {
  console.log('User entered the geofence');
  // trigger notification, API call, etc.
}
wasInsideRef.current = isInside;

Install geolib with npx expo install geolib. It provides accurate Haversine distance calculations so you don't have to implement the math yourself (and trust me, you don't want to).

Performance Optimization Tips

Maps are one of the most GPU-intensive components in a React Native app. Here are the practices that actually make a noticeable difference:

  • Set tracksViewChanges={false} on every marker that uses a static image or custom view. This is the single biggest performance win you can get. When set to true (the default), React Native re-renders the marker image every single frame.
  • Use clustering for anything beyond 50 markers. Even if your dataset is only 100 items, clustering makes the map feel noticeably smoother on lower-end Android devices.
  • Debounce onRegionChangeComplete callbacks. If you're fetching data based on the visible map region, debounce by at least 300ms to avoid firing dozens of requests while the user pans around.
  • Optimize marker images. Keep PNGs under 5KB each. Oversized images cause visible lag when the map needs to render many markers at once.
  • Avoid unnecessary re-renders of MapView. Memoize the component and make sure parent state changes don't cause the entire map to unmount and remount. React.memo and stable references for region and marker data go a long way here.
  • Test on real devices. The iOS Simulator and Android emulator handle maps way better than most mid-range phones. Always benchmark on actual hardware before shipping.

New Architecture Compatibility in 2026

If you're on Expo SDK 55, the New Architecture is mandatory — you can't disable it. Here's what that means for maps:

  • react-native-maps version 1.20.x (the SDK 53 default) works through the New Architecture interop layer. For most features — rendering maps, markers, polylines, callouts — it's stable and production-ready.
  • Version 1.21.0 introduced a New Architecture-first rewrite, but it's still stabilizing. Keep an eye on the GitHub issues before upgrading.
  • There's a known issue with Google Maps on iOS in SDK 55 where the Expo plugin can fail during prebuild. If you hit the "Cannot add Google Maps to the project's AppDelegate" error, check for updates to the config plugin or consider using Apple Maps on iOS as a fallback.
  • expo-maps is built for the New Architecture from the ground up and doesn't have interop layer concerns — but remember, it's still in alpha.

Frequently Asked Questions

Do I need a Google Maps API key to test in Expo Go?

Nope. Expo Go uses Apple Maps on iOS and a default Google Maps setup on Android, so you can develop and test without an API key. You only need keys when creating a production build for the app stores.

Why is my map showing a blank screen on Android?

This usually means the Google Maps API key is missing, invalid, or the Maps SDK for Android isn't enabled in your Google Cloud project. Check the API Console to verify the key is active and that the correct SHA-1 fingerprint is listed under key restrictions. Also make sure you've rebuilt the native binary after adding the key to your config plugin — this is the one people forget most often.

Can I use Apple Maps on iOS and Google Maps on Android?

Yes, and this is actually the default behavior of react-native-maps when you don't set provider={PROVIDER_GOOGLE}. On iOS it uses Apple Maps (MapKit), and on Android it uses Google Maps. This approach skips the iOS Google Maps API key setup entirely and is a totally valid strategy for production if you don't need a consistent map style across platforms.

How do I handle map permissions being denied?

The map itself renders just fine without location permission — it simply won't show the user's blue dot or center on their location. Handle denied permissions gracefully by showing a fallback UI or a message explaining why the app works better with location access. You can use Linking.openSettings() to give users a direct path to the device settings if they want to enable it later.

Is react-native-maps compatible with Expo SDK 55 and the New Architecture?

Yes, through the interop layer. Version 1.20.x works reliably for most features. There is a known issue with the Google Maps iOS config plugin on SDK 55 though, so keep an eye on the react-native-maps GitHub repository and keep dependencies up to date. If you're starting a fresh project and can target iOS 17+, expo-maps is a future-proof alternative worth evaluating — just be aware of its alpha status.

About the Author Editorial Team

Our team of expert writers and editors.