Development

Flutter Google Maps Integration: Geolocation & Custom Markers Guide

Muhammad Shakil Muhammad Shakil
Apr 08, 2026
18 min read
Flutter Google Maps integration with custom markers and geolocation
Back to Blog

Every ride-sharing app, delivery tracker, and real estate listing started with the same question: how do I put a map in my Flutter app? The answer is google_maps_flutter — Google's official plugin. It's well-maintained, uses native platform views for smooth performance, and supports everything from basic pin drops to custom-styled maps with thousands of clustered markers.

I've shipped maps in production apps for food delivery, ride-hailing, and property listing platforms. The plugin itself is straightforward. The complexity comes from everything around it: API key security, location permissions, custom marker rendering, and keeping API costs under control. This guide covers the full stack — from Google Cloud Console setup to production-ready map screens.

What You'll Learn

1. Google Maps API Setup

Before writing any Flutter code, you need a Google Maps API key. Skip this and you'll get a grey screen.

Step 1: Create a Project in Google Cloud Console

Go to Google Cloud Console. Create a new project or select an existing one. Enable billing — the Maps SDK requires a billing account even though the free tier is generous ($200/month credit).

Step 2: Enable Required APIs

Navigate to APIs & Services → Library and enable:

Step 3: Create and Restrict API Keys

Go to APIs & Services → Credentials → Create Credentials → API Key. Create separate keys for Android, iOS, and web — never share one key across platforms:

# Android key restriction:
# Application restriction: Android apps
# Package name: com.yourcompany.yourapp
# SHA-1 fingerprint: from your debug/release keystore

# iOS key restriction:
# Application restriction: iOS apps
# Bundle ID: com.yourcompany.yourapp

# Web key restriction:
# Application restriction: HTTP referrers
# Referrer: https://yourapp.com/*

Never Commit API Keys to Git

Store API keys in environment variables or a .env file excluded from version control. On Android, reference them from local.properties. On iOS, use an xcconfig file. Leaked API keys can run up thousands of dollars in charges.

2. Platform Configuration (Android & iOS)

Android Setup

Add your API key to android/app/src/main/AndroidManifest.xml:

<manifest ...>
    <application ...>
        <meta-data
            android:name="com.google.android.geo.API_KEY"
            android:value="${MAPS_API_KEY}" />
    </application>
</manifest>

Define MAPS_API_KEY in android/local.properties:

MAPS_API_KEY=AIzaSyYourActualKeyHere

Reference it in android/app/build.gradle:

android {
    defaultConfig {
        manifestPlaceholders += [MAPS_API_KEY: localProperties.getProperty('MAPS_API_KEY', '')]
    }
}

Set the minimum SDK to 21 in android/app/build.gradle:

android {
    defaultConfig {
        minSdk = 21
    }
}

iOS Setup

Add the API key to ios/Runner/AppDelegate.swift:

import GoogleMaps

@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GMSServices.provideAPIKey("YOUR_IOS_API_KEY")
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

3. Displaying a Basic Map

// pubspec.yaml
dependencies:
  google_maps_flutter: ^2.10.0
import 'package:google_maps_flutter/google_maps_flutter.dart';

class MapScreen extends StatefulWidget {
  const MapScreen({super.key});

  @override
  State<MapScreen> createState() => _MapScreenState();
}

class _MapScreenState extends State<MapScreen> {
  GoogleMapController? _controller;

  static const _initialPosition = CameraPosition(
    target: LatLng(31.4504, 73.1350), // Faisalabad, Pakistan
    zoom: 14,
  );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Map')),
      body: GoogleMap(
        initialCameraPosition: _initialPosition,
        onMapCreated: (controller) => _controller = controller,
        myLocationEnabled: true,
        myLocationButtonEnabled: true,
        zoomControlsEnabled: false,
        mapToolbarEnabled: false,
      ),
    );
  }

  @override
  void dispose() {
    _controller?.dispose();
    super.dispose();
  }
}

4. Custom Markers and Info Windows

The default red pin works for prototypes. Production apps need branded markers — custom icons, colors, and info windows.

Asset-Based Custom Markers

class _MapScreenState extends State<MapScreen> {
  Set<Marker> _markers = {};
  BitmapDescriptor? _customIcon;

  @override
  void initState() {
    super.initState();
    _loadCustomIcon();
  }

  Future<void> _loadCustomIcon() async {
    _customIcon = await BitmapDescriptor.asset(
      const ImageConfiguration(size: Size(48, 48)),
      'assets/images/custom_pin.png',
    );
    _addMarkers();
  }

