expo-maps Tutorial 2026: Native Apple Maps and Google Maps for React Native

Ship native Apple Maps and Google Maps in a React Native app with expo-maps: install the config plugin, render your first map, drop markers, handle camera events, and decide when to stay on react-native-maps.

Updated: August 16, 2026

expo-maps is the Expo team's native maps library for React Native, built directly on SwiftUI's Map on iOS and Jetpack Compose's Google Maps composable on Android. You install it with npx expo install expo-maps, render <AppleMaps.View /> on iOS or <GoogleMaps.View /> on Android, and drop markers, polylines, and camera controls without touching Xcode or Gradle. As of SDK 57 it's still officially in alpha, requires a development build (no Expo Go), and needs iOS 18 as the deployment target for the full event surface.

  • expo-maps exposes two platform-specific components (AppleMaps.View for iOS/SwiftUI MapKit and GoogleMaps.View for Android/Compose Google Maps) instead of one cross-platform MapView.
  • It ships as an alpha library in SDK 53+ (57 at time of writing), is not available in Expo Go, and requires a development build via EAS Build or npx expo run:ios | run:android.
  • Google Maps on iOS is intentionally not supported. If you need Google Maps everywhere, stay on react-native-maps.
  • Configuration is entirely declarative: the API key for Android sits in app.json under android.config.googleMaps.apiKey, and the location permission prompt is set through the expo-maps config plugin.
  • The library supports markers, iOS annotations, polylines, polygons, circles, camera control, and event handlers (onMapClick, onMarkerClick, onCameraMove, onPOIClick), but it does not render arbitrary React components as marker content.
  • For greenfield Expo apps targeting iOS 18+, expo-maps gives you a faster startup and truer platform look-and-feel. For existing production apps, react-native-maps is still the safer choice.

What is expo-maps and how is it different from react-native-maps?

Coming from React web, my mental model for a map component used to be a single <Map /> that hides everything platform-specific. That's how react-native-maps works: one MapView, with props like provider="google" to switch renderers. expo-maps deliberately breaks that model. It exposes two components (AppleMaps.View and GoogleMaps.View), and each one is a thin JavaScript wrapper around the actual native declarative UI on that platform. On iOS that means SwiftUI's Map from MapKit. On Android it means the GoogleMap composable from maps-compose.

The upside of that choice is that map interactions feel genuinely native. Panning, tilting, 3D buildings, POI callouts, and the compass all match what the OS ships in Apple Maps and Google Maps proper, because the underlying view is the OS view. It isn't a rewritten bridge on top of MKMapView. Startup is also faster: SwiftUI initializes lazily and the Compose side avoids the older bridging cost that react-native-maps historically paid with the paper renderer.

The trade-off is scope. Because SwiftUI's Map API is younger than MapKit's UIKit view, and because Compose Maps is a separate SDK from the Google Maps SDK for iOS, expo-maps only exposes what both platforms genuinely agree on. Google Maps on iOS is out. React components as marker content, a very common react-native-maps pattern, is out. Heatmaps, custom tile providers, and clustering plugins are out until the underlying native APIs cover them. If your app relies on that ecosystem, the community package remains the pragmatic choice, as I covered in the react-native-maps guide with markers, clustering, and directions.

Install expo-maps in an Expo project

You need a project on Expo SDK 53 or newer (57 is current). Because expo-maps ships native code that isn't baked into the Expo Go binary, you also need a development build produced by a config plugin. Nothing about that is unusual for 2026 (most useful native libraries need one now), but if you've been prototyping in Expo Go, this is where you graduate.

Install the package with the Expo CLI so the correct version is pinned to your SDK:

npx expo install expo-maps

Add the plugin to your app.json (or app.config.ts) so prebuild wires up the iOS Info.plist entries and Android manifest permissions. The plugin's job is to declare the location permission prompt string and to opt you into requesting while-using-app location access:

{
  "expo": {
    "name": "map-demo",
    "slug": "map-demo",
    "ios": {
      "bundleIdentifier": "com.example.mapdemo",
      "deploymentTarget": "18.0"
    },
    "android": {
      "package": "com.example.mapdemo"
    },
    "plugins": [
      [
        "expo-maps",
        {
          "requestLocationPermission": true,
          "locationPermission": "Allow $(PRODUCT_NAME) to show your location on the map."
        }
      ]
    ]
  }
}

Regenerate native code and run the app. From this point on you always use the development build, not Expo Go:

npx expo prebuild --clean
npx expo run:ios --device
# or
npx expo run:android

Add a Google Maps API key with the config plugin

