React Native Maps: Custom Markers, Clustering, and Directions API Integration
Build production React Native map features with react-native-maps. Covers custom markers, marker clustering with supercluster, polyline directions, region tracking, and Google Maps vs Apple Maps configuration.
Maps are table stakes for location-aware mobile apps โ delivery tracking, store finders, field service dispatch, property search. React Native Maps wraps native map SDKs (Google Maps on Android, Apple Maps/Google Maps on iOS) with a React component interface. Getting it right means handling the edge cases: custom marker rendering without re-rendering the entire map, clustering thousands of pins efficiently, and drawing accurate routes from the Directions API.
This guide covers the full react-native-maps production stack.
Installation and Configuration
npx expo install react-native-maps
For bare React Native:
npm install react-native-maps
npx pod-install
iOS Configuration
# ios/Podfile
# react-native-maps uses Apple Maps by default on iOS
# To use Google Maps on iOS, add the GoogleMaps pod:
pod 'GoogleMaps'
pod 'Google-Maps-iOS-Utils'
// ios/AppDelegate.swift
import GoogleMaps
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
GMSServices.provideAPIKey("YOUR_GOOGLE_MAPS_IOS_KEY")
return true
}
Android Configuration
<!-- android/app/src/main/AndroidManifest.xml -->
<application>
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="YOUR_GOOGLE_MAPS_ANDROID_KEY" />
</application>
Expo Configuration
// app.json
{
"expo": {
"plugins": [
[
"react-native-maps",
{
"googleMapsApiKey": "YOUR_EXPO_GO_KEY"
}
]
],
"ios": {
"config": {
"googleMapsApiKey": "YOUR_IOS_KEY"
}
},
"android": {
"config": {
"googleMaps": {
"apiKey": "YOUR_ANDROID_KEY"
}
}
}
}
}
Basic Map with Region Control
// components/maps/BaseMap.tsx
import React, { useRef, useCallback } from "react";
import { StyleSheet, View } from "react-native";
import MapView, {
PROVIDER_GOOGLE,
Region,
MapViewProps,
} from "react-native-maps";
const DEFAULT_REGION: Region = {
latitude: 37.7749,
longitude: -122.4194,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
};
interface BaseMapProps extends Partial<MapViewProps> {
children?: React.ReactNode;
onRegionChange?: (region: Region) => void;
}
export function BaseMap({ children, onRegionChange, ...props }: BaseMapProps) {
const mapRef = useRef<MapView>(null);
const animateTo = useCallback((region: Region) => {
mapRef.current?.animateToRegion(region, 500);
}, []);
return (
<MapView
ref={mapRef}
style={StyleSheet.absoluteFillObject}
provider={PROVIDER_GOOGLE} // Remove for Apple Maps on iOS
initialRegion={DEFAULT_REGION}
showsUserLocation
showsMyLocationButton
showsCompass
rotateEnabled={false} // Simpler UX for most apps
toolbarEnabled={false} // Hides Android navigation buttons
onRegionChangeComplete={onRegionChange}
mapType="standard"
userInterfaceStyle="light"
{...props}
>
{children}
</MapView>
);
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Custom Markers
The key performance rule: never put complex components inside <Marker>. Use <Marker> with a callout and keep the marker view lightweight โ or use image prop with a pre-rendered bitmap.
// components/maps/LocationMarker.tsx
import React, { memo } from "react";
import { View, Text, StyleSheet } from "react-native";
import { Marker, Callout } from "react-native-maps";
export interface Location {
id: string;
latitude: number;
longitude: number;
title: string;
subtitle?: string;
type: "store" | "warehouse" | "office" | "pickup";
isOpen?: boolean;
}
const MARKER_COLORS: Record<Location["type"], string> = {
store: "#3b82f6",
warehouse: "#f59e0b",
office: "#8b5cf6",
pickup: "#10b981",
};
interface LocationMarkerProps {
location: Location;
onPress?: (location: Location) => void;
}
// memo is critical โ prevents re-render when parent map state changes
export const LocationMarker = memo(function LocationMarker({
location,
onPress,
}: LocationMarkerProps) {
const color = MARKER_COLORS[location.type];
return (
<Marker
coordinate={{ latitude: location.latitude, longitude: location.longitude }}
identifier={location.id}
tracksViewChanges={false} // CRITICAL: set false after initial render to prevent jank
onPress={() => onPress?.(location)}
>
{/* Custom marker view */}
<View style={[styles.markerContainer, { borderColor: color }]}>
<View style={[styles.markerDot, { backgroundColor: color }]}>
<Text style={styles.markerIcon}>{getMarkerIcon(location.type)}</Text>
</View>
{/* Status indicator */}
{location.isOpen !== undefined && (
<View
style={[
styles.statusDot,
{ backgroundColor: location.isOpen ? "#10b981" : "#ef4444" },
]}
/>
)}
</View>
{/* Callout (info window shown on tap) */}
<Callout tooltip onPress={() => onPress?.(location)}>
<View style={styles.callout}>
<Text style={styles.calloutTitle}>{location.title}</Text>
{location.subtitle && (
<Text style={styles.calloutSubtitle}>{location.subtitle}</Text>
)}
<Text style={[styles.calloutStatus, { color: location.isOpen ? "#10b981" : "#ef4444" }]}>
{location.isOpen ? "Open" : "Closed"}
</Text>
</View>
</Callout>
</Marker>
);
});
function getMarkerIcon(type: Location["type"]): string {
const icons = { store: "๐ช", warehouse: "๐ญ", office: "๐ข", pickup: "๐ฆ" };
return icons[type];
}
const styles = StyleSheet.create({
markerContainer: {
position: "relative",
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 2,
backgroundColor: "white",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 5,
},
markerDot: {
width: 30,
height: 30,
borderRadius: 15,
justifyContent: "center",
alignItems: "center",
},
markerIcon: { fontSize: 16 },
statusDot: {
position: "absolute",
top: -2,
right: -2,
width: 12,
height: 12,
borderRadius: 6,
borderWidth: 2,
borderColor: "white",
},
callout: {
backgroundColor: "white",
borderRadius: 10,
padding: 12,
minWidth: 160,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 4,
},
calloutTitle: { fontSize: 15, fontWeight: "600", color: "#111827" },
calloutSubtitle: { fontSize: 13, color: "#6b7280", marginTop: 2 },
calloutStatus: { fontSize: 12, fontWeight: "500", marginTop: 4 },
});
Marker Clustering with Supercluster
When you have hundreds of markers, clustering groups nearby pins at lower zoom levels.
npm install supercluster @types/supercluster
// hooks/useMapClustering.ts
import { useState, useCallback, useMemo } from "react";
import Supercluster from "supercluster";
import { Region } from "react-native-maps";
import type { Location } from "@/components/maps/LocationMarker";
interface ClusterFeature {
type: "Feature";
properties: {
cluster: true;
cluster_id: number;
point_count: number;
point_count_abbreviated: string;
} | {
cluster: false;
location: Location;
};
geometry: {
type: "Point";
coordinates: [number, number]; // [longitude, latitude]
};
}
function regionToBBox(region: Region): [number, number, number, number] {
return [
region.longitude - region.longitudeDelta / 2,
region.latitude - region.latitudeDelta / 2,
region.longitude + region.longitudeDelta / 2,
region.latitude + region.latitudeDelta / 2,
];
}
function regionToZoom(region: Region): number {
// Approximate zoom level from latitudeDelta
return Math.round(Math.log(360 / region.longitudeDelta) / Math.LN2);
}
export function useMapClustering(locations: Location[]) {
const [region, setRegion] = useState<Region | null>(null);
const supercluster = useMemo(() => {
const sc = new Supercluster<{ location: Location }>({
radius: 60, // pixels
maxZoom: 16,
minZoom: 0,
minPoints: 2, // Minimum points to form a cluster
});
sc.load(
locations.map((loc) => ({
type: "Feature" as const,
properties: { location: loc },
geometry: {
type: "Point" as const,
coordinates: [loc.longitude, loc.latitude],
},
}))
);
return sc;
}, [locations]);
const clusters = useMemo((): ClusterFeature[] => {
if (!region) return [];
const bbox = regionToBBox(region);
const zoom = regionToZoom(region);
return supercluster.getClusters(bbox, zoom) as ClusterFeature[];
}, [supercluster, region]);
const expandCluster = useCallback(
(clusterId: number): Region => {
const expansionZoom = Math.min(
supercluster.getClusterExpansionZoom(clusterId),
16
);
const [longitude, latitude] = supercluster.getCluster(clusterId)
.geometry.coordinates;
const delta = 360 / Math.pow(2, expansionZoom);
return {
latitude,
longitude,
latitudeDelta: delta * 0.5,
longitudeDelta: delta * 0.5,
};
},
[supercluster]
);
return { clusters, setRegion, expandCluster };
}
// components/maps/ClusteredMap.tsx
import React, { useRef } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import MapView, { Marker, PROVIDER_GOOGLE, Region } from "react-native-maps";
import { useMapClustering } from "@/hooks/useMapClustering";
import { LocationMarker } from "./LocationMarker";
import type { Location } from "./LocationMarker";
interface ClusteredMapProps {
locations: Location[];
onLocationPress?: (location: Location) => void;
}
export function ClusteredMap({ locations, onLocationPress }: ClusteredMapProps) {
const mapRef = useRef<MapView>(null);
const { clusters, setRegion, expandCluster } = useMapClustering(locations);
const handleClusterPress = (clusterId: number) => {
const newRegion = expandCluster(clusterId);
mapRef.current?.animateToRegion(newRegion, 400);
};
return (
<MapView
ref={mapRef}
style={StyleSheet.absoluteFillObject}
provider={PROVIDER_GOOGLE}
showsUserLocation
onRegionChangeComplete={setRegion}
>
{clusters.map((cluster) => {
const [longitude, latitude] = cluster.geometry.coordinates;
const props = cluster.properties;
if (props.cluster) {
// Render cluster marker
return (
<Marker
key={`cluster-${props.cluster_id}`}
coordinate={{ latitude, longitude }}
tracksViewChanges={false}
onPress={() => handleClusterPress(props.cluster_id)}
>
<View style={styles.cluster}>
<Text style={styles.clusterText}>
{props.point_count_abbreviated}
</Text>
</View>
</Marker>
);
}
// Render individual location marker
return (
<LocationMarker
key={props.location.id}
location={props.location}
onPress={onLocationPress}
/>
);
})}
</MapView>
);
}
const styles = StyleSheet.create({
cluster: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "#3b82f6",
borderWidth: 3,
borderColor: "white",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 5,
},
clusterText: { color: "white", fontWeight: "700", fontSize: 14 },
});
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Directions API: Draw Routes
// services/directions.ts
export interface DirectionsResult {
polylinePoints: Array<{ latitude: number; longitude: number }>;
distance: string;
duration: string;
steps: Array<{
instruction: string;
distance: string;
duration: string;
startLocation: { lat: number; lng: number };
}>;
}
export async function getDirections(
origin: { latitude: number; longitude: number },
destination: { latitude: number; longitude: number },
mode: "driving" | "walking" | "bicycling" | "transit" = "driving"
): Promise<DirectionsResult> {
const apiKey = process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY!;
const params = new URLSearchParams({
origin: `${origin.latitude},${origin.longitude}`,
destination: `${destination.latitude},${destination.longitude}`,
mode,
key: apiKey,
alternatives: "false",
units: "imperial",
});
const response = await fetch(
`https://maps.googleapis.com/maps/api/directions/json?${params}`
);
const data = await response.json();
if (data.status !== "OK" || !data.routes.length) {
throw new Error(`Directions API error: ${data.status}`);
}
const route = data.routes[0];
const leg = route.legs[0];
return {
polylinePoints: decodePolyline(route.overview_polyline.points),
distance: leg.distance.text,
duration: leg.duration.text,
steps: leg.steps.map((step: any) => ({
instruction: step.html_instructions.replace(/<[^>]*>/g, ""),
distance: step.distance.text,
duration: step.duration.text,
startLocation: step.start_location,
})),
};
}
// Decode Google's encoded polyline format
function decodePolyline(
encoded: string
): Array<{ latitude: number; longitude: number }> {
const points: Array<{ latitude: number; longitude: number }> = [];
let index = 0;
let lat = 0;
let lng = 0;
while (index < encoded.length) {
let b: number;
let shift = 0;
let result = 0;
do {
b = encoded.charCodeAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
const dlat = (result & 1) !== 0 ? ~(result >> 1) : result >> 1;
lat += dlat;
shift = 0;
result = 0;
do {
b = encoded.charCodeAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
const dlng = (result & 1) !== 0 ? ~(result >> 1) : result >> 1;
lng += dlng;
points.push({ latitude: lat / 1e5, longitude: lng / 1e5 });
}
return points;
}
// components/maps/RouteMap.tsx
import React, { useState, useEffect, useCallback } from "react";
import { View, Text, StyleSheet, ActivityIndicator } from "react-native";
import MapView, { Polyline, PROVIDER_GOOGLE, Marker } from "react-native-maps";
import { getDirections, type DirectionsResult } from "@/services/directions";
interface RouteMapProps {
origin: { latitude: number; longitude: number; label?: string };
destination: { latitude: number; longitude: number; label?: string };
travelMode?: "driving" | "walking" | "bicycling";
}
const ROUTE_COLORS = {
driving: "#3b82f6",
walking: "#10b981",
bicycling: "#f59e0b",
};
export function RouteMap({ origin, destination, travelMode = "driving" }: RouteMapProps) {
const [route, setRoute] = useState<DirectionsResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchRoute = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await getDirections(origin, destination, travelMode);
setRoute(result);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load route");
} finally {
setLoading(false);
}
}, [origin, destination, travelMode]);
useEffect(() => {
void fetchRoute();
}, [fetchRoute]);
// Calculate bounding region to fit both markers
const boundingRegion = {
latitude: (origin.latitude + destination.latitude) / 2,
longitude: (origin.longitude + destination.longitude) / 2,
latitudeDelta: Math.abs(origin.latitude - destination.latitude) * 1.5 + 0.01,
longitudeDelta: Math.abs(origin.longitude - destination.longitude) * 1.5 + 0.01,
};
return (
<View style={styles.container}>
<MapView
style={StyleSheet.absoluteFillObject}
provider={PROVIDER_GOOGLE}
initialRegion={boundingRegion}
>
{/* Origin marker */}
<Marker
coordinate={origin}
title={origin.label ?? "Start"}
tracksViewChanges={false}
pinColor="green"
/>
{/* Destination marker */}
<Marker
coordinate={destination}
title={destination.label ?? "Destination"}
tracksViewChanges={false}
pinColor="red"
/>
{/* Route polyline */}
{route && (
<Polyline
coordinates={route.polylinePoints}
strokeColor={ROUTE_COLORS[travelMode]}
strokeWidth={4}
lineDashPattern={travelMode === "walking" ? [10, 5] : undefined}
geodesic
/>
)}
</MapView>
{/* Route info overlay */}
{route && (
<View style={styles.infoCard}>
<Text style={styles.infoTime}>{route.duration}</Text>
<Text style={styles.infoDist}>{route.distance}</Text>
<Text style={styles.infoMode}>{travelMode}</Text>
</View>
)}
{loading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="small" color="#3b82f6" />
<Text style={styles.loadingText}>Getting directions...</Text>
</View>
)}
{error && (
<View style={styles.errorBanner}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
infoCard: {
position: "absolute",
bottom: 32,
left: 16,
right: 16,
backgroundColor: "white",
borderRadius: 12,
padding: 16,
flexDirection: "row",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
infoTime: { fontSize: 22, fontWeight: "700", color: "#111827", marginRight: 8 },
infoDist: { fontSize: 16, color: "#6b7280", flex: 1 },
infoMode: { fontSize: 12, color: "#9ca3af", textTransform: "capitalize" },
loadingOverlay: {
position: "absolute",
top: 16,
alignSelf: "center",
backgroundColor: "white",
borderRadius: 8,
padding: 12,
flexDirection: "row",
alignItems: "center",
gap: 8,
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
loadingText: { fontSize: 14, color: "#374151" },
errorBanner: {
position: "absolute",
top: 16,
left: 16,
right: 16,
backgroundColor: "#fee2e2",
borderRadius: 8,
padding: 12,
},
errorText: { color: "#dc2626", fontSize: 13, textAlign: "center" },
});
Live Location Tracking
// hooks/useLocationTracking.ts
import { useState, useEffect, useRef } from "react";
import * as Location from "expo-location";
import MapView from "react-native-maps";
interface TrackingState {
location: Location.LocationObject | null;
error: string | null;
isTracking: boolean;
}
export function useLocationTracking(mapRef: React.RefObject<MapView>) {
const [state, setState] = useState<TrackingState>({
location: null,
error: null,
isTracking: false,
});
const subscriptionRef = useRef<Location.LocationSubscription | null>(null);
const startTracking = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") {
setState((s) => ({ ...s, error: "Location permission denied" }));
return;
}
setState((s) => ({ ...s, isTracking: true, error: null }));
subscriptionRef.current = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.High,
timeInterval: 3000, // Update every 3 seconds
distanceInterval: 10, // Or every 10 meters
},
(location) => {
setState((s) => ({ ...s, location }));
// Follow user on map
mapRef.current?.animateToRegion(
{
latitude: location.coords.latitude,
longitude: location.coords.longitude,
latitudeDelta: 0.005,
longitudeDelta: 0.005,
},
300
);
}
);
};
const stopTracking = () => {
subscriptionRef.current?.remove();
subscriptionRef.current = null;
setState((s) => ({ ...s, isTracking: false }));
};
useEffect(() => {
return () => subscriptionRef.current?.remove();
}, []);
return { ...state, startTracking, stopTracking };
}
Performance Checklist
| Issue | Cause | Fix |
|---|---|---|
| Map freezes when scrolling list | Map re-renders on parent state changes | Wrap MapView in React.memo or move to separate screen |
| Markers flicker | tracksViewChanges={true} (default) | Set tracksViewChanges={false} after initial render |
| Slow with 500+ markers | Individual <Marker> components | Use supercluster clustering |
| High memory on iOS | Keeping many map screens mounted | Unmount maps when navigating away |
| callout tap not working on Android | Known react-native-maps issue | Use onPress on Marker instead of Callout |
| Map blank on Android | Missing API key or SHA-1 mismatch | Verify Google Cloud Console key restrictions |
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic map with static markers | 1 dev | 1 day | $300โ600 |
| Custom markers + callouts + region tracking | 1 dev | 2โ3 days | $600โ1,200 |
| Clustering + directions + live tracking | 1โ2 devs | 1โ2 weeks | $2,500โ5,000 |
| Full delivery/logistics map (real-time multi-driver) | 2โ3 devs | 3โ5 weeks | $8,000โ20,000 |
Google Maps API costs (2027): Static Maps $2/1K, Directions $5/1K, Maps JS $7/1K. Use session-based pricing for Places Autocomplete. Budget ~$100โ500/month for a typical consumer app.
See Also
- React Native Gesture Handler: Swipe, Drag, Pinch
- React Native Animations with Reanimated 3
- React Native Offline Sync with WatermelonDB
- React Native Navigation with Expo Router
- React Native Camera and QR Scanning
Working With Viprasol
Location features look straightforward until you're debugging marker flicker on iOS 17, handling the Google Maps API key restrictions for different build environments, or optimising cluster re-renders for 10,000 delivery pins. Our mobile team has built map-heavy apps for logistics, field service, and property platforms โ with the performance and UX that users expect from native apps.
What we deliver:
- Custom marker design rendered efficiently with
tracksViewChanges={false} - Supercluster integration with smooth zoom-to-expand
- Directions API integration with polyline decoding and turn-by-turn steps
- Live location tracking with background location for delivery apps
- Google Maps API key setup and cost monitoring
Talk to our team about your location-based app โ
Or explore our mobile development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.