Fast, typed maps for React Native, built on Nitro Modules and the New Architecture.
Built with Nitro Modules for high-performance native map rendering.
Features • Installation • Quick start • Map providers • Documentation • Public API
Full documentation lives in docs/. Start with Expo setup, Architecture, and Roadmap.
- Features
- iOS Apple Maps clustering comparison
- Android Google Maps clustering comparison
- Requirements
- Supported platforms
- Installation
- Quick start
- Map providers
- Region change events
- Native POI press events
- Custom marker images
- GeoJSON overlays
- Google Maps setup
- Marker entering animations
- Re-renders
- Overlay ids
- Overlay presses
- Invalid input
- Capability matrix
- Public API
- Example app
- Documentation
- Common problems
- Development
- What's next
- Performance first - Nitro Modules and JSI power zero-bridge map interactions.
- New Architecture native - Built exclusively for React Native's New Architecture: Fabric + TurboModules.
- Unified map API - One typed React API for Apple MapKit and Google Maps SDK.
- Provider-aware props - TypeScript narrows provider-specific props with
MapViewPropsForProvider<P>. - Markers and overlays - Markers with title/subtitle callouts and drag support, plus polylines, polygons, circles, and GeoJSON FeatureCollections.
- Native POI taps -
onPoiPressreports provider-owned places from Apple Maps and Google Maps without confusing them with app-owned markers. - Native POI details -
applePoiDetailPresentationopens MapKit's own place details (callout, sheet, or Open in Maps) on Apple Maps, iOS 18+. - Camera control - Declarative region/camera props plus imperative camera helpers.
- Marker clustering - Native marker clustering for large point sets.
- Native entering animations - Configurable marker and cluster entrance animations.
- Expo friendly - Config plugin for Google Maps API keys and location permissions.
- Tree-shakeable package - ESM-only build with an explicit
exportsmap.
The clips below compare the same iOS Apple Maps marker clustering scenario in
react-native-better-maps and react-native-maps with
react-native-clusterer.
| react-native-better-maps | react-native-maps + react-native-clusterer |
|---|---|
| Native MapKit-backed clustering through the Nitro map provider. | React Native Maps with JS-side clusterer integration. |
Open GIF |
Open GIF |
The clips below compare the same Android Google Maps marker clustering scenario
in react-native-better-maps and react-native-maps with
react-native-clusterer.
| react-native-better-maps | react-native-maps + react-native-clusterer |
|---|---|
| Native Google Maps-backed clustering through the Nitro map provider. | React Native Maps with JS-side clusterer integration. |
Open GIF |
Open GIF |
| Requirement | Version / note |
|---|---|
| React Native | 0.78+ |
| React Native architecture | New Architecture enabled |
| Nitro Modules | react-native-nitro-modules >=0.35.0 |
| iOS | 16.0+ |
| Android | minSdkVersion 24+ |
| Expo | SDK 56+ (verified through SDK 57) with a development build; Expo Go is not supported |
Not supported today:
- Custom React Native marker child views such as
<Marker><View /></Marker>; use bitmap marker images instead. openstreetmapandmapboxproviders; the public provider type reserves these names for future native implementations.
| Platform | Default provider | Available providers |
|---|---|---|
| iOS | apple |
apple, google |
| Android | google |
google |
Unsupported explicit providers throw before a native map view is created.
bun add react-native-better-maps react-native-nitro-modulesnpm install react-native-better-maps react-native-nitro-modulesyarn add react-native-better-maps react-native-nitro-modulespnpm add react-native-better-maps react-native-nitro-modulesFor Expo apps using SDK 56+, add the config plugin to app.json or app.config.js:
export default {
expo: {
plugins: [
[
'react-native-better-maps',
{
googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
locationPermission:
'Allow $(PRODUCT_NAME) to use your location for map features.',
},
],
],
},
};| Option | Platform | Description |
|---|---|---|
googleMapsApiKey |
iOS + Android | Shared fallback when platform-specific keys are omitted. |
iosGoogleMapsApiKey |
iOS | Injects GoogleMapsIosApiKey into Info.plist and sets betterMaps.iosGoogleProvider in Podfile.properties.json so the Google Maps SDK is linked. |
androidGoogleMapsApiKey |
Android | Injects com.google.android.geo.API_KEY metadata. |
locationPermission |
iOS + Android | Foreground location message. Injects NSLocationWhenInUseUsageDescription plus ACCESS_FINE_LOCATION + ACCESS_COARSE_LOCATION. Pass false or omit to skip. |
locationAlwaysPermission |
iOS + Android | Background location message. Injects NSLocationAlwaysAndWhenInUseUsageDescription plus ACCESS_BACKGROUND_LOCATION; also supplies foreground usage strings and permissions when locationPermission is omitted. Pass false or omit to skip. |
After expo prebuild, native projects have the required keys and permissions without manual edits.
Google Maps API key: Use either this plugin's
googleMapsApiKeyoption or Expo's built-inandroid.config.googleMaps.apiKey. Pick one source, not both.EAS Secrets: Store
GOOGLE_MAPS_API_KEYas an EAS secret and reference it viaprocess.env.GOOGLE_MAPS_API_KEYinapp.config.js.
See docs/expo-setup.md for a full Expo setup walkthrough (SDK 56+, verified through SDK 57).
import { MapView, Marker, Polyline } from 'react-native-better-maps';
function MyMap() {
return (
<MapView
style={{ flex: 1 }}
mapType="standard"
onRegionChangeComplete={(region, details) =>
console.log(region, details.isGesture)
}
>
<Marker
coordinate={{ latitude: 52.2297, longitude: 21.0122 }}
title="Warsaw"
image={require('./assets/pin.png')}
anchor={{ x: 0.5, y: 1 }}
rotation={45}
flat
opacity={0.9}
/>
<Polyline
coordinates={[
{ latitude: 52.2297, longitude: 21.0122 },
{ latitude: 52.237, longitude: 21.017 },
]}
strokeColor="#FF0000"
strokeWidth={3}
/>
</MapView>
);
}import { useRef } from 'react';
import { MapView, type MapViewRef } from 'react-native-better-maps';
function ControlledMap() {
const mapRef = useRef<MapViewRef>(null);
const flyToWarsaw = async () => {
const map = mapRef.current;
if (map == null) {
return;
}
try {
await map.animateCamera(
{ center: { latitude: 52.2297, longitude: 21.0122 }, zoom: 12 },
1000,
);
// The camera is there now, so this reads where it actually arrived.
const camera = await map.getCamera();
console.log(camera.center);
} catch (error) {
// The map view unmounted, so there is no camera left to move or read.
console.warn(error);
}
};
return <MapView ref={mapRef} style={{ flex: 1 }} />;
}animateToRegion takes the Region that the region prop takes and
onRegionChangeComplete reports, so a view saved from the event can be
animated back to later:
import { useRef } from 'react';
import {
MapView,
type MapViewRef,
type Region,
} from 'react-native-better-maps';
function MapWithSavedView() {
const mapRef = useRef<MapViewRef>(null);
const savedRegion = useRef<Region | null>(null);
const returnToSavedView = () => {
if (savedRegion.current != null) {
mapRef.current?.animateToRegion(savedRegion.current, 500);
}
};
return (
<MapView
ref={mapRef}
style={{ flex: 1 }}
onRegionChangeComplete={(region) => {
savedRegion.current = region;
}}
/>
);
}The region is framed as the region prop frames it: all of it in view, inside
mapPadding, north up and flat. Its proportions rarely match the map's, so one
axis shows more than the region asks for.
Durations are in milliseconds, for animateCamera and animateToRegion
alike, as in react-native-maps. Both default to 250, and 0 moves the camera
without animating. Up to 1.2.1 animateCamera took seconds, so multiply a
duration passed to it by 1000 when upgrading.
The native map is created after React commits, so mapRef.current is populated
before there is anything native behind it. Calls made in that window are held
and replayed, in the order they were made, as soon as the native map exists:
import { useEffect, useRef } from 'react';
import {
MapView,
type Coordinate,
type MapViewRef,
} from 'react-native-better-maps';
function FittedMap({ points }: { points: Coordinate[] }) {
const mapRef = useRef<MapViewRef>(null);
useEffect(() => {
// Runs before the native map exists, and still moves the camera.
mapRef.current
?.fitToCoordinates(points, undefined, true)
.catch((error: Error) => console.warn(error.message));
}, [points]);
return <MapView ref={mapRef} style={{ flex: 1 }} />;
}So no call needs setTimeout, a retry, or an onMapReady handler to be safe.
onMapReady reports something later and different - that the map finished
loading its tiles - and is the right hook for showing your own UI on top of a
map that has actually drawn.
A call still waiting when the map view unmounts rejects, as does any call made
afterwards, so handle the rejection the way the example above does. Development
builds wrapped in React's <StrictMode> see this on every mount: React tears
the effect down and sets it up again, the first call is rejected by that
teardown, and the second one does the work.
animateCamera, animateToRegion and fitToCoordinates resolve when the camera
has arrived, not when the animation is handed to the map. setCamera moves
without animating, so it resolves right away, as do a 0 duration and
fitToCoordinates with animated: false.
An animation that is cut short - by a gesture, by a later camera command, or by
the map view unmounting mid-animation - resolves too rather than hanging. It does
not reject, and it does not report whether the requested position was reached:
read getVisibleRegion() or getCamera() after the await when that matters.
(On Apple Maps a gesture cannot cut animateCamera or animateToRegion short:
the map ignores touches until the animation ends.)
Pass fitToCoordinates' animated argument explicitly: left out, iOS animates
the fit and Android jumps to it.
Behavior change after 1.2.1: these promises used to resolve as soon as the animation started, so
awaitreturned with the camera still at its old position.
pointForCoordinate and coordinateForPoint convert between a coordinate and a
position on the map view, which is what placing React Native content exactly
over the map takes - a label, a custom callout, an animated pointer. A Point
is in density-independent pixels from the top-left corner of the map view, the
units and origin of the map view's own layout on both platforms:
import { useCallback, useRef, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import {
MapView,
Marker,
type MapViewRef,
type Point,
} from 'react-native-better-maps';
const warsaw = { latitude: 52.2297, longitude: 21.0122 };
function LabelledMap() {
const mapRef = useRef<MapViewRef>(null);
const [labelPoint, setLabelPoint] = useState<Point | null>(null);
const placeLabel = useCallback(() => {
mapRef.current
?.pointForCoordinate(warsaw)
.then(setLabelPoint)
.catch((error: Error) => console.warn(error.message));
}, []);
return (
<View style={{ flex: 1 }}>
<MapView
ref={mapRef}
style={StyleSheet.absoluteFill}
onMapReady={placeLabel}
onRegionChangeComplete={placeLabel}
>
<Marker coordinate={warsaw} />
</MapView>
{labelPoint != null && (
// The map fills this parent, so a point on the map is also a position in it.
<Text
pointerEvents="none"
style={{ position: 'absolute', left: labelPoint.x, top: labelPoint.y }}
>
Warsaw
</Text>
)}
</View>
);
}A point describes the camera at the time of the call, so convert again once the
camera has moved; onRegionChangeComplete reports every move, whoever made it. A
coordinate that is off screen converts to a point outside the map view's
bounds. Both calls reject straight away for input that is not on the map: a
coordinate outside ±90 / ±180, or a point whose x or y is not finite.
MapView accepts an optional provider prop:
import { Platform } from 'react-native';
import { MapView, type MapProvider } from 'react-native-better-maps';
const provider: MapProvider = Platform.OS === 'android' ? 'google' : 'apple';
export function ProviderMap() {
return <MapView provider={provider} style={{ flex: 1 }} />;
}When provider is omitted, defaults stay backward-compatible:
| Platform | Default provider |
|---|---|
| iOS | apple |
| Android | google |
Changing provider remounts the native map view. Controlled props such as region, camera, overlays, and callbacks should therefore be supplied again through React props.
Provider-specific TypeScript props are exposed through MapViewPropsForProvider<P>. For example, showsScale is accepted for apple but rejected for google because Google Maps SDK has no native scale control.
onRegionChange fires when the camera starts moving and onRegionChangeComplete when it
stops. Both fire for every move, whoever started it: a pinch or pan, setCamera,
animateCamera, fitToCoordinates, or an updated region / camera prop. The second
argument says which it was.
<MapView
style={{ flex: 1 }}
onRegionChange={(region, details) => {
if (details.isGesture) {
cancelAutoFollow();
}
}}
onRegionChangeComplete={(region, details) => {
console.log(
details.isGesture ? 'user moved the map' : 'the app moved the map',
);
}}
/>One move emits exactly one onRegionChange and one onRegionChangeComplete, with the
same details on both, and nothing fires while the camera is on its way. An update that
leaves the camera where it is - a region or camera prop set to what the map already
shows, or a repeated fitToCoordinates - emits nothing, and neither does the map settling
into its first position as it appears. Read getVisibleRegion() from onMapReady for
that one.
A gesture that interrupts the app's own animation ends that move and starts the user's:
onRegionChangeComplete with isGesture: false where the finger caught the camera, then
onRegionChange with isGesture: true. The opposite does not split: a camera command
issued while the map is still moving from a gesture stays part of the gesture's move. On
Apple Maps an animateCamera animation cannot be interrupted this way, because the map
ignores touches until it ends.
isGesture means a touch gesture on the map itself. Android's own controls (the zoom
buttons and the my-location button) report as isGesture: false, because the Google Maps
SDK classifies them as an API animation rather than a gesture.
| react-native-maps | react-native-better-maps |
|---|---|
onRegionChangeStart(region, details) |
onRegionChange(region, details) |
onRegionChange(region, details), on every frame |
No equivalent - nothing fires while the camera moves |
onRegionChangeComplete(region, details) |
Same |
details.isGesture, on Google Maps only |
details.isGesture, on Apple and Google Maps alike |
Note the first two rows: onRegionChange here fires once, when a move begins, which is
what react-native-maps calls onRegionChangeStart.
Behavior change after 1.2.1: both callbacks used to fire only for user gestures, and took the region alone. Code that treated every event as user input should now check
details.isGesture.
Feeding onRegionChangeComplete back into a controlled region prop is safe: the map is
already showing that region, so the update moves nothing and emits nothing. Feeding back
onRegionChange is not. It hands the map the region the camera is leaving, and for a move
the app started, going back there cancels it.
Provider-owned points of interest are base-map features supplied by Apple Maps or Google Maps, such as restaurants, parks, schools, hotels, and stores. They are separate from app-owned <Marker /> elements and bulk markers; marker presses still use Marker.onPress and MapView.onMarkerPress.
onPoiPress is enabled automatically when provided. A POI tap emits only onPoiPress; it does not also emit background-map onPress.
<MapView
provider="google"
style={{ flex: 1 }}
onPoiPress={(event) => {
console.log(event.provider, event.name, event.placeId);
}}
/>
<MapView
provider="apple"
style={{ flex: 1 }}
onPoiPress={(event) => {
console.log(event.provider, event.name, event.category, event.rawCategory);
}}
/>Provider-specific props narrow the callback payload:
| Provider | Payload |
|---|---|
apple |
{ provider: 'apple', coordinate, name?, category, rawCategory? } |
google |
{ provider: 'google', coordinate, name, placeId } |
| omitted | ApplePoiPressEvent | GooglePoiPressEvent because the runtime default depends on platform |
Apple MapKit can present its own place details for a selected point of interest through MKSelectionAccessory.mapItemDetail(...) on iOS 18+. Set applePoiDetailPresentation to opt in. The prop is accepted for provider="apple" and when the provider is omitted, and rejected for google, openstreetmap, and mapbox.
<MapView
provider="apple"
style={{ flex: 1 }}
applePoiDetailPresentation="callout"
onPoiPress={(event) => {
console.log(event.name, event.category);
}}
/>| Value | MapKit presentation |
|---|---|
'automatic' |
MapKit picks the presentation for the current context |
'callout' |
Callout anchored to the selected place |
'sheet' |
Sheet from the map's view controller; falls back to callout if none is available |
'openInMaps' |
Affordance that opens the place in the Maps app |
- Setting the prop enables selectable points of interest on its own;
onPoiPressis optional. When both are set, the event fires immediately and the native details open for the same tap. - Without the prop, a POI tap emits
onPoiPressand the native selection is cleared right away. With the prop, the place stays selected while its details are shown. - On iOS 16 and 17 the prop is a no-op: POI taps still emit
onPoiPressand the selection is cleared, but no native details appear. - The Google Maps SDK (iOS and Android) has no equivalent native place-detail surface, so Google POI taps remain event-only.
Markers support custom bitmap icons with positioning and styling options:
<Marker
coordinate={coord}
image={require('./pin.png')}
anchor={{ x: 0.5, y: 1.0 }}
rotation={45}
flat
opacity={0.9}
/>
<MapView
markers={[
{
id: '1',
coordinate: coord,
image: { uri: 'https://example.com/pin.png' },
anchor: { x: 0.5, y: 1 },
},
]}
/>Supported image sources:
| Source | Example | Notes |
|---|---|---|
| Bundled asset | require('./pin.png') |
Resolved on JS side before crossing Nitro |
| Local URI | { uri: 'file:///…' } |
Platform file paths |
| Remote URL | { uri: 'https://…' } |
Async fetch with in-memory cache; the host is restricted, see below |
A remote URL you pass yourself — image={{ uri: someUrl }} — is checked on both platforms before
the image is fetched. The URL must use http/https, must not carry user:password@, and its
host must not resolve to a private address: loopback, any-local, link-local, site-local,
multicast, IPv6 unique-local, a .local/.localhost name, or a cloud metadata endpoint. Every
redirect target is checked the same way, so a permitted host cannot forward the fetch to a blocked
one, and a redirect from https to http is not followed. This guards the common case of a
marker icon URL arriving as data from an API.
Android also checks the address the connection is actually made to, before it sends the request.
iOS checks the name only: URLSession resolves it again to connect, so a DNS answer that changes
in between is not re-checked there. Reaching the network the device itself is on still needs the
user's Local Network permission on iOS.
A rejected URL logs Rejected remote marker image URI — under the NitroMaps logcat tag on
Android, and under the NitroMaps category of the com.nitromaps subsystem in the unified log
on iOS. The marker itself falls back to the default pin on both Google providers. On Apple Maps
it draws with no image at all, the same as any marker image that fails to load.
require()d assets are exempt, including the packager URL Metro resolves them to in a
development build.
If you need markers served from a private host, resolve it to an image yourself and pass the bytes as a local file, or proxy it through a public endpoint.
Additional props:
| Prop | Default | Description |
|---|---|---|
anchor |
{ x: 0.5, y: 1 } |
Point on the image aligned to the coordinate |
centerOffset |
— | Extra offset in dp (MapKit-style) |
rotation |
0 |
Clockwise rotation in degrees |
flat |
false |
Rotate with map plane (Google Maps; MapKit approximates via view transform) |
opacity |
1 |
Marker opacity from 0 to 1 |
markerColor |
— | Color applied to the default marker |
zIndex |
— | Drawing order relative to other map overlays |
Platform notes:
- Recommended icon size: up to 128×128 dp; larger bitmaps are downscaled when
width/heightare provided. - Retina assets: pass
require()and let Metro resolve@2x/@3x; optional explicitwidth/height/scaleonMarkerImage. - Remote URLs use a basic in-memory cache only (no disk persistence).
- Custom React Native marker views (
<Marker><View /></Marker>) are not supported.
| react-native-maps | react-native-better-maps |
|---|---|
image={require(...)} |
image={require(...)} |
anchor={{ x, y }} |
anchor={{ x, y }} |
centerOffset |
centerOffset |
rotation |
rotation |
flat |
flat |
opacity |
opacity |
| Custom RN child views | Not supported (use bitmap image) |
<Geojson> converts a GeoJSON object (or JSON string) into the existing marker, polyline, and polygon overlay pipeline. There is no native GeoJSON parser — conversion happens in JavaScript so overlay diffing stays shared.
import { MapView, Geojson, type GeojsonInput } from 'react-native-better-maps';
export function DeliveryMap({
deliveryZones,
}: {
deliveryZones: GeojsonInput;
}) {
return (
<MapView style={{ flex: 1 }}>
<Geojson
geojson={deliveryZones}
strokeColor="#FF3B30"
fillColor="#FF3B3044"
strokeWidth={2}
onPress={(feature) => console.log(feature.properties)}
/>
</MapView>
);
}| GeoJSON type | Rendered as |
|---|---|
Point / MultiPoint |
Marker(s) |
LineString / MultiLineString |
Polyline(s) |
Polygon / MultiPolygon |
Polygon(s) |
FeatureCollection / Feature / GeometryCollection |
Flattened into the types above |
Per-feature style follows the simplestyle property names used by react-native-maps: stroke, stroke-width, stroke-opacity, fill, fill-opacity, and marker-color. Marker titles use properties.title or properties.name; properties.zIndex overrides the component-level drawing order.
For large FeatureCollections, convert once with geojsonToOverlayDescriptors and pass the result to bulk markers / polylines / polygons props. Collections that expand to more than 1000 overlays log a development warning.
Not supported today: custom marker views, TopoJSON, and altitude (Z is dropped). Invalid GeoJSON is skipped with a development warning instead of crashing.
See docs/geojson.md for the full geometry, style, and limit notes.
| react-native-maps | react-native-better-maps |
|---|---|
<Geojson geojson={collection} /> |
Same |
color |
markerColor |
markerComponent |
Not supported (default markers) |
lineDashPattern |
Not supported |
zIndex |
Same |
onPress overlay event |
onPress(feature) with the source Feature |
| Polygon holes | Supported |
Host apps must provide platform API keys for the Google Maps SDK.
Add the config plugin to your app config:
{
"expo": {
"plugins": [
[
"react-native-better-maps",
{
"googleMapsApiKey": "YOUR_KEY_HERE"
}
]
]
}
}Use iosGoogleMapsApiKey and androidGoogleMapsApiKey when each platform needs a different restricted key.
The example app uses the config plugin. It reads GOOGLE_MAPS_IOS_API_KEY and GOOGLE_MAPS_ANDROID_API_KEY with GOOGLE_MAPS_API_KEY as a shared fallback.
On iOS, provider="google" needs two host-app settings that the config plugin normally writes together:
- Runtime API key —
GoogleMapsIosApiKeyinInfo.plist(read when the map mounts). - SDK linkage —
"betterMaps.iosGoogleProvider": "true"inios/Podfile.properties.json(read byreact-native-better-maps.podspecduringpod installto add theGoogleMapspod).
In a bare workflow the plugin does not run, so configure both manually:
<!-- Info.plist -->
<key>GoogleMapsIosApiKey</key>
<string>YOUR_API_KEY</string>{
"betterMaps.iosGoogleProvider": "true"
}Then run pod install from ios/.
- Android: add
com.google.android.geo.API_KEYmetadata toAndroidManifest.xml.
The google provider accepts googleMapId for Google Cloud Map ID styling:
<MapView provider="google" googleMapId="YOUR_MAP_ID" style={{ flex: 1 }} />googleMapId is creation-time configuration for native SDK views. Changing it remounts the native map view, matching provider changes.
MapView can configure native entering animations for markers and marker clusters:
<MapView
style={{ flex: 1 }}
clusteringEnabled
markerEnteringAnimation={{ preset: 'fade-scale', duration: 180 }}
clusterEnteringAnimation={{ preset: 'fade' }}
>
<Marker
coordinate={{ latitude: 52.2297, longitude: 21.0122 }}
enteringAnimation={false}
/>
</MapView>markerEnteringAnimation is the map-level default for all markers, including bulk markers descriptors. Marker.enteringAnimation and bulk marker enteringAnimation override that default for one marker; false is an explicit opt-out. clusterEnteringAnimation applies to marker clusters when clustering is enabled.
When no animation prop is set, the default is system: each provider keeps its native entering behavior. Explicit presets (fade, fade-scale) are the cross-provider contract. fade-scale may gracefully fall back to fade on SDK marker surfaces that do not support efficient scaling.
Explicit configs use milliseconds. duration defaults to 180, delay defaults to 0, and both values are clamped to 0..3000 before they reach the native provider. reduceMotion defaults to system, which disables explicit animations when the platform Reduced Motion setting asks for it. Use never only when the app intentionally ignores that setting for this overlay.
On Google Maps providers, marker and cluster entering animations can reduce UI-thread frame rate when a large viewport refresh adds many markers at once. The provider caps animated markers per refresh and may show the remaining markers immediately to preserve map gesture performance. For very large marker sets, prefer clustering, shorter durations, or markerEnteringAnimation={false} / clusterEnteringAnimation={false} when smooth gestures are more important than entrance motion.
Nitro compares view props by reference identity, so a prop rebuilt from unchanged data would still be re-serialized across JSI and re-applied to the native map. MapView guards against that on your behalf:
- Overlay arrays - whether they come from
<Marker />children or the bulkmarkers/polylines/polygons/circlesprops - are compared field by field. Passing a freshly built array with identical content costs one comparison and nothing else. markerEnteringAnimationandclusterEnteringAnimationare compared the same way, so an inline{ preset: 'fade' }object is fine.- Event handlers are wrapped once per handler identity rather than once per render, and the internal
hybridRefwrapper is created once per mount.
A re-render of the component holding the MapView therefore reaches native with no dirty props at all when nothing actually changed. What is left is yours to control: an arrow function created inline in JSX (onPress={() => …}) is a new handler on every render, so wrap it in useCallback if the surrounding component re-renders often.
Descriptors and the objects inside them are treated as immutable. Mutating a coordinate or a descriptor you already handed to MapView is not picked up, because the comparison sees the same object on both sides - build a new object instead:
// Not picked up - the same coordinate object is mutated in place.
marker.coordinate.latitude = 52.5;
// Picked up.
setMarkers((current) =>
current.map((m) =>
m.id === 'm1'
? { ...m, coordinate: { ...m.coordinate, latitude: 52.5 } }
: m,
),
);Every overlay child has an id: native code diffs overlays by it, and onMarkerPress, onMarkerDragEnd, onClusterPress, onPolylinePress, onPolygonPress and onCirclePress report it. MapView takes the first of:
- the
idprop, - the element's React
key, - the overlay's position among those of its kind that have neither:
marker-0,marker-1, …,polyline-0, … (geojson-0for a<Geojson>layer).
A keyed list therefore needs nothing more, and the callbacks hand back your own ids:
<MapView
style={{ flex: 1 }}
onMarkerPress={(id) => setSelected(stops.find((stop) => stop.id === id))}
>
{stops.map((stop) => (
<Marker key={stop.id} coordinate={stop.coordinate} title={stop.name} />
))}
</MapView>Removing a stop removes one marker and leaves the others alone. With positional ids, every marker after it would take over the id of the one before: it is redrawn, reported under another id, and entering animations play on the wrong marker. React does not warn about a list without keys here, because MapView never renders its children, so MapView warns once in development instead, when the number of overlays without an id or key changes.
Ids only have to be unique per kind: a marker and a polyline may share one. Keys only have to be unique within one list, so when two lists hand MapView the same key, the later overlay gets #2 appended ("42#2") and a development warning, rather than one of the two silently not being drawn. An id prop is used as given - a key or position that collides with it gets the suffix instead - and two overlays of one kind with the same id prop are reported in development, since only one of them is drawn.
A polyline, polygon or circle reports taps only when it is tappable. A <Polyline>, <Polygon> or <Circle> child with an onPress is tappable automatically. Anything else - a child without onPress, and every descriptor in the bulk polylines / polygons / circles props - needs tappable: true before onPolylinePress, onPolygonPress or onCirclePress fires for it:
<MapView
style={{ flex: 1 }}
circles={[
{
id: 'delivery-zone',
center: { latitude: 52.2297, longitude: 21.0122 },
radius: 1500,
tappable: true,
},
]}
onCirclePress={(id) => console.log('pressed', id)}
/>A tap on a shape that is not tappable passes through to the map and fires onPress.
Behavior change after 1.2.1: circles used to be tappable by default on Apple Maps and on Google Maps for Android, but not on Google Maps for iOS. They now default to not tappable on every provider, like polylines and polygons. If you handle presses on bulk
circles, or on<Circle>children withoutonPress, throughonCirclePress, addtappable: trueto them.
A coordinate that arrives as NaN or out of range is dropped instead of being forwarded to MapKit and the Google Maps SDK, which throw on it:
- An invalid
regionis ignored, and the map keeps the region it already had. - An invalid
camerais ignored the same way, and a pitch past the range the SDKs draw is pulled back to it rather than rejected. setCameraandanimateCamerareject an invalid camera instead of ignoring it, straight away and on both platforms - unlike a prop, they have a promise to report it on. A pitch past the drawable range is pulled back for them too.animateToRegionrejects an invalid region the same way.pointForCoordinateandcoordinateForPointreject a coordinate outside the world, or a point whosexoryis not finite, the same way.- An overlay whose coordinates, ring length or radius cannot be drawn is skipped; its neighbours still render.
- Anything supplied through
region,camera, the bulkmarkersprop, or a<Marker>/<Polyline>/<Polygon>/<Circle>child is reported throughconsole.warnin development.
Where the check runs depends on the entry point. region, camera, fitToCoordinates, the two point conversions and marker descriptors are guarded natively on both platforms, so a hybridRef call - setCamera, animateCamera and animateToRegion included - cannot reach the SDKs either. Polyline, polygon and circle descriptors are additionally filtered natively on Android, where an undrawable overlay throws inside the Fabric mount transaction and would otherwise take the whole screen down. Native skips are reported to logcat on Android rather than through console.warn.
One gap is worth knowing about: descriptors passed through the bulk polylines / polygons / circles props are checked only natively on Android - on iOS they reach MapKit and the Google Maps SDK unchecked, and neither platform warns about them in development.
Valid means: latitude and longitude finite and within ±90 / ±180, region deltas finite and greater than 0, camera zoom / heading / pitch / altitude finite when supplied (with zoom and heading also small enough for the 32-bit float the SDKs keep them in), two coordinates for a polyline, three per polygon ring, and a finite radius of at least 0 for a circle. A region whose span would run past a pole is pulled back to what the map can show rather than rejected.
An optional overlay field set to null - the way JSON data usually says "no value" - is treated as if it were left out: title: null, image: null or strokeColor: null behave like no title, no image and the default stroke, in overlay children and bulk props alike.
| Capability | apple iOS |
google iOS |
google Android |
|---|---|---|---|
| Region / camera | Supported | Supported | Supported |
| Camera animation | Supported | Supported | Supported |
| Animate to region | Supported | Supported | Supported |
| Visible region | Supported | Supported | Supported |
| Fit to coordinates | Supported | Supported | Supported |
| Screen point conversion | Supported | Supported | Supported |
| Region change events | Supported, with isGesture |
Supported, with isGesture |
Supported, with isGesture |
| Map types | Standard, satellite, hybrid; terrain falls back to standard | Standard, satellite, hybrid, terrain | Standard, satellite, hybrid, terrain |
| Gestures | Supported | Supported | Supported |
| User location | Supported; host app owns permission prompt | Supported; host app owns permission prompt | Supported; host app owns permission prompt |
| Follow user location | Supported | Supported | Unsupported; debug builds log a warning |
| Compass | Supported | Supported | Supported |
| Scale control | Supported | Unsupported | Unsupported; debug builds log a warning |
| Markers / overlays | Supported | Supported | Supported |
| Custom marker images | Supported | Supported | Supported |
| Marker callouts / dragging | Supported | Supported | Supported |
| Overlay press events | Supported | Supported | Supported |
| GeoJSON overlays | Supported (JS conversion) | Supported (JS conversion) | Supported (JS conversion) |
| Native POI press events | Supported on iOS 16+ | Supported | Supported |
| Native POI details | Supported on iOS 18+ (callout, sheet, Open in Maps) | Unsupported; taps stay event-only | Unsupported; taps stay event-only |
| Marker entering animation | System + fade, fade-scale |
System + fade; scale fallback |
System + fade; scale fallback |
| Cluster entering animation | System + fade, fade-scale |
System + fade; scale fallback |
System + fade; scale fallback |
| Clustering | Supported | Supported | Supported |
| Custom styles | Curated subset on iOS 16+ | Google Maps JSON styles | Google Maps JSON styles |
| Google Map ID | Unsupported | Supported | Supported |
| Component | Description |
|---|---|
MapView |
Root map container |
Marker |
Point annotation |
Polyline |
Line overlay |
Polygon |
Filled area overlay |
Circle |
Circular area overlay |
Geojson |
GeoJSON FeatureCollection overlay |
| Type | Description |
|---|---|
Coordinate |
{ latitude, longitude } |
Point |
{ x, y } in dp from the map view's top-left corner |
Region |
Center + span |
RegionChangeDetails |
{ isGesture } context for a region change |
Camera |
Position, zoom, heading, pitch |
MapType |
'standard' | 'satellite' | 'hybrid' | 'terrain' |
MapProvider |
'apple' | 'google' | 'openstreetmap' | 'mapbox' |
PoiPressEvent |
Provider-discriminated native POI press payload |
ApplePoiPressEvent |
Apple Maps POI payload with category |
GooglePoiPressEvent |
Google Maps POI payload with place ID |
ApplePoiCategory |
Known MapKit POI categories plus unknown |
ApplePoiDetailPresentation |
'automatic' | 'callout' | 'sheet' | 'openInMaps' |
MapViewRef |
Imperative handle for the camera and screen points |
MapViewProps |
Props for MapView |
MapViewPropsForProvider |
Provider-specific MapView props |
MarkerDescriptor |
Bulk marker descriptor |
MarkerProps |
Props for Marker |
MarkerImage |
Resolved marker image descriptor |
MarkerAnchor |
Anchor point on marker image (0..1) |
MarkerPoint |
Point offset in dp |
OverlayEnteringAnimation |
Marker / marker-cluster entering animation config |
PolylineProps |
Props for Polyline |
PolygonProps |
Props for Polygon |
CircleProps |
Props for Circle |
GeojsonProps |
Props for Geojson |
GeojsonFeature |
Feature passed to Geojson onPress |
GeojsonOverlayDescriptors |
Result of geojsonToOverlayDescriptors |
| Function | Description |
|---|---|
regionFromCoordinate(coord, latDelta?, lonDelta?) |
Create a Region from a coordinate |
distanceBetween(a, b) |
Haversine distance in meters |
geojsonToOverlayDescriptors(geojson, options?) |
Convert GeoJSON into bulk overlay descriptors |
bun install
bun run example startThe example app lives in example. It demonstrates provider switching, overlays, GeoJSON FeatureCollections, clustering, Google Map IDs, entering animation presets, and native POI tap logging.
For Google Maps in the example app, configure one shared key or platform-specific keys:
GOOGLE_MAPS_API_KEY=your_key
GOOGLE_MAPS_IOS_API_KEY=your_ios_key
GOOGLE_MAPS_ANDROID_API_KEY=your_android_keySee example/.env.example for the supported environment variables.
- Expo setup
- Architecture
- GeoJSON overlays
- Roadmap
- Contributing
- Code of conduct
- Security policy
- Releasing
- ADRs
| Problem | Solution |
|---|---|
| Map is blank when using Google Maps | Add a Google Maps API key through the Expo config plugin, GoogleMapsIosApiKey in Info.plist, or com.google.android.geo.API_KEY in AndroidManifest.xml. |
| iOS Google Maps key set but provider errors | iOS needs both GoogleMapsIosApiKey in Info.plist and "betterMaps.iosGoogleProvider": "true" in Podfile.properties.json, then pod install. The config plugin sets both; in bare workflow or with a stale Podfile.properties.json, they can drift apart. |
| New Architecture errors | Confirm React Native 0.78+, New Architecture, and react-native-nitro-modules are installed, then rebuild the native app. |
| Provider throws before rendering | Check the supported platforms table. openstreetmap and mapbox are reserved for future support but do not render yet. |
| Expo Go does not load native maps | Use a development build after expo prebuild; native Nitro modules are not available in Expo Go. |
| Marker animations affect gesture smoothness | For very large marker sets, prefer clustering, shorter durations, or disable marker/cluster entering animations. |
MapView is not mounted from a ref call |
The map view has unmounted. Calls made before the native map exists are held and replayed, so a freshly mounted map is not the cause. |
# Install dependencies
bun install
# Build the library
bun run build
# Run linting and type checks
bun run lint
bun run typecheck
bun run typecheck:provider-types
# Regenerate Nitro bindings after spec changes
bun run nitrogen
# Start the example app
bun run example startSee CONTRIBUTING.md for contribution guidelines.
The release surface focuses on Apple MapKit and Google Maps SDK providers. Follow-up work is tracked in docs/roadmap.md, including additional providers, expanded migration docs, and offline tile support.
MIT - see LICENSE.




