MapLibre React Native: a map in your app
MapLibre React Native puts MapLibre Native, the open-source map renderer for iOS and Android, behind React components: a Map, a Camera, sources and layers for your data, and annotations for markers. It started as a fork of the Mapbox React Native library, and needs no Mapbox token.
This guide builds a map of coffee shops near a point. Tapping one draws the walking route to it. It uses Mapricot's free map tiles, which need no key, and Mapricot's places and routing APIs for the data. The code is written for @maplibre/maplibre-react-native 11.5 with Expo SDK 58 and React Native 0.88.
Before you start
You need:
- Node.js 20.19 or later, which comes with npm. Run
node --versionin a terminal to check which you have. - The tools to build a native app. For iOS, a Mac with Xcode and its simulator. For Android, Android Studio with an emulator, or an Android phone with USB debugging on. Expo's environment setup walks through each: choose "Development build" and "Build with Expo tooling".
- A server key with only the Places and Routing products. Getting started shows how to make one. An app sends no web origin, so a browser key, which works only from the origins you list, is refused in an app with
403. Keys in an app explains how to limit the risk of a key in an app. The map tiles need no key. - Some familiarity with React components, state, and effects. React's tutorial covers them.
1. Create the app
MapLibre React Native is native code, so it doesn't run in Expo Go. It needs a development build of your app. Start an Expo app and add the library, and a decoder for route lines:
npx create-expo-app@latest my-map --template blank-typescript
cd my-map
npx expo install @maplibre/maplibre-react-native
npm install @mapbox/polyline
npm install --save-dev @types/mapbox__polyline
npx expo install also adds the library's config plugin to app.json:
{
"expo": {
"plugins": ["@maplibre/maplibre-react-native"]
}
}
Put your key in a file called .env.local in the my-map folder. Expo builds variables that start with EXPO_PUBLIC_ into the app, and the template's .gitignore keeps .env*.local files out of your repository:
EXPO_PUBLIC_MAPRICOT_KEY=mapricot_sk_…
Then build and run the app on a simulator, emulator, or connected device, with one of:
npx expo run:ios
npx expo run:android
The first build takes several minutes. Run it again whenever you add or update a native library. Changes to your JavaScript reload without a rebuild, and the app shows Expo's blank starter screen for now.
In a React Native app without Expo, install the package and follow the library's setup guide for iOS and Android.
2. Show a map
Replace everything in App.tsx with:
import { StyleSheet } from "react-native";
import { Camera, Map } from "@maplibre/maplibre-react-native";
export default function App() {
return (
<Map style={styles.map} mapStyle="https://tiles.mapricot.com/styles/light.json">
<Camera initialViewState={{ center: [-122.6793, 45.5189], zoom: 15 }} />
</Map>
);
}
const styles = StyleSheet.create({
map: { flex: 1 },
});
mapStyletakes any MapLibre style URL. Mapricot's map tiles come inlight,dark, andgrayscalestyles, and the map shows the OpenStreetMap credit through its attribution button.Camera'sinitialViewStatesets where the map starts. Coordinates are[longitude, latitude].- The map fills its parent, so the parent needs a size.
flex: 1makes it fill the screen.
Save the file, and the app shows a map of downtown Portland.
3. Add markers
A ViewAnnotation draws any React Native view at a coordinate, and a Callout inside it shows a label when it's tapped. The places are coffee shops near the starting point, from Mapricot's nearby places endpoint.
First, replace the imports at the top of App.tsx with these, which bring in React's hooks, View, and the components for markers and lines:
import { useEffect, useState } from "react";
import { StyleSheet, View } from "react-native";
import { Callout, Camera, GeoJSONSource, Layer, Map, ViewAnnotation } from "@maplibre/maplibre-react-native";
Below the imports and above App, add the key, the starting point, a type for places, and a function that sends a request to Mapricot and returns the JSON response, or throws an error if the API refuses it:
const key = process.env.EXPO_PUBLIC_MAPRICOT_KEY;
const start = { lat: 45.5189, lon: -122.6793 };
type Place = { id: string; name: string; lat: number; lon: number };
async function mapricot(path: string, body?: object) {
const response = await fetch(`https://api.mapricot.com${path}`, {
method: body ? "POST" : "GET",
headers: { Authorization: `Bearer ${key}`, ...(body && { "Content-Type": "application/json" }) },
body: body && JSON.stringify(body),
});
if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
return response.json();
}
At the top of App, before return, keep the places in state and fetch them once when the map first appears:
const [places, setPlaces] = useState<Place[]>([]);
const [selected, setSelected] = useState<Place | null>(null);
useEffect(() => {
mapricot(`/v1/nearby?lat=${start.lat}&lon=${start.lon}&category=food:coffee_shop&limit=10`).then((data) =>
setPlaces(data.results),
);
}, []);
Inside the Map, after the Camera, add an annotation for each place:
{places.map((place) => (
<ViewAnnotation key={place.id} id={place.id} lngLat={[place.lon, place.lat]} onSelect={() => setSelected(place)}>
<View style={styles.pin} />
<Callout title={place.name} />
</ViewAnnotation>
))}
Then add the pin's look to the styles at the bottom of the file, after map:
pin: { width: 20, height: 20, borderRadius: 10, backgroundColor: "#0d6efd", borderWidth: 3, borderColor: "white" },
Blue pins appear around the square, and tapping one shows its name. Views are fine for a few dozen markers. For hundreds, a circle or symbol layer on a GeoJSONSource performs better, since the map draws it instead of placing a view for each one.
4. Draw a route
A line is a GeoJSONSource with a Layer inside it. When a place is selected, ask Mapricot's routing API for a walking route and turn its polyline into a GeoJSON line. Import the decoder at the top of the file:
import polyline from "@mapbox/polyline";
Add the route's state and effect in App, after the others:
const [route, setRoute] = useState<GeoJSON.LineString | null>(null);
useEffect(() => {
if (!selected) return;
mapricot("/v1/route", {
waypoints: [start, { lat: selected.lat, lon: selected.lon }],
mode: "walk",
}).then((data) => {
const points = polyline.decode(data.routes[0].polyline, 6);
setRoute({ type: "LineString", coordinates: points.map(([lat, lon]) => [lon, lat]) });
});
}, [selected]);
Inside the Map, after the Camera and before the annotations, add the line:
{route && (
<GeoJSONSource id="route" data={route}>
<Layer id="route" type="line" paint={{ "line-color": "#fd7e14", "line-width": 5 }} />
</GeoJSONSource>
)}
Mapricot's polylines use six decimal places, so decode with precision 6; most decoders default to Google's 5. And MapLibre takes coordinates as [longitude, latitude], the reverse of the decoder's output.
Tap a pin, and the walking route to it draws in orange.
The whole app
Here's App.tsx with all of the above:
import { useEffect, useState } from "react";
import { StyleSheet, View } from "react-native";
import { Callout, Camera, GeoJSONSource, Layer, Map, ViewAnnotation } from "@maplibre/maplibre-react-native";
import polyline from "@mapbox/polyline";
const key = process.env.EXPO_PUBLIC_MAPRICOT_KEY;
const start = { lat: 45.5189, lon: -122.6793 };
type Place = { id: string; name: string; lat: number; lon: number };
async function mapricot(path: string, body?: object) {
const response = await fetch(`https://api.mapricot.com${path}`, {
method: body ? "POST" : "GET",
headers: { Authorization: `Bearer ${key}`, ...(body && { "Content-Type": "application/json" }) },
body: body && JSON.stringify(body),
});
if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
return response.json();
}
export default function App() {
const [places, setPlaces] = useState<Place[]>([]);
const [selected, setSelected] = useState<Place | null>(null);
const [route, setRoute] = useState<GeoJSON.LineString | null>(null);
useEffect(() => {
mapricot(`/v1/nearby?lat=${start.lat}&lon=${start.lon}&category=food:coffee_shop&limit=10`).then((data) =>
setPlaces(data.results),
);
}, []);
useEffect(() => {
if (!selected) return;
mapricot("/v1/route", {
waypoints: [start, { lat: selected.lat, lon: selected.lon }],
mode: "walk",
}).then((data) => {
const points = polyline.decode(data.routes[0].polyline, 6);
setRoute({ type: "LineString", coordinates: points.map(([lat, lon]) => [lon, lat]) });
});
}, [selected]);
return (
<Map style={styles.map} mapStyle="https://tiles.mapricot.com/styles/light.json">
<Camera initialViewState={{ center: [start.lon, start.lat], zoom: 15 }} />
{route && (
<GeoJSONSource id="route" data={route}>
<Layer id="route" type="line" paint={{ "line-color": "#fd7e14", "line-width": 5 }} />
</GeoJSONSource>
)}
{places.map((place) => (
<ViewAnnotation key={place.id} id={place.id} lngLat={[place.lon, place.lat]} onSelect={() => setSelected(place)}>
<View style={styles.pin} />
<Callout title={place.name} />
</ViewAnnotation>
))}
</Map>
);
}
const styles = StyleSheet.create({
map: { flex: 1 },
pin: { width: 20, height: 20, borderRadius: 10, backgroundColor: "#0d6efd", borderWidth: 3, borderColor: "white" },
});
Keys in an app
The map tiles need no key. The places and routing requests do, and anything built into an app can be read by someone who unpacks it. Browser keys rely on the page's origin, which an app doesn't send, so an app needs a server key. Create a separate one for the app, with only the Places and Routing products, and caps on its requests per minute and per billing period, so a copied key can't use up your free requests or credit. Keep keys with wider access on your servers. Security explains how keys are protected. To keep the key out of the app entirely, send these requests through your own backend instead.
Moving from Mapbox
MapLibre React Native began as a fork of @rnmapbox/maps, so the ideas carry over: a map, a camera, sources, layers, and annotations. Version 11 renamed and reshaped several components, so expect to change imports and props rather than swap the package name. Mapbox styles and tiles only work with a Mapbox token, so change each mapbox://styles/… URL to a MapLibre style URL, such as Mapricot's.
Questions
Does MapLibre React Native work with Expo? Yes, in a development build made with npx expo run:ios or npx expo run:android. It doesn't work in Expo Go, because it includes native code.
Do I need a Mapbox token? No. MapLibre is open source, and Mapricot's tiles need no key at all.
Are the map tiles free? Mapricot's tiles are free, need no key, and don't count against your requests. Places and routing requests come from 250,000 free requests every 30 days, then cost $0.10 per 1,000.