  void _addMarkers() {
    setState(() {
      _markers = {
        Marker(
          markerId: const MarkerId('restaurant_1'),
          position: const LatLng(31.4504, 73.1350),
          icon: _customIcon ?? BitmapDescriptor.defaultMarker,
          infoWindow: const InfoWindow(
            title: 'Café Luna',
            snippet: '4.5 ★ • Open until 11 PM',
          ),
          onTap: () => _onMarkerTapped('restaurant_1'),
        ),
        Marker(
          markerId: const MarkerId('restaurant_2'),
          position: const LatLng(31.4520, 73.1380),
          icon: _customIcon ?? BitmapDescriptor.defaultMarker,
          infoWindow: const InfoWindow(
            title: 'The Grill House',
            snippet: '4.2 ★ • Open until 10 PM',
          ),
        ),
      };
    });
  }

  void _onMarkerTapped(String id) {
    // Navigate to detail screen or show bottom sheet
  }
}

Widget-to-Marker Conversion

For truly custom markers (avatars, price tags, status indicators), convert a Flutter widget to a bitmap:

import 'dart:ui' as ui;

Future<BitmapDescriptor> createCustomMarkerFromWidget({
  required String label,
  required Color color,
  double size = 100,
}) async {
  final recorder = ui.PictureRecorder();
  final canvas = Canvas(recorder);
  final paint = Paint()..color = color;

  // Draw circle background
  canvas.drawCircle(Offset(size / 2, size / 2), size / 2, paint);

  // Draw label text
  final textPainter = TextPainter(
    text: TextSpan(
      text: label,
      style: const TextStyle(
        color: Colors.white,
        fontSize: 28,
        fontWeight: FontWeight.bold,
      ),
    ),
    textDirection: TextDirection.ltr,
  )..layout();

  textPainter.paint(
    canvas,
    Offset(
      (size - textPainter.width) / 2,
      (size - textPainter.height) / 2,
    ),
  );

  // Draw pointer triangle
  final trianglePaint = Paint()..color = color;
  final path = Path()
    ..moveTo(size / 2 - 15, size)
    ..lineTo(size / 2 + 15, size)
    ..lineTo(size / 2, size + 20)
    ..close();
  canvas.drawPath(path, trianglePaint);

  final picture = recorder.endRecording();
  final image = await picture.toImage(size.toInt(), (size + 20).toInt());
  final bytes = await image.toByteData(format: ui.ImageByteFormat.png);

  return BitmapDescriptor.bytes(bytes!.buffer.asUint8List());
}

5. User Geolocation and Permissions

The geolocator package handles both permission requests and location retrieval. Never request location on app startup — wait until the user does something that needs it.

// pubspec.yaml
dependencies:
  geolocator: ^13.0.0
class LocationService {
  /// Returns current position or null if permission denied/unavailable.
  Future<Position?> getCurrentLocation() async {
    // 1. Check if location services are enabled
    final serviceEnabled = await Geolocator.isLocationServiceEnabled();
    if (!serviceEnabled) return null;

    // 2. Check permission status
    var permission = await Geolocator.checkPermission();

    if (permission == LocationPermission.denied) {
      permission = await Geolocator.requestPermission();
      if (permission == LocationPermission.denied) return null;
    }

    if (permission == LocationPermission.deniedForever) {
      // User permanently denied — open settings
      await Geolocator.openAppSettings();
      return null;
    }

    // 3. Get current position
    return Geolocator.getCurrentPosition(
      locationSettings: const LocationSettings(
        accuracy: LocationAccuracy.high,
        distanceFilter: 10, // Meters before update
      ),
    );
  }

  /// Stream of position updates for real-time tracking.
  Stream<Position> getLocationStream() {
    return Geolocator.getPositionStream(
      locationSettings: const LocationSettings(
        accuracy: LocationAccuracy.high,
        distanceFilter: 10,
      ),
    );
  }
}

Platform Permissions

Add location permission descriptions to the platform configs:

<!-- Android: android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- iOS: ios/Runner/Info.plist -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>We need your location to show nearby restaurants on the map.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>We need background location access to track your delivery in real time.</string>

6. Drawing Polylines and Polygons

Polylines draw routes on the map. Polygons define areas (delivery zones, service areas).

class RouteMapScreen extends StatefulWidget {
  const RouteMapScreen({super.key});

  @override
  State<RouteMapScreen> createState() => _RouteMapScreenState();
}

class _RouteMapScreenState extends State<RouteMapScreen> {
  Set<Polyline> _polylines = {};
  Set<Polygon> _polygons = {};

  @override
  void initState() {
    super.initState();
    _drawRoute();
    _drawDeliveryZone();
  }