Apple Maps needs zero credentials, since MapKit is included in every iOS app. Google Maps for Android needs a key from a Google Cloud project with the Maps SDK for Android API enabled. Once you have the key, put it in app.json under android.config.googleMaps.apiKey and Expo's prebuild will inject the correct meta-data entry into AndroidManifest.xml. Don't commit a raw key into the file if the repo is public. Pull it from an environment variable via app.config.ts instead:

// app.config.ts
import type { ExpoConfig } from 'expo/config';

export default (): ExpoConfig => ({
  name: 'map-demo',
  slug: 'map-demo',
  ios: {
    bundleIdentifier: 'com.example.mapdemo',
    deploymentTarget: '18.0',
  },
  android: {
    package: 'com.example.mapdemo',
    config: {
      googleMaps: {
        apiKey: process.env.GOOGLE_MAPS_ANDROID_KEY,
      },
    },
  },
  plugins: [
    [
      'expo-maps',
      {
        requestLocationPermission: true,
        locationPermission: 'Allow $(PRODUCT_NAME) to show your location on the map.',
      },
    ],
  ],
});

In EAS, set the value once per environment with eas env:create --scope project --name GOOGLE_MAPS_ANDROID_KEY --value "...". The key is only needed at build time. Android reads it from the manifest, so there's no runtime call to make. Restrict the key in Google Cloud Console to your Android package name and SHA-1 signing fingerprint. Both debug and release fingerprints belong on the whitelist. Google's Android SDK setup docs spell out how to fetch fingerprints from Gradle.

Render your first map on iOS and Android

The two views are similar enough that a small platform switch covers most screens. Here's the smallest useful example: a map centered on San Francisco with default UI controls visible.

// components/CityMap.tsx
import { AppleMaps, GoogleMaps } from 'expo-maps';
import { Platform, StyleSheet, Text, View } from 'react-native';

const SAN_FRANCISCO = { latitude: 37.78825, longitude: -122.4324 };

export function CityMap() {
  const camera = {
    coordinates: SAN_FRANCISCO,
    zoom: 13,
  };

  if (Platform.OS === 'ios') {
    return (
      <AppleMaps.View
        style={StyleSheet.absoluteFill}
        cameraPosition={camera}
        properties={{ isTrafficEnabled: false, mapType: 'STANDARD' }}
        uiSettings={{ compassEnabled: true, scaleBarEnabled: true }}
      />
    );
  }

  if (Platform.OS === 'android') {
    return (
      <GoogleMaps.View
        style={StyleSheet.absoluteFill}
        cameraPosition={camera}
        properties={{ isMyLocationEnabled: true, mapType: 'NORMAL' }}
        uiSettings={{ compassEnabled: true, myLocationButtonEnabled: true }}
      />
    );
  }

  return (
    <View style={styles.fallback}>
      <Text>Maps are only available on Android and iOS.</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  fallback: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});

A few things to notice. cameraPosition takes a plain object with coordinates and zoom. No latitudeDelta/longitudeDelta like react-native-maps. Zoom levels are Google-style integers (roughly 0 = world, 20 = building). properties controls what the map renders (traffic, buildings, indoor floors, map type), and uiSettings controls the on-screen chrome (compass, scale bar, my-location button). Splitting the two mirrors how SwiftUI and Compose expose the same distinction natively, and it means you rarely re-render the whole map just to toggle a control.

Add markers and annotations

Markers are the workhorse of any map screen. On both platforms, pass an array to the markers prop, and expo-maps will diff the array between renders for you. Wrap the array in useMemo so identity is stable and the native view doesn't needlessly reconcile:

import { AppleMaps, GoogleMaps } from 'expo-maps';
import { useMemo } from 'react';
import { Platform, StyleSheet } from 'react-native';

const CAFES = [
  { id: 'blue-bottle', latitude: 37.7767, longitude: -122.3937, title: 'Blue Bottle' },
  { id: 'sightglass', latitude: 37.7724, longitude: -122.4108, title: 'Sightglass' },
  { id: 'ritual',     latitude: 37.7568, longitude: -122.4218, title: 'Ritual' },
];

export function CafeMap() {
  const markers = useMemo(
    () => CAFES.map((cafe) => ({
      coordinates: { latitude: cafe.latitude, longitude: cafe.longitude },
      title: cafe.title,
      snippet: 'Tap for details',
      tintColor: '#FF7A00',
    })),
    []
  );

  if (Platform.OS === 'ios') {
    return (
      <AppleMaps.View
        style={StyleSheet.absoluteFill}
        markers={markers}
        onMarkerClick={(event) => console.log('tapped', event.title)}
      />
    );
  }

  return (
    <GoogleMaps.View
      style={StyleSheet.absoluteFill}
      markers={markers}
      onMarkerClick={(event) => console.log('tapped', event.title)}
    />
  );
}

