In this blog post, I’ll walk you through the journey of displaying a map with markers in my Expo Router app. Was it a smooth, trouble-free experience? Unfortunately, no.
I’ll share the challenges I faced, how I overcame them, and the doubts I still have.
Ready? Let’s Code!
The idea is to use platform-specific extensions to define two different implementations of a map component: one for native and one for the web. This means creating Map.tsx
for native and Map.web.tsx
for web.
Native Implementation
For the native implementation, we use a WebView to load a local HTML file that renders a Leaflet map.
The HTML File
The HTML file contains a simple Leaflet map setup:
<!DOCTYPE html>
<html>
<head>
<title>Quick Start - Leaflet</title>
<meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1.0">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css" integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ==" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js" integrity="sha512-gZwIG9x3wUXg2hdXF6+rVkLF/0Vi9U8D2Ntg4Ga5I5BZpVkVxlJWbSQtXPSiUTtC0TjtGOmxa1AJPuV0CPthew==" crossorigin=""></script>
</head>
<body style="padding: 0; margin: 0">
<div id="mapid" style="width: 100%; height: 100vh;"></div>
<script>
var mymap = L.map('mapid').setView([51.50, 0.12], 7);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: 'Map data © OpenStreetMap contributors'
}).addTo(mymap);
</script>
</body>
</html>
The Map.tsx
Component
We store the HTML file as a string and load it inside a WebView:
import { html_map } from "@/assets/html";
import { useEffect, useRef } from "react";
import WebView from "react-native-webview";
import { MapProps } from "./types";
export function Map({ locations, initialPosition }: MapProps) {
const webviewRef = useRef<WebView>(null);
useEffect(() => {
const ref = webviewRef?.current;
if (!locations) return;
const timeout = setTimeout(() => {
ref?.injectJavaScript(
`mymap.setView([${initialPosition.lat}, ${initialPosition.lon}], 9); true`
);
for (const location of locations) {
ref?.injectJavaScript(
`L.marker([${location.lat},${location.lon}]).addTo(mymap); true`
);
}
}, 1000);
return () => {
clearTimeout(timeout);
ref?.injectJavaScript(`mymap.eachLayer(function(layer) {
if (layer instanceof L.Marker) {
mymap.removeLayer(layer);
}
}); true`);
};
}, [locations, initialPosition]);
return <WebView ref={webviewRef} source={{ html: html_map }} />;
}
Note: The
setTimeout
is a bit of a hack to wait for the map to load. A more robust solution would be better.
Web Implementation
It's time for some headache
For the web, using a WebView is not an option. Instead, we use react-leaflet
to build our map declaratively.
Install Dependencies
npx expo install react-leaflet@4.2.1 leaflet@1.9
npm install -D @types/leaflet
The Map.web.tsx
Component
import { icon } from "leaflet";
import { MapProps } from "./types";
import "leaflet/dist/leaflet.css";
import { MapContainer, Marker, TileLayer } from "react-leaflet";
const markerIcon = icon({
iconUrl: "/marker-icon.png",
shadowUrl: "/marker-shadow.png",
iconAnchor: [12, 41],
});
export function Map({ locations, initialPosition }: MapProps) {
return (
<MapContainer
center={[initialPosition.lat, initialPosition.lon]}
zoom={13}
style={{ height: "100%", width: "100%", zIndex: 0 }}
>
<TileLayer
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{locations.map((location, idx) => (
<Marker key={idx} icon={markerIcon} position={[location.lat, location.lon]} />
))}
</MapContainer>
);
}
Broken Marker Icons
Leaflet’s default marker icons don’t resolve properly. The network tab shows the HTML file being returned instead of the correct asset. A workaround is to manually copy the default icons from node_modules/leaflet/dist/images
into a public/
folder.
Handling window is not defined
You might think that we are ready to run the app, let's open our browser and boom 💥
Starting the web app results in:
Server Error
window is not defined
This happens because Leaflet tries to access the DOM, but Expo is set to static rendering by default in app.json
:
"web": {
"output": "static"
}
To fix this, switch "output"
from "static"
to "single"
, restart the server, and your map will work.
Caveats
- No Static Rendering Solution: Giving up static rendering for a single screen feels like a bad tradeoff, and I haven’t found a better way yet.
- Marker Icon Issues: Importing icons manually is inconvenient. I tried using
leaflet-defaulticon-compatibility
but couldn’t make it work.
Final Thoughts
Implementing maps in an Expo Universal app is possible but comes with challenges, especially for static rendering.
It's frustrating to have to abandon static rendering entirely just because of a single screen, and I'm unsure if there's a better solution.
If the Expo team chose to set static as the default value in the template app created with npx create-expo-app@latest, they should provide more guidance on handling web libraries that require access to the DOM.
If you have any insights or better solutions, I’d love to hear them!
Let me know your thoughts in the comments! 🚀