  void _drawRoute() {
    _polylines = {
      Polyline(
        polylineId: const PolylineId('route_1'),
        points: const [
          LatLng(31.4504, 73.1350),
          LatLng(31.4520, 73.1380),
          LatLng(31.4550, 73.1400),
          LatLng(31.4580, 73.1420),
        ],
        color: Colors.blue,
        width: 5,
        patterns: [PatternItem.dash(20), PatternItem.gap(10)],
      ),
    };
  }

  void _drawDeliveryZone() {
    _polygons = {
      Polygon(
        polygonId: const PolygonId('zone_1'),
        points: const [
          LatLng(31.4450, 73.1300),
          LatLng(31.4450, 73.1450),
          LatLng(31.4600, 73.1450),
          LatLng(31.4600, 73.1300),
        ],
        fillColor: Colors.blue.withValues(alpha: 0.1),
        strokeColor: Colors.blue,
        strokeWidth: 2,
      ),
    };
  }

  @override
  Widget build(BuildContext context) {
    return GoogleMap(
      initialCameraPosition: const CameraPosition(
        target: LatLng(31.4520, 73.1380),
        zoom: 14,
      ),
      polylines: _polylines,
      polygons: _polygons,
    );
  }
}

7. Geocoding: Coordinates to Addresses

Geocoding converts coordinates to human-readable addresses (reverse geocoding) and vice versa. The geocoding package handles this without a separate API key:

// pubspec.yaml
dependencies:
  geocoding: ^3.0.0
import 'package:geocoding/geocoding.dart';

class GeocodingService {
  /// Reverse geocode: LatLng → Address
  Future<String?> getAddressFromCoordinates(double lat, double lng) async {
    try {
      final placemarks = await placemarkFromCoordinates(lat, lng);
      if (placemarks.isEmpty) return null;

      final place = placemarks.first;
      return [
        place.street,
        place.subLocality,
        place.locality,
        place.administrativeArea,
        place.country,
      ].where((s) => s != null && s.isNotEmpty).join(', ');
    } catch (e) {
      return null;
    }
  }

  /// Forward geocode: Address → LatLng
  Future<LatLng?> getCoordinatesFromAddress(String address) async {
    try {
      final locations = await locationFromAddress(address);
      if (locations.isEmpty) return null;
      return LatLng(locations.first.latitude, locations.first.longitude);
    } catch (e) {
      return null;
    }
  }
}

For address search with autocomplete suggestions, use the Google Places API through HTTP requests:

class PlacesService {
  final Dio _dio;
  final String _apiKey;

  PlacesService(this._dio, this._apiKey);

  Future<List<PlacePrediction>> getAutocomplete(String query) async {
    if (query.length < 3) return [];

    final response = await _dio.get(
      'https://maps.googleapis.com/maps/api/place/autocomplete/json',
      queryParameters: {
        'input': query,
        'key': _apiKey,
        'types': 'geocode|establishment',
        'language': 'en',
        'components': 'country:pk', // Restrict to Pakistan
      },
    );

    final predictions = response.data['predictions'] as List;
    return predictions
        .map((p) => PlacePrediction(
              placeId: p['place_id'],
              description: p['description'],
              mainText: p['structured_formatting']['main_text'],
            ))
        .toList();
  }

  Future<LatLng?> getPlaceDetails(String placeId) async {
    final response = await _dio.get(
      'https://maps.googleapis.com/maps/api/place/details/json',
      queryParameters: {
        'place_id': placeId,
        'key': _apiKey,
        'fields': 'geometry',
      },
    );

    final location = response.data['result']?['geometry']?['location'];
    if (location == null) return null;
    return LatLng(location['lat'], location['lng']);
  }
}

class PlacePrediction {
  final String placeId;
  final String description;
  final String mainText;
  const PlacePrediction({
    required this.placeId,
    required this.description,
    required this.mainText,
  });
}

9. Marker Clustering for Large Datasets

Rendering 500+ markers individually kills scroll performance. Clustering groups nearby markers into a single count badge at lower zoom levels:

// pubspec.yaml
dependencies:
  google_maps_cluster_manager: ^4.0.0
import 'package:google_maps_cluster_manager/google_maps_cluster_manager.dart';

class Place with ClusterItem {
  final String id;
  final String name;
  final LatLng latLng;
  const Place({required this.id, required this.name, required this.latLng});

  @override
  LatLng get location => latLng;
}

class ClusteredMapScreen extends StatefulWidget {
  const ClusteredMapScreen({super.key});

  @override
  State<ClusteredMapScreen> createState() => _ClusteredMapScreenState();
}