On iOS you also get the richer AppleMaps.Marker configuration. systemImage lets you use any SF Symbol as the pin icon, monogram renders a two-letter badge, and tintColor replaces the classic red drop with a brand color. Honestly, that last one is what I reach for whenever I need consistent marker styling across a design system.

Annotations are an iOS-only concept exposed through the annotations prop on AppleMaps.View. Unlike markers, annotations render a custom bitmap (fetched via expo-image) instead of the built-in pin. This is how you get logo-shaped pins without shipping the image as an app asset:

import { useImage } from 'expo-image';
import { AppleMaps } from 'expo-maps';
import { StyleSheet } from 'react-native';

export function BrandedMap() {
  const icon = useImage(
    'https://cdn.example.com/pins/store-marker.png',
    { maxWidth: 64, maxHeight: 64 }
  );

  return (
    <AppleMaps.View
      style={StyleSheet.absoluteFill}
      annotations={
        icon
          ? [{
              coordinates: { latitude: 37.78825, longitude: -122.4324 },
              icon,
              title: 'Flagship Store',
            }]
          : []
      }
    />
  );
}

Control the camera and handle events

Camera movement is expressed declaratively. You update the cameraPosition prop, and the native view animates to the new position. There's also an imperative ref API for one-off transitions like "fit to markers" when you don't want a re-render to trigger the flight:

import { AppleMaps } from 'expo-maps';
import { useRef, useState } from 'react';
import { Button, StyleSheet, View } from 'react-native';

export function AnimatedCameraMap() {
  const ref = useRef<AppleMaps.MapView>(null);
  const [zoom, setZoom] = useState(11);

  const flyToGoldenGate = () => {
    ref.current?.setCameraPosition({
      coordinates: { latitude: 37.8199, longitude: -122.4783 },
      zoom: 15,
      duration: 800,
    });
  };

  return (
    <View style={{ flex: 1 }}>
      <AppleMaps.View
        ref={ref}
        style={StyleSheet.absoluteFill}
        cameraPosition={{
          coordinates: { latitude: 37.7749, longitude: -122.4194 },
          zoom,
        }}
        onCameraMove={(event) => setZoom(event.zoom)}
        onMapClick={(event) =>
          console.log('map tap', event.coordinates)
        }
      />
      <View style={{ position: 'absolute', bottom: 40, left: 20 }}>
        <Button title="Fly to Golden Gate" onPress={flyToGoldenGate} />
      </View>
    </View>
  );
}

Common event handlers you'll actually use:

  • onMapClick(event): fires with { coordinates } when the user taps empty map.
  • onMarkerClick(event): fires with the marker payload. iOS 18+ only on AppleMaps.View, and always available on GoogleMaps.View.
  • onCameraMove(event): fires while the camera is moving. Payload includes coordinates, zoom, bearing, and tilt. Debounce this if you use it for network fetches.
  • onPOIClick(event): Android only. Useful when you want to intercept Google-provided POIs (restaurants, parks) and show your own detail sheet.
  • onMapLongClick(event): Android only. The idiom for "drop a pin where I long-pressed".

Draw polylines, polygons, and circles

Overlays are also declarative arrays. This example draws a walking route on iOS with a rounded, dashed style:

import { AppleMaps } from 'expo-maps';
import { StyleSheet } from 'react-native';

const ROUTE = [
  { latitude: 37.7749, longitude: -122.4194 },
  { latitude: 37.7793, longitude: -122.4192 },
  { latitude: 37.7833, longitude: -122.4167 },
  { latitude: 37.7871, longitude: -122.4075 },
];

export function RouteMap() {
  return (
    <AppleMaps.View
      style={StyleSheet.absoluteFill}
      polylines={[{
        coordinates: ROUTE,
        color: '#2F80ED',
        width: 6,
      }]}
      circles={[{
        center: { latitude: 37.7749, longitude: -122.4194 },
        radius: 300,
        color: '#2F80ED33',
        lineColor: '#2F80ED',
        lineWidth: 2,
      }]}
    />
  );
}

Polygons follow the same shape as polylines but with a filled interior. On GoogleMaps.View, both accept an onClick lambda so you can build interactive overlays without wrapping each shape in a listener. This is where the SwiftUI/Compose foundation really pays off, because the overlay API on both platforms is genuinely rich, and expo-maps is a thin pass-through.

