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
- Setting up Google Maps API keys with proper restrictions
- Android and iOS platform configuration
- Custom markers using widget-to-image conversion
- Real-time geolocation with the
geolocatorpackage - Drawing routes and boundaries with polylines and polygons
- Geocoding and reverse geocoding
- Places autocomplete for address search
- Marker clustering for performance with large datasets
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:
- Maps SDK for Android — required for Android builds
- Maps SDK for iOS — required for iOS builds
- Maps JavaScript API — required for Flutter Web
- Geocoding API — if you need address lookups
- Places API — if you need place search / autocomplete
- Directions API — if you need route drawing
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;
}
}
}
8. Places Autocomplete and Search
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
- Restrict API keys by platform. Android key restricted to your package name + SHA-1. iOS key restricted to your bundle ID. Web key restricted to your domain. Never use unrestricted keys.
- Set billing alerts. In Google Cloud Console, set budget alerts at $50, $100, $150. One runaway loop can cost hundreds.
- Cache geocoding results. Don't call the Geocoding API every time. Cache address-to-coordinate mappings locally.
- Debounce autocomplete requests. Don't fire a Places API call on every keystroke — debounce by 300ms. Each autocomplete request costs money.
- Use marker clustering. More than 50 markers? Cluster them. The performance difference is dramatic.
- Dispose map controllers. Call
controller.dispose()in your state'sdispose()method. Map controllers hold native resources. - Lazy-load map screens. Don't initialize the map on app startup. Load it only when the user navigates to a map screen.
- Use
litemode for static maps. If your map is just showing a location without interaction, useGoogleMap(liteModeEnabled: true)for better memory usage. - Minimize enabled APIs. Only enable the specific APIs you use. Each enabled API has its own usage quota and pricing.
Related Guides
- Deep Linking in Flutter: Universal Links, App Links & GoRouter
- Flutter Responsive Design: Building Adaptive UIs
- GetX vs Bloc vs Riverpod: State Management Comparison 2026
- Flutter Performance: Optimization Techniques That Work
- Flutter Clean Architecture: A Practical Guide
- Building E-Commerce Apps with Flutter and Stripe
- Flutter App Security: Protecting User Data
- Flutter Testing Strategy: Unit, Widget, and Integration Tests
- Flutter Internationalization: Complete i18n & l10n Guide
- Flutter DevOps: CI/CD Pipeline with GitHub Actions
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.