class _ClusteredMapScreenState extends State<ClusteredMapScreen> {
  late ClusterManager<Place> _clusterManager;
  Set<Marker> _markers = {};

  final _places = List.generate(
    200,
    (i) => Place(
      id: 'place_$i',
      name: 'Place $i',
      latLng: LatLng(
        31.4504 + (i % 20) * 0.002,
        73.1350 + (i ~/ 20) * 0.002,
      ),
    ),
  );

  @override
  void initState() {
    super.initState();
    _clusterManager = ClusterManager<Place>(
      _places,
      _updateMarkers,
      markerBuilder: _markerBuilder,
    );
  }

  void _updateMarkers(Set<Marker> markers) {
    setState(() => _markers = markers);
  }

  Future<Marker> _markerBuilder(Cluster<Place> cluster) async {
    return Marker(
      markerId: MarkerId(cluster.getId()),
      position: cluster.location,
      icon: cluster.isMultiple
          ? await _getClusterIcon(cluster.count)
          : BitmapDescriptor.defaultMarker,
      infoWindow: cluster.isMultiple
          ? InfoWindow(title: '${cluster.count} places')
          : InfoWindow(title: cluster.items.first.name),
    );
  }

  Future<BitmapDescriptor> _getClusterIcon(int count) async {
    return createCustomMarkerFromWidget(
      label: count.toString(),
      color: count > 20 ? Colors.red : Colors.blue,
      size: count > 50 ? 120 : 100,
    );
  }

  @override
  Widget build(BuildContext context) {
    return GoogleMap(
      initialCameraPosition: const CameraPosition(
        target: LatLng(31.4550, 73.1400),
        zoom: 12,
      ),
      markers: _markers,
      onCameraMove: _clusterManager.onCameraMove,
      onCameraIdle: _clusterManager.updateMap,
    );
  }
}

10. Custom Map Styling

Default map styling works, but branded apps need custom colors. Generate a style JSON from Google Maps Styling Wizard or the Snazzy Maps gallery:

// assets/map_style.json
[
  {
    "elementType": "geometry",
    "stylers": [{ "color": "#242f3e" }]
  },
  {
    "elementType": "labels.text.fill",
    "stylers": [{ "color": "#746855" }]
  },
  {
    "featureType": "road",
    "elementType": "geometry",
    "stylers": [{ "color": "#38414e" }]
  },
  {
    "featureType": "water",
    "elementType": "geometry",
    "stylers": [{ "color": "#17263c" }]
  }
]
class _MapScreenState extends State<MapScreen> {
  GoogleMapController? _controller;

  @override
  void initState() {
    super.initState();
    _applyMapStyle();
  }

  Future<void> _applyMapStyle() async {
    final style = await rootBundle.loadString('assets/map_style.json');
    _controller?.setMapStyle(style);
  }

  // Call _applyMapStyle in onMapCreated callback too
  void _onMapCreated(GoogleMapController controller) {
    _controller = controller;
    _applyMapStyle();
  }
}

11. Production Optimization

Related Guides

Frequently Asked Questions

How much does the Google Maps API cost for a Flutter app?

Google gives you $200 free credit per month. That covers roughly 28,000 map loads, 40,000 geocoding requests, or 10,000 directions requests. Most indie apps and small-to-medium businesses stay within the free tier. Enable budget alerts in Google Cloud Console to avoid surprises.

Should I use google_maps_flutter or flutter_map?

google_maps_flutter is the official Google plugin — native SDK, best performance, full feature set. flutter_map uses OpenStreetMap tiles and is fully open-source with no API key needed. Use Google Maps for production apps needing directions, place search, or traffic. Use flutter_map to avoid Google dependencies or for custom tile servers.

How do I handle location permissions correctly in Flutter?

Use geolocator. Always check permission status before requesting. If permanently denied, direct the user to app settings. Request location only when the user performs an action that needs it — never on app startup.

Why is my Google Map showing a grey screen on Android?

Usually: (1) API key is wrong or not enabled for Maps SDK for Android. (2) The Maps SDK API isn't enabled in Google Cloud Console. (3) The meta-data tag in AndroidManifest.xml has a typo. Check the debug console for specific error messages.

Can I use Google Maps on Flutter Web?

Yes, using google_maps_flutter_web. Add it as a dependency and Flutter's federated plugin system handles it. You also need a JavaScript Maps API script tag in web/index.html. Performance is good for standard interactions but custom markers and complex overlays are more limited than native.

How do I show hundreds of markers without lag?

Use marker clustering with google_maps_cluster_manager. It groups nearby markers into cluster icons at lower zoom levels. For 10,000+ markers, load in batches based on the visible viewport using onCameraMove — only request markers for the current map region.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.