Skip to content

50,000 free calls a month, card-free. Get an API key →

Documentation menu
docs / mobile · raw .md

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.

SourceURL or callKeyMeteredUse it for
Hosted house stylehttps://api.mapmap.ai/styles/{id}.jsonnonenoEvaluation, demos, prototypes
Keyed tile APIhttps://api.mapmap.ai/tiles/{territory}/style.json?api_key=…yesyesProduction apps online
Installed territoryTerritoryStore.territoryStyle(…)nonenoProduction 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:

perl
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)

ini
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:

json
{
  "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:

bash
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).

swift
// 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 MGL prefixes or import Mapbox predates MapLibre Native 6.0.0. The whole surface was renamed MGL… to MLN… in that release, along with the module name. Snippets you find elsewhere may not have caught up; the module is MapLibre and the view is MLNMapView.

A minimal map on a MapMap style:

swift
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:

  • contentInset gets overwritten. MLNMapView.automaticallyAdjustsContentInset is already false by default, but MapLibre Native still runs the legacy adjustment, which consults the view controller's deprecated automaticallyAdjustsScrollViewInsets and rewrites contentInset with the safe-area insets on the next layout pass. If you set a focal inset for a chase camera, set automaticallyAdjustsScrollViewInsets = false on the controller. Re-applying the inset after layout does not work; it is overwritten again.
  • Nested interpolate on zoom. A paint property whose expression nests a zoom interpolate inside another expression cannot be replaced with setPaintProperty-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:

swift
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:

swift
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:

kotlin
dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
}
kotlin
// 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.mapboxsdk predates MapLibre Native 11.0.0. That release moved the package root to org.maplibre.android and renamed the classes with it (MapboxMap to MapLibreMap, Mapbox to MapLibre). The org.maplibre.gl group 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:

xml
<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:

kotlin
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)
    }
}
xml
<!-- 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:

kotlin
@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:

kotlin
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:

ts
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:

  1. Install a signed .snpkg package with TerritoryStore (ed25519 manifest, BLAKE3 content-addressed layers, differential over-the-air updates with an atomic swap).
  2. 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 are pmtiles://<absolute path>.
  3. Load that JSON string into MapLibre Native: MLNMapView.styleJSON on 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:

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:

code
© 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-native is 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 :car and :car-maplibre, and iOS's MapMapCarPlay, wrap the platform car surfaces around MapMap guidance. :car-maplibre in 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 for sdk-v0.7.0 after 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