Documentation menu
Mobile maps with MapLibre Native
MapMap's mobile SDKs are deliberately headless. ai.mapmap:core on
Android and MapMapKit on iOS give you routing, guidance, voice and
signed offline territory packages, and they hand back a MapLibre style
JSON for you to render. Neither ships a map view. The renderer is
MapLibre Native, the
open-source C++ map engine with iOS and Android bindings, and you own it.
That is the same split the web takes, where @mapmap/maps wraps
maplibre-gl (see Maps, tiles & Studio). On mobile there is
no wrapper: you add MapLibre Native yourself and point it at a MapMap
style. This page is how.
It covers three things people get wrong on the first attempt: which style URL to use, what kind of API key works in a native app (an origin-restricted key does not, and the reason is worth reading), and what happens when the radios are off.
Scope. Everything here is the map surface. Turn-by-turn guidance, ADR-aware offline routing and territory installation live in the platform SDKs: SDKs & installation, Android API reference, Territory packages & OTA updates. What MapMap does not ship for mobile is stated plainly in What we do not ship at the end, before you build a plan around something that is not there.
Which map source
There are three, and they are not interchangeable.
| Source | URL or call | Key | Metered | Use it for |
|---|---|---|---|---|
| Hosted house style | https://api.mapmap.ai/styles/{id}.json | none | no | Evaluation, demos, prototypes |
| Keyed tile API | https://api.mapmap.ai/tiles/{territory}/style.json?api_key=… | yes | yes | Production apps online |
| Installed territory | TerritoryStore.territoryStyle(…) | none | no | Production apps offline |
Hosted house styles (keyless)
GET /styles/{id}.json serves the compiled MapMap house styles with no
key and no metering. The two we publish are:
https://api.mapmap.ai/styles/mapmap-light-76ef8d@2.json
https://api.mapmap.ai/styles/mapmap-dark-762117@2.json
The @2 suffix pins an immutable version, so a screenshot or a filmed
demo cannot change under you; drop it for "latest". Both point their
territory source at pmtiles://https://tiles.mapmap.ai/uk.pmtiles, read
over HTTP range requests.
These are the styles our own iOS and Android demo apps load, so they are
known to work under MapLibre Native. They are not a production
entitlement: tiles.mapmap.ai is our own CDN, free on our own surfaces,
and the metered tile API below is the supported path for an app you ship.
A detail worth knowing if you ever debug a 403 there: tiles.mapmap.ai
runs a browser-origin gate. A request declaring a foreign Origin or
Referer is refused; a request declaring neither, which is exactly what a
native MapLibre client sends, is served. That is the gate working as
designed, not an outage.
The keyed tile API (production)
https://api.mapmap.ai/tiles/{territory}/style.json?api_key=snk_…
https://api.mapmap.ai/tiles/{territory}/tiles.json?api_key=snk_…
https://api.mapmap.ai/tiles/{territory}/{z}/{x}/{y}.mvt?api_key=snk_…
The query parameter is api_key (the Authorization: Bearer header form
works too, but a style document referenced by bare URL from a map client
cannot attach one, which is why the query form exists). Styles, sprites,
glyphs and tiles are all metered against that key. See
Maps, tiles & Studio for territories, custom styles and
Studio.
An installed territory (offline)
A signed territory package installed by TerritoryStore carries its own
PMTiles layer. territoryStyle(territoryId:theme:) returns a complete
style JSON whose sources are pmtiles://<absolute local path>, so the map
renders with the radios off and nothing is metered. That is the subject of
Offline territories below.
Keys in a native app
This is the part to read before you mint anything.
Browser keys let you lock a key to the
websites it may be called from (allowed_origins) and to the map and
search surface (scope: "maps"). The first of those two properties
breaks a native app, and it fails closed rather than degrading.
An origin-restricted key requires the request to carry an Origin header,
or failing that a Referer the gateway can reduce to an origin. Those are
the two things a browser sends and a stolen key replayed from a server
cannot forge. A request presenting neither is refused, deliberately:
{
"type": "urn:sn-gateway:problem:origin-not-allowed",
"title": "Origin not allowed",
"status": 403,
"detail": "this API key is restricted to browser origins and this request carried neither an Origin nor a Referer header; origin-restricted keys are for browsers — mint a key without allowed_origins for server-to-server use",
"origin": null,
"docs": "https://mapmap.ai/docs/api-reference#browser-keys",
"hint": "browser keys are restricted to the origins named at issuance: mint one with POST /v1/keys {\"allowed_origins\": [\"https://app.example.com\"], \"scope\": \"maps\"}, or use a key with no allowed_origins for server-to-server calls"
}
(origin is null because the request presented none; the wording is the
gateway's own, quoted verbatim.)
MapLibre Native sends neither header. There is no Origin on a URLSession
or OkHttp request that no browser made, and adding one by hand would be a
string you typed rather than evidence of anything. So an origin-restricted
key in an iOS or Android app answers 403 on every tile, and you get a
blank basemap with no partial-success mode to mislead you.
Mint a maps-scope key with no allowed_origins:
curl -X POST "https://api.mapmap.ai/v1/keys" \
-H "Content-Type: application/json" \
-d '{
"email": "you@example.com",
"accept_tos": true,
"label": "ios-app",
"scope": "maps"
}'
scope: "maps" still does real work. It limits the key to tiles, style,
font and sprite reads, geocoding and its own status endpoints; every write
and every routing call answers 403 scope-insufficient. A key lifted out
of your app binary cannot order a matrix, publish a style or replace your
places dataset.
What it cannot do is stop the key being used from somewhere else, because nothing about a native request identifies the app it came from. Treat a key shipped in a binary as extractable and plan accordingly:
- One key per app, per platform, labelled, so you can revoke without touching anything else.
- Always
scope: "maps". Never ship a full-scope key in a binary. - Watch
GET /v1/usage. A key spending outside your release pattern is the signal you get. - Rotate by issue-then-revoke on a schedule you set, shipped with an app update.
- If none of that is enough for your threat model, proxy tiles through your own backend, hold a server-side key there, and let your app authenticate to you instead. That is the only arrangement that actually binds map spend to your users, and it is the same conclusion every vendor in this category reaches.
Nothing above is specific to MapMap; a client-side map key is a shared industry problem. The honest statement is that origin allow-lists solve it for browsers and have no native equivalent.
iOS (Swift)
MapLibre Native iOS ships as an official SwiftPM binary distribution. The current release is 6.29.0 (23 August 2026).
// Package.swift
.package(
url: "https://github.com/maplibre/maplibre-gl-native-distribution",
from: "6.27.0"
)
In Xcode: File > Add Package Dependencies, that URL, and add the
MapLibre library to your app target. from: admits any 6.x, so this
resolves to the current release while holding a floor. Our own CarPlay demo
uses the same floor: 6.26.1 carries a fill-extrusion memory fix and 6.27.0
the PMTiles ambient cache, and a pmtiles:// source is exactly what a
MapMap style uses.
CocoaPods works too: the pod is MapLibre, published in step with the
SwiftPM releases, deployment target iOS 12.0.
Anything using
MGLprefixes orimport Mapboxpredates MapLibre Native 6.0.0. The whole surface was renamedMGL…toMLN…in that release, along with the module name. Snippets you find elsewhere may not have caught up; the module isMapLibreand the view isMLNMapView.
A minimal map on a MapMap style:
import MapLibre
import UIKit
final class MapViewController: UIViewController, MLNMapViewDelegate {
/// Metered production path. For evaluation, swap in the keyless house
/// style: https://api.mapmap.ai/styles/mapmap-light-76ef8d@2.json
private static let styleURL = URL(
string: "https://api.mapmap.ai/tiles/uk/style.json?api_key=snk_…"
)!
private var mapView: MLNMapView!
override func viewDidLoad() {
super.viewDidLoad()
let map = MLNMapView(frame: view.bounds, styleURL: Self.styleURL)
map.autoresizingMask = [.flexibleWidth, .flexibleHeight]
map.delegate = self
// MapMap's canonical corner layout: attribution small bottom-left,
// the MapMap mark bottom-right, never stacked. Turn MapLibre's own
// ornaments off rather than letting them fight yours for the same
// corners. If you draw neither, leave these visible: the OSM credit
// is not optional (see "Attribution" below).
map.logoView.isHidden = true
map.attributionButton.isHidden = true
map.compassView.isHidden = true
map.setCenter(
CLLocationCoordinate2D(latitude: 51.5074, longitude: -0.1278),
zoomLevel: 12,
animated: false
)
view.addSubview(map)
mapView = map
}
// Called once the style and its sources are ready. Add your own
// sources and layers here, never before.
func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) {
// e.g. style.addSource(…) / style.addLayer(…)
}
}
MLNMapView needs no explicit lifecycle wiring: it is a UIView and
manages itself. Two traps that have cost us real time:
contentInsetgets overwritten.MLNMapView.automaticallyAdjustsContentInsetis alreadyfalseby default, but MapLibre Native still runs the legacy adjustment, which consults the view controller's deprecatedautomaticallyAdjustsScrollViewInsetsand rewritescontentInsetwith the safe-area insets on the next layout pass. If you set a focal inset for a chase camera, setautomaticallyAdjustsScrollViewInsets = falseon the controller. Re-applying the inset after layout does not work; it is overwritten again.- Nested
interpolateon zoom. A paint property whose expression nests a zoominterpolateinside another expression cannot be replaced withsetPaintProperty-style updates and fails silently. Rebuild the layer instead of patching it.
Keeping the key out of the style URL (iOS)
Putting ?api_key= in the style URL is the supported path on iOS, and it
is the one we recommend. Header injection is not a documented contract
there: MLNNetworkConfiguration.sharedManager.sessionConfiguration exists
and can carry custom headers, but Apple explicitly tells you not to set
Authorization through URLSessionConfiguration.httpAdditionalHeaders,
and MapLibre's per-request hook (MLNNetworkConfigurationDelegate) is
marked as experimental and excluded from the generated documentation. Do
not build a release around it.
If you would rather not bake the key into a constant string, the
documented hook is MLNOfflineStorageDelegate, which lets you rewrite
every resource URL before it is fetched:
final class KeyInjector: NSObject, MLNOfflineStorageDelegate {
func offlineStorage(
_ storage: MLNOfflineStorage,
urlForResourceOf kind: MLNResourceKind,
with url: URL
) -> URL {
guard url.host == "api.mapmap.ai" else { return url }
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
components.queryItems = (components.queryItems ?? [])
+ [URLQueryItem(name: "api_key", value: Secrets.mapmapKey)]
return components.url ?? url
}
}
// Set once, before any MLNMapView exists.
MLNOfflineStorage.shared.delegate = KeyInjector()
The key still ships in the binary; this only keeps it out of the style URL you hand around. Read Keys in a native app again if you are relying on this for security rather than tidiness.
SwiftUI
MLNMapView is UIKit. Wrap it once and reuse:
import MapLibre
import SwiftUI
struct MapMapView: UIViewRepresentable {
let styleURL: URL
func makeUIView(context: Context) -> MLNMapView {
MLNMapView(frame: .zero, styleURL: styleURL)
}
func updateUIView(_ view: MLNMapView, context: Context) {
if view.styleURL != styleURL { view.styleURL = styleURL }
}
}
Android (Kotlin)
MapLibre Native Android is on Maven Central as org.maplibre.gl:android-sdk.
mavenCentral() alone is enough; no custom repository is needed. Declare
it at the root, in settings.gradle.kts's
dependencyResolutionManagement, not in a module:
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
// app/build.gradle.kts
dependencies {
implementation("org.maplibre.gl:android-sdk:13.4.1")
}
13.4.1 is the version our own Android modules build and test against; the
current release is 13.6.0. Pin an exact version, and note that
android-sdk changed renderer under the same coordinates: from 13.0.0
it defaults to Vulkan, where 12.x was OpenGL ES. If you need the old
renderer, the artefact is org.maplibre.gl:android-sdk-opengl; there is
also android-sdk-vulkan-opengl, which carries both and picks at runtime
for a larger binary. Upgrading 12.x to 13.x without changing coordinates
changes your renderer, which is worth knowing before you chase a device
specific rendering bug.
MapLibre Native's own floor is minSdk 23 and Java 11; ai.mapmap:core
needs minSdk 26, so an app using both sits at 26.
Anything importing
com.mapbox.mapboxsdkpredates MapLibre Native 11.0.0. That release moved the package root toorg.maplibre.androidand renamed the classes with it (MapboxMaptoMapLibreMap,MapboxtoMapLibre). Theorg.maplibre.glgroup id is older than the package rename, so do not infer the import namespace from the coordinates.
Permissions. You usually do not need to declare any. The SDK's own
manifest merges in INTERNET, ACCESS_NETWORK_STATE and
ACCESS_WIFI_STATE, and also ACCESS_COARSE_LOCATION and
ACCESS_FINE_LOCATION whether or not you use the location component. If
your app has no business asking for location, strip them explicitly rather
than shipping a permission you cannot justify at review:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
tools:node="remove" />
MapLibre.getInstance(context) must run once before any MapView is
constructed or inflated (MapView throws
MapLibreConfigurationException otherwise), and MapView needs its
lifecycle callbacks forwarded by hand: there is no LifecycleObserver in
the SDK. Missing either is the usual cause of a black rectangle:
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.MapLibre
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.Style
class MapActivity : AppCompatActivity() {
private lateinit var mapView: MapView
override fun onCreate(savedInstanceState: Bundle?) {
// Before setContentView, and before any MapView exists.
MapLibre.getInstance(this)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { map -> onMapReady(map) }
}
private fun onMapReady(map: MapLibreMap) {
// Metered production path. For evaluation, swap in the keyless
// house style: https://api.mapmap.ai/styles/mapmap-light-76ef8d@2.json
val styleUri = "https://api.mapmap.ai/tiles/uk/style.json?api_key=snk_…"
map.setStyle(Style.Builder().fromUri(styleUri)) { style ->
// Style and sources are ready. Add your own layers here.
}
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(51.5074, -0.1278))
.zoom(12.0)
.build()
}
// MapView holds a GL surface and will leak or render black without
// every one of these.
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { mapView.onPause(); super.onPause() }
override fun onStop() { mapView.onStop(); super.onStop() }
override fun onLowMemory() { super.onLowMemory(); mapView.onLowMemory() }
override fun onDestroy() { mapView.onDestroy(); super.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
<!-- res/layout/activity_map.xml -->
<org.maplibre.android.maps.MapView
android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
Jetpack Compose
There is no first-party Compose map, so host the View. Because
MapView's lifecycle methods are not optional, forward them from a
DisposableEffect rather than relying on AndroidView's onRelease
alone:
@Composable
fun MapMapMap(styleUri: String, modifier: Modifier = Modifier) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val mapView = remember {
MapLibre.getInstance(context)
MapView(context).apply { onCreate(null) }
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> mapView.onStart()
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
Lifecycle.Event.ON_STOP -> mapView.onStop()
// ON_DESTROY is deliberately absent: onDispose below owns
// it, and destroying twice crashes.
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
mapView.onDestroy()
}
}
AndroidView(modifier = modifier, factory = { mapView })
LaunchedEffect(styleUri) {
mapView.getMapAsync { map -> map.setStyle(Style.Builder().fromUri(styleUri)) }
}
}
(Style.Builder().fromUri is the current name; fromUrl is deprecated
but still present, which is why old snippets still compile with a
warning.)
There is also an official
maplibre-compose
Compose Multiplatform stack. It is a parallel implementation rather than a
Compose skin over the View SDK, it is beta on both Android and iOS, and its
own release notes warn that minor releases can carry breaking changes. We
have not built on it; the AndroidView host above is the conservative
route.
Keeping the key out of the style URL (Android)
Unlike iOS, Android has a reliable header hook. HttpRequestUtil swaps in
your own OkHttp Call.Factory for every map request, and the
configuration survives across MapView instances:
import okhttp3.OkHttpClient
import org.maplibre.android.module.http.HttpRequestUtil
val client = OkHttpClient.Builder()
.addInterceptor { chain ->
val request = chain.request()
if (request.url.host == "api.mapmap.ai") {
chain.proceed(
request.newBuilder()
.header("Authorization", "Bearer ${BuildConfig.MAPMAP_KEY}")
.build(),
)
} else {
chain.proceed(request)
}
}
.build()
// Once, before any MapView exists. Pass null to reset to the default.
HttpRequestUtil.setOkHttpClient(client)
The gateway accepts the bearer form on every endpoint, so this works. It is tidier than a key in a URL, and it is not a security control: see Keys in a native app.
React Native
@mapmap/react-native is an Expo module wrapping the two
native SDKs. Like them, it exposes no map component: its TypeScript
surface is the navigation handle, typed guidance events, a pure-TS mock
that runs in Expo Go, and a config plugin.
Render with
@maplibre/maplibre-react-native
and feed it the style the module returns:
import { getMapmapNav } from "@mapmap/react-native";
// false for the native module, true for the pure-TS mock (runs in Expo Go).
const nav = getMapmapNav(false);
const styleJson = await nav.getTerritoryStyle("uk", "light");
That is the offline path (local pmtiles:// sources). For an online map,
pass one of the style URLs above to the renderer directly. We soak-tested
@maplibre/maplibre-react-native under Expo with 3D buildings on, which is
the memory-hungriest case: cost was bounded and it did not run out of
memory, so it is a reasonable choice rather than merely the only one.
The architecture, and what the React Native bridge does and does not do (including the parts that are compile-verified rather than device-tested), is in MapMap Nav.
Offline territories
The reason to use MapMap on mobile at all is usually that the map has to work with no network. A signed territory package carries the PMTiles basemap, the routing graph and a geocode index together, so the basemap and the route come from the same install and cannot disagree.
The shape of it:
- Install a signed
.snpkgpackage withTerritoryStore(ed25519 manifest, BLAKE3 content-addressed layers, differential over-the-air updates with an atomic swap). - Ask for its style:
territoryStyle(territoryId:theme:)on iOS,territoryStyle(territoryId, theme)on Android,getTerritoryStyle(territoryId, theme)in React Native. You get a complete style JSON whose sources arepmtiles://<absolute path>. - Load that JSON string into MapLibre Native:
MLNMapView.styleJSONon iOS,Style.Builder().fromJson(styleJson)on Android.
Nothing in step 3 touches the network, and nothing is metered.
layerPath(territoryId:kind:) gives you the raw PMTiles file if you would
rather assemble your own style around it.
Full detail, including the trust model, the OTA differential mechanism, the hosted channel's verifying key and how to publish your own channel:
- Territory packages & OTA updates
- Trust model
- Differential over-the-air updates
- Fetching territories over the API
- The hosted channel's verifying key
Do not use MapLibre Native's own offline pack API for a MapMap
basemap. MLNOfflineStorage and Android's OfflineManager predownload
a tile pyramid from a tile server into MapLibre's own cache. A territory
package already is the offline basemap, versioned, signed and updated
differentially, and running both means two caches, two update paths and
two answers to "what does this device have". Use MapLibre's offline packs
only for sources you own that MapMap does not package.
Attribution
The map data is OpenStreetMap on the OpenMapTiles schema. The credit is a licence obligation (ODbL and CC-BY), it follows the data rather than the platform, and it applies identically to a phone screen, a car display and a web page. The exact string, shared verbatim by the web SDK and both demo apps:
© OpenStreetMap contributors © OpenMapTiles
Either leave MapLibre's own attribution control visible, or hide it and draw the credit yourself. MapMap's canonical layout is the attribution small at the bottom-left and the MapMap mark at the bottom-right, never stacked in the same corner.
What we do not ship
Stated plainly, so you can plan against what exists rather than what a roadmap implies.
No mobile map rendering SDK. There is no MapMap map view for iOS or
Android, and no wrapper around MapLibre Native comparable to
@mapmap/maps on the web. ai.mapmap:core declares no MapLibre
dependency at all. You add MapLibre Native, you own the view, the camera
and the styling, and the SDK hands you style JSON. That is a deliberate
choice rather than a gap we are about to close: a map view is the part of
a navigation app teams most want to control, and wrapping it well is a
larger commitment than wrapping it badly.
No native turn-by-turn navigation UI. The SDKs give you guidance
state: manoeuvres, distances, lane hints and formatted strings through
NavigationEngine and GuidanceDisplay, plus voice. The HUD, the
manoeuvre card, the route line, the puck and the chase camera are yours to
build. There is no drop-in navigation view controller or Compose
navigation screen.
What you can reuse today:
- The React Native app.
@mapmap/react-nativeis published and real: the navigation handle, typed guidance and territory events, a mock that runs in Expo Go without a native build, and an Expo config plugin that writes the location usage strings and the background modes. MapMap Nav documents the reference architecture and the early-access Android app you can install and drive today. - The CarPlay and Android Auto modules. Android's
:carand:car-maplibre, and iOS'sMapMapCarPlay, wrap the platform car surfaces around MapMap guidance.:car-maplibrein particular contains a working chase camera, route line and puck over MapLibre Native, which is the closest thing in the tree to a reference map implementation. They are beta and source-distribution only today: no published artefact, built from a source checkout, public artefacts planned forsdk-v0.7.0after head-unit hardware validation. Read CarPlay & Android Auto before you commit to a date, because Apple's entitlement and Google's Play review are the long poles, not the code.
Version skew you will notice. The Android artefact
(ai.mapmap:core) and the iOS package (MapMapKit) are not on the same
version number and are not released in lockstep. The current pair is in
the compatibility table on SDKs & installation; trust that
table over any README.
Next steps
- SDKs & installation for install coordinates, the signing-key trust anchor and licensing.
- Android API reference for every public class of
ai.mapmap:core. - Territory packages & OTA updates for the offline basemap.
- Maps, tiles & Studio for territories, custom styles and the Studio editor.
- Browser keys for the browser-side version of the key question above.