Almost every field-service, delivery, logistics and marketplace app we are asked
to build has a map in it, and the map is almost always the screen that gets
rebuilt twice. Teams start with an <iframe> or a web JS map inside the
WebView, then discover that 2,000 markers makes an ion-content scroll at
8fps, that the map is blank in a tunnel, and that "navigate there" needs to
hand off to a native app. This tutorial walks the three decisions that matter —
which map renderer, how location is actually acquired, and what
happens offline — and gives you working code for each in an Ionic + Capacitor
app. Examples are Ionic Angular; the plugin APIs are identical in React and Vue.
1. Pick a renderer: native map view vs WebGL in the WebView
There are three realistic options in 2026.
| Option | What it is | Good for | Costs you |
|---|---|---|---|
@capacitor/google-maps | Native Google Maps SDK rendered behind the WebView | Dense marker sets, smooth pan/zoom, familiar UX | Per-load billing, no DOM over the map without care |
| MapLibre GL JS | Open-source WebGL map in the WebView | Vector tiles, full styling control, self-hosted or cheap tile vendors | You own tile hosting/performance |
| Leaflet + raster tiles | Classic DOM/canvas map | Simple pin-on-a-map screens, tiny bundles | Raster only, weak at >500 markers |
Rule of thumb from our projects: if the map is the product (dispatch boards, live tracking, thousands of assets) use the native view or MapLibre. If the map is a supporting detail (one store location, a delivery address confirmation) Leaflet is fine and the simplest thing to maintain.
The transparency gotcha with the native map
@capacitor/google-maps renders the native SDK underneath the WebView and
punches a hole through it. Anything in your app that paints an opaque
background over that hole hides the map. You need the page, ion-content and
the host element transparent:
/* map.page.scss */
ion-content { --background: transparent; }
.map-host {
display: block;
width: 100%;
height: 100%;
background: transparent;
}
/* global.scss — only while a native map page is active */
body.native-map-open,
body.native-map-open ion-app,
body.native-map-open ion-router-outlet { background: transparent !important; }
Toggle that body class in ionViewWillEnter / ionViewWillLeave rather than
globally, otherwise every other page loses its background.
2. Mount the native map and clean it up properly
import { Component, ElementRef, ViewChild } from '@angular/core';
import { GoogleMap } from '@capacitor/google-maps';
import { Geolocation } from '@capacitor/geolocation';
@Component({ selector: 'app-map', templateUrl: './map.page.html' })
export class MapPage {
@ViewChild('mapHost') mapHost!: ElementRef<HTMLElement>;
private map?: GoogleMap;
async ionViewDidEnter() {
document.body.classList.add('native-map-open');
const start = await this.safeCurrentPosition();
this.map = await GoogleMap.create({
id: 'jobs-map', // stable id; reused across creates
element: this.mapHost.nativeElement,
apiKey: environment.mapsApiKey,
config: {
center: { lat: start.lat, lng: start.lng },
zoom: 12,
androidLiteMode: false,
},
});
await this.map.enableCurrentLocation(true);
await this.map.setOnMarkerClickListener((e) => this.openJob(e.markerId));
}
async ionViewWillLeave() {
document.body.classList.remove('native-map-open');
await this.map?.destroy(); // leaking a map view = leaking a GL surface
this.map = undefined;
}
}
Two things teams forget: destroy() on leave (otherwise iOS keeps the GL
surface alive and memory climbs with every navigation), and that the map does
not follow Ionic page transitions — if you push a modal over it, hide the map
or the native view will float above the modal on Android.
3. Location acquisition that works in the field
Geolocation.getCurrentPosition() rejects in more ways than most apps handle:
permission denied, location services off at OS level, timeout in a basement,
and iOS "approximate location only". Wrap it once:
import { Geolocation } from '@capacitor/geolocation';
export async function safeCurrentPosition() {
let perm = await Geolocation.checkPermissions();
if (perm.location === 'prompt' || perm.location === 'prompt-with-rationale') {
perm = await Geolocation.requestPermissions({ permissions: ['location'] });
}
if (perm.location === 'denied') {
throw new LocationError('denied'); // show a "open settings" sheet
}
try {
const pos = await Geolocation.getCurrentPosition({
enableHighAccuracy: true,
timeout: 8000,
maximumAge: 30_000, // a 30s-old fix beats a spinner
});
return { lat: pos.coords.latitude, lng: pos.coords.longitude, accuracy: pos.coords.accuracy };
} catch {
const last = await loadCachedFix(); // your own last-known store
if (last) return last;
throw new LocationError('unavailable');
}
}
Always persist the last good fix (Preferences or SQLite) and render the map from it immediately, then re-center when the live fix arrives. The perceived speed difference is large, and it is the difference between a usable and an unusable screen in a parking garage.
On iOS 14+ check accuracy: anything over ~1000m means the user granted
approximate location. Detect it and explain what precise location buys them
before calling requestTemporaryFullAccuracyAuthorization-style flows; do not
nag on first launch.
Permission strings are not optional. Info.plist needs
NSLocationWhenInUseUsageDescription (and the Always key only if you genuinely
track in the background — App Review rejects speculative Always requests), and
AndroidManifest.xml needs ACCESS_FINE_LOCATION plus ACCESS_COARSE_LOCATION.
4. Thousands of markers without killing the frame rate
Never add markers one at a time in a loop across the bridge. Batch them, and cluster.
// one bridge call, not 2000
const ids = await this.map!.addMarkers(
jobs.map((j) => ({
coordinate: { lat: j.lat, lng: j.lng },
title: j.ref,
iconUrl: j.urgent ? 'assets/pin-urgent.png' : 'assets/pin.png',
})),
);
await this.map!.enableClustering(3); // cluster below zoom 3+ groupings
Then only load what is on screen. Listen for camera idle, debounce, and query your API by bounding box:
await this.map!.setOnCameraIdleListener(async (e) => {
this.bounds$.next(e.bounds); // debounceTime(300) downstream
});
this.bounds$.pipe(debounceTime(300), switchMap((b) => this.api.jobsInBounds(b)))
.subscribe((jobs) => this.renderMarkers(jobs));
Diff before you redraw: remove only the marker ids that left the viewport and
add only the new ones. Full removeMarkers() + addMarkers() on every pan
produces a visible flicker on Android.
For MapLibre the equivalent is a GeoJSON source with cluster: true and
clusterRadius: 50 — it clusters on the GPU and handles tens of thousands of
points comfortably, which is the main reason to prefer it over Leaflet.
5. Offline maps
Two things can be offline: the tiles and the data.
Tiles. With MapLibre you can ship or download an MBTiles/PMTiles archive
and serve it locally. The pragmatic Capacitor pattern is to download the
.pmtiles file for the user's region to the filesystem and point MapLibre at
it through a local protocol handler:
import { Filesystem, Directory } from '@capacitor/filesystem';
import { Protocol } from 'pmtiles';
const { uri } = await Filesystem.getUri({ directory: Directory.Data, path: 'region.pmtiles' });
const protocol = new Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
const map = new maplibregl.Map({
container: 'map',
style: { /* style JSON whose sources use pmtiles://<uri> */ },
});
Budget the download: a metro-area vector extract is typically 20–80 MB. Ask before downloading on cellular, and show progress — silent 60 MB downloads are a support ticket generator.
With @capacitor/google-maps you cannot legally cache tiles yourself. If
offline maps are a hard requirement, that decides your renderer for you.
Data. Cache the features themselves in SQLite alongside the rest of your offline store, keyed by tile or geohash, so the map still shows yesterday's jobs with a clear "last updated 14:20" banner instead of an empty canvas.
6. Handing off to turn-by-turn navigation
Do not build turn-by-turn. Hand off to the maps app the user already trusts:
import { Capacitor } from '@capacitor/core';
import { AppLauncher } from '@capacitor/app-launcher';
export async function navigateTo(lat: number, lng: number, label: string) {
const platform = Capacitor.getPlatform();
const q = encodeURIComponent(label);
if (platform === 'ios') {
const apple = `maps://?daddr=${lat},${lng}&dirflg=d`;
const { value } = await AppLauncher.canOpenUrl({ url: 'comgooglemaps://' });
await AppLauncher.openUrl({
url: value ? `comgooglemaps://?daddr=${lat},${lng}&directionsmode=driving` : apple,
});
return;
}
// Android: geo: intent, falls back to a chooser
await AppLauncher.openUrl({ url: `geo:${lat},${lng}?q=${lat},${lng}(${q})` });
}
On iOS, add comgooglemaps and waze to LSApplicationQueriesSchemes in
Info.plist or canOpenUrl always returns false.
7. Testing and cost control
- Simulated routes. Xcode's Debug → Simulate Location → City Run and the Android emulator's GPX playback let you test follow-me behaviour without walking around the block. Make this part of your release checklist.
- Mock the provider in CI. Your Playwright/Maestro suites should run against a stub tile server; real tile requests in CI are both flaky and billable.
- Watch map loads. Google Maps bills per map load (SDK) — a page that re-creates the map on every navigation can multiply your bill tenfold. Reuse one map instance per screen and verify the load count in the Cloud console after a QA run.
- Restrict your key. Separate iOS/Android keys, restricted by bundle id and SHA-1, with a daily quota cap. A leaked unrestricted maps key is a real and common bill shock.
A sensible default stack
For most of the Ionic apps we take over, this is where we land:
@capacitor/google-mapswhen the client is already in Google Cloud, the map is online-only, and marker counts are moderate.- MapLibre + a vector tile vendor (or self-hosted PMTiles) when offline maps, heavy clustering or custom cartography matter — it is also the cheaper option at scale.
@capacitor/geolocationfor foreground fixes, a dedicated background geolocation plugin only if you truly track while backgrounded, and always a cached last-known fix behind both.- Native hand-off for navigation, never a homegrown routing UI.
Maps are one of the few Ionic screens where the wrong early decision is expensive to undo, because tiles, billing and offline behaviour are all tangled together. If you are mapping out (sorry) a location-heavy Ionic app and want a second opinion before you commit, get in touch — our Ionic consultants have shipped this screen many times.