Should you migrate from react-native-maps to expo-maps?

So, here's my honest take after moving two side projects and shelving the migration for a client app. expo-maps is the right choice for new Expo projects that only need core map functionality and can live with two platform-specific components. It's the wrong choice for existing apps with a complex overlay ecosystem, and it's the wrong choice when your product mandates Google Maps tiles on iOS.

Dimensionexpo-maps (2026)react-native-maps (2026)
RendererSwiftUI MapKit + Compose Google MapsMKMapView + Google Maps SDK
iOS provider optionsApple Maps onlyApple Maps or Google Maps
Expo GoNot supportedNot supported (dev build needed)
Custom React marker contentNoYes
Clustering pluginsNone yetRich third-party ecosystem
New ArchitectureYes, nativeYes, since 1.14
Minimum iOS18 for full events13
StabilityAlpha, breaking changes each SDKStable, semver-respecting

If you're shipping a store locator, a run tracker, or any app whose map is essentially "show pins on a map and let me tap them", expo-maps genuinely delivers a nicer UX with less code. If you're shipping a delivery app with heatmaps, custom tile servers, or dense clustering, the ecosystem around react-native-maps is still where the work has been done, and pairing it with the React Native New Architecture migration checklist gives you the same underlying performance model.

For deeper background on why the Expo team built their own maps library on top of SwiftUI and Compose rather than wrapping the existing SDKs, the Introducing expo-maps blog post lays out the reasoning and the roadmap.

Common errors and how to fix them

A few footguns I've hit or seen on the Expo Discord in the past few weeks:

  • "Native module cannot be null" on launch. You're running in Expo Go. Rebuild the dev client with npx expo run:ios or eas build --profile development.
  • Blank grey map on Android. The Google Maps API key is missing, restricted to the wrong package name, or the Maps SDK for Android isn't enabled on the Google Cloud project. Check adb logcat | grep -i "Google Maps"; the SDK logs the exact cause.
  • Marker taps do nothing on iOS. You're below iOS 18. Set ios.deploymentTarget to "18.0" in app.json, re-run npx expo prebuild --clean, and rebuild.
  • Camera jumps back to the initial position on state change. You're passing a fresh object literal to cameraPosition on every render, which the native side interprets as "please animate back". Memoize the object, or use the imperative ref API.
  • Location dot missing. The plugin's requestLocationPermission: true only declares the string; you still need to call Location.requestForegroundPermissionsAsync() from expo-location at runtime before the map's my-location layer will populate.

You can browse the full prop reference and TypeScript types in the official expo-maps documentation, and pin exact versions from the expo-maps npm page if you need to lock behavior while the API stabilizes.

Frequently Asked Questions

Does expo-maps work with Expo Go?

No. expo-maps ships its own native code and isn't baked into the Expo Go binary. You need a development build produced with npx expo run:ios, npx expo run:android, or an EAS Build development profile. Once the dev client is installed, hot reload works exactly like Expo Go.

Can I use Google Maps on iOS with expo-maps?

No. expo-maps intentionally supports only Apple Maps on iOS and only Google Maps on Android. If your product mandates Google Maps tiles on both platforms, use react-native-maps instead, since it wraps the Google Maps SDK for iOS and lets you pass provider="google".

Is expo-maps production ready in 2026?

Not officially. The package is still marked alpha in SDK 57 and the maintainers explicitly warn about frequent breaking changes. Several teams already run it in production for simple map screens, but you should pin the version, subscribe to the changelog, and budget a small migration cost each SDK upgrade until it stabilizes.

How do I add a marker to expo-maps?

Pass an array of marker objects to the markers prop on either AppleMaps.View or GoogleMaps.View. Each object needs coordinates: { latitude, longitude }, and optionally title, snippet, tintColor, systemImage (iOS), or a custom icon. Memoize the array so the native side doesn't diff on every render.

What is the difference between markers and annotations in expo-maps?

Both drop a graphic at a coordinate. Markers use the platform's built-in pin appearance and support light styling (tint color, SF Symbol, monogram). Annotations are iOS-only and let you supply a fully custom image, typically loaded with expo-image's useImage, so you can render branded pins without shipping the image as an app asset.

Do I need an API key for Apple Maps in expo-maps?

No. MapKit is bundled with every iOS app and doesn't require registration or credentials for on-device rendering. You only need a Google Cloud API key with the Maps SDK for Android enabled, which you set through android.config.googleMaps.apiKey in app.json or app.config.ts.

Anita Iyer
About the Author Anita Iyer

Cross-platform mobile developer who came to RN from web. Bridges the two worlds and explains the seams.