Back to Blog

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.

Viprasol Tech Team
March 16, 2027
13 min read

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

IssueCauseFix
Map freezes when scrolling listMap re-renders on parent state changesWrap MapView in React.memo or move to separate screen
Markers flickertracksViewChanges={true} (default)Set tracksViewChanges={false} after initial render
Slow with 500+ markersIndividual <Marker> componentsUse supercluster clustering
High memory on iOSKeeping many map screens mountedUnmount maps when navigating away
callout tap not working on AndroidKnown react-native-maps issueUse onPress on Marker instead of Callout
Map blank on AndroidMissing API key or SHA-1 mismatchVerify Google Cloud Console key restrictions

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Basic map with static markers1 dev1 day$300โ€“600
Custom markers + callouts + region tracking1 dev2โ€“3 days$600โ€“1,200
Clustering + directions + live tracking1โ€“2 devs1โ€“2 weeks$2,500โ€“5,000
Full delivery/logistics map (real-time multi-driver)2โ€“3 devs3โ€“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


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol ยท Web Development

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.