The Bumpy Road to Implementing a Map in My Expo Universal App

@variabilealeatoria.bsky.social

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.

final result


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 &copy; 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='&copy; <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 💥

explosion

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

  1. 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.
  2. 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! 🚀

variabilealeatoria.bsky.social
Alessandro

@variabilealeatoria.bsky.social

I just rant about React

Post reaction in Bluesky

*To be shown as a reaction, include article link in the post or add link card

Reactions from everyone (0)