How to Build an Interactive Map with React Leaflet and Strapi

Strapi
Strapi
Published in
11 min readAug 21, 2024

Introduction

Interactive maps are an essential part of many web and mobile applications. They provide users with a visual representation of geographical data enabling them to interact with it in meaningful ways. They transcend the limitations of static maps by offering dynamic features that empower users to explore, analyze, and interact with geographical data in real time.

In this tutorial, you’ll learn how to build an interactive map application using React Leaflet and Strapi. By the end of this tutorial, you’ll learn how to create a map that displays location markers and enables searching for locations. Additionally, you’ll discover how to enable the dragging of markers, display location information including images, and allow the drawing of polygons.

Prerequisites

To comfortably follow along, you’ll need to have:

Creating and Managing Geographic Data in Strapi

Install Strapi

Begin by creating the geographic data necessary for pinpointing locations on the map and presenting the relevant information. To achieve this run the following command on the terminal:

npx create-strapi-app@latest my-project

The command will initialize a new Strapi project named my-project. When the initialization is complete, on your terminal, navigate to the my-project directory and run the following command:

npm run develop

The command will start a development server for your Strapi project. Open the default Strapi Admin URL http://localhost:1337/admin to access the strapi admin registration panel.

Register yourself to access the admin dashboard.

Create Collection Type

Click on Content-Type Builder and create a new collection type named Location and click Continue.

Create five fields named: name, description, longitude, latitude, and finally photo. The name and description should be of type text, longitude and latitude should be of type number, and should hold float number format. And the photo should be a Media field of type single.

Note: Ensure to click save when you are done creating these fields.

Create Entries

Proceed to the Content Manager and create a new entry. Fill all the fields with the data of the location you want to pinpoint and describe on the map. For the purpose of this tutorial, we will input the data of several capital cities around the world. You can obtain the latitude and longitude coordinates from Google Maps.

Enter as many entries as you wish, after each entry, click Save and Publish respectively.

Enable API Public Access

You now have your data stored in the Strapi database. To expose this data to be used by an application or a program, head to Settings > Users & Permissions Plugin > Roles > Public.

Then click on the location and turn on find and findOne.

Proceed to the location endpoint at http://localhost:1337/api/locations?populate=photo to see the structure of your data. The ?populate=photo parameter at the end of the URL instructs Strapi to include the associated photo data for each location in the response.

Here is a sample data from the endpoint:

This is the data you will later use to create the interactive map. You now need an app that will use the data to showcase an interactive map.

Setting up the App’s Development Environment

Install Next.js

Navigate to the directory where you want to host your project using your preferred IDE. Then, open the terminal and execute the following command:

npx create-next-app@latest

Choose the following options:

The command will set up a new Next.js project named map-app using the latest available version.

Install Other Dependencies

Then install the required dependencies using this command:

npm install react-leaflet react-leaflet-draw axios

Here is what each library will do:

  • react-leaflet: This library provides React components for building interactive maps using the Leaflet JavaScript library.
  • react-leaflet-draw: This library extends react-leaflet by providing components for adding drawing functionalities to the map.
  • axios: This library will make HTTP requests to fetch data from the Strapi API containing the geographical data.

react-Leaflet provides bindings between React and Leaflet, leveraging Leaflet to abstract its layers as React components. It does not replace Leaflet but rather works with it. Both leaflet and leaflet-draw are installed as peer dependencies of react-Leaflet and react-leaflet-draw respectively.

After the dependencies are installed, you are free to start coding.

Fetching Location Data from Strapi

On your map-app project, inside the api folder of the pages folder, create a new file named locations.js. This will be the file containing the code responsible for fetching location data from Strapi.

// pages/api/locations.js
import axios from "axios";

export default async (req, res) => {
try {
const response = await axios.get(
"http://localhost:1337/api/locations?populate=photo",
);
res.status(200).json(response.data);
} catch (error) {
console.error("Error fetching data:", error);
if (error.response) {
res
.status(error.response.status)
.json({ error: error.response.data.message });
} else {
res.status(500).json({ error: "Error fetching data" });
}
}
};

The code utilizes the Axios library for HTTP requests. The route sends a GET request to the Strapi endpoint. Upon successful retrieval of data, the route responds with a status code of 200 and sends the fetched data as JSON. Error handling logs any encountered errors and provides appropriate error responses to client requests. This includes both server-generated and network-related errors.

Creating the Interactive Map

After your app has fetched data from Strapi, you are ready to create the interactive map. Proceed to the map-app project root directory and create a folder named components. Then create a component named Map.js inside this folder.

This file will contain the code responsible for creating and rendering the interactive map. The code has been divided into smaller sub-sections for easier understanding.

Importing the Required Libraries

Open the Map.js file and start by importing the required libraries and initializing the state variables:

// components/Map.js
import React, { useState, useEffect, useRef } from "react";
import {
MapContainer,
TileLayer,
Marker,
Popup,
Polygon,
FeatureGroup,
useMap,
} from "react-leaflet";
import { EditControl } from "react-leaflet-draw";
import "leaflet/dist/leaflet.css";
import "leaflet-draw/dist/leaflet.draw.css";
import L from "leaflet";

const Map = () => {
const [locations, setLocations] = useState([]);
const [filteredLocations, setFilteredLocations] = useState([]);
const [selectedLocation, setSelectedLocation] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
const [polygonPoints, setPolygonPoints] = useState([]);
const mapRef = useRef(null);

Here is a breakdown of what each main component does:

  • MapContainer: This is the main container for your map.
  • TileLayer: It defines the background map tiles.
  • Marker: It represents location markers on the map.
  • Popup: This is the information window displayed when clicking a marker.
  • Polygon: Represents a polygon drawn on the map.
  • FeatureGroup: Groups your map features together for easier management.
  • useMap: This is a hook used within the component to access the map instance.
  • EditControl from react-leaflet-draw: It enables drawing functionalities on the map.
  • Leaflet CSS (leaflet/dist/leaflet.css) and Leaflet Draw CSS (leaflet-draw/dist/leaflet.draw.css): They provide styles for the map and drawing tools.

These components will be crucial when adding functionalities to your map.

Fetching Location Data From the Next.js API

Inside the Map.js file, Create a useEffect hook responsible for fetching location data from the server when the Map component mounts. For each location, it will extract the URL of the photo associated with that location (if available) and construct the photoUrl property.

// components/Map.js
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch("/api/locations");
const data = await response.json();
const locationsWithPhotoUrl = data.data.map((location) => ({
...location,
photoUrl: `http://localhost:1337${location.attributes.photo?.data[0]?.attributes?.url}`,
}));
setLocations(locationsWithPhotoUrl);
setFilteredLocations(locationsWithPhotoUrl);
} catch (error) {
console.error("Error fetching data:", error);
}
};
fetchData();
}, []);

In the code above, we need to construct the photoUrl property because the data obtained from strapi includes the relative path. But for the browser to display the image it needs the complete URL else the image won't be visible.

Enabling Marker Clicking and Adding Search Functionality

The map must respond to user interactions, such as clicking on markers or searching for locations. To achieve this, define the relevant event handlers:

// components/Map.js
const customIcon = L.icon({
iconUrl: "/custom-marker.png",
iconSize: [30, 30],
iconAnchor: [15, 30],
});

const handleMarkerClick = (location) => {
setSelectedLocation(location);
mapRef.current.flyTo(
[location.attributes.latitude, location.attributes.longitude],
14, // Zoom level
);
};

The code above defines a custom marker icon and creates a handleMarkerClick function that handles user clicks on markers. When a marker is clicked, it updates the state variable selectedLocation with the details of the clicked location. This enables the map to display the information about that location. It utilizes the Leaflet flyTo method to smoothly animate the map view to the coordinates of the clicked location.

The image below is what we used as the custom icon. Make sure to download it and put it inside the public folder with the name custom-maker.png.

NOTE: you can use any image of your choice and replace with custom-marker.png in your code.

// components/Map.js
const handleSearch = () => {
if (searchQuery.trim() === "") {
setFilteredLocations(locations);
setSelectedLocation(null);
} else {
const filtered = locations.filter((location) =>
location.attributes.name
.toLowerCase()
.includes(searchQuery.toLowerCase()),
);
setFilteredLocations(filtered);
if (filtered.length > 0) {
setSelectedLocation(filtered[0]);
mapRef.current.flyTo(
[filtered[0].attributes.latitude, filtered[0].attributes.longitude],
14, // Zoom level
);
} else {
setSelectedLocation(null);
}
}
};

The handleSearch function filters the list of locations based on the user-entered search query, updating the filteredLocations state accordingly. If there are matching locations, it selects the first one from the filtered list and adjusts the map view to focus on its coordinates. If no matching locations are found, it clears the selected location, providing feedback to the user that no results were found.

// components/Map.js
const handleDragMarker = (event, location) => {
const newLatLng = event.target.getLatLng();
const updatedLocations = locations.map((loc) =>
loc.id === location.id
? {
...loc,
attributes: {
...loc.attributes,
latitude: newLatLng.lat,
longitude: newLatLng.lng,
},
}
: loc,
);
setLocations(updatedLocations);
setFilteredLocations(updatedLocations);
};

The handleDragMarker function updates the position of a marker on the map when it is dragged by the user. When a marker is dragged to a new location, this function captures the new latitude and longitude coordinates of the marker from the drag event. It then updates the locations state variable by mapping over the existing array of locations and modifying the coordinates of the dragged marker.

Adding a Polygon Drawing and Editing Functionality

For the map to be interactive, allowing users to draw polygons and edit them. You need to handle map interactions.

// components/Map.js
const _onCreate = (e) => {
const { layerType, layer } = e;
if (layerType === "polygon") {
setPolygonPoints(layer.getLatLngs()[0]);
}
};

const _onEdited = (e) => {
const {
layers: { _layers },
} = e;
Object.values(_layers).map(({ editing }) => {
setPolygonPoints(editing.latlngs[0]);
});
};
const _onDeleted = (e) => {
const {
layers: { _layers },
} = e;
Object.values(_layers).map(() => {
setPolygonPoints([]);
});
};

When a new polygon is drawn, its coordinates are extracted and used to update the polygonPoints state, reflecting the newly drawn polygon. When it is edited, the updated coordinates are captured and applied to the polygonPoints state, ensuring that changes to the polygon's shape are reflected. Lastly, when a polygon is deleted, the polygonPoints state is reset to an empty array, indicating the absence of any polygons on the map.

Creating the User Interface

The final step in creating the interactive map component is rendering the user interface elements:

// components/Map.js
return (
<div>
<input
type="text"
placeholder="Search locations"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
color: "black",
border: "1px solid #ccc",
borderRadius: "4px",
padding: "8px",
}}
/>
<button
onClick={handleSearch}
style={{
backgroundColor: "green",
color: "white",
border: "none",
borderRadius: "4px",
padding: "8px 16px",
marginLeft: "8px",
}}
>
Search
</button>
<MapContainer
center={[0, 0]}
zoom={2}
style={{ height: "80vh" }}
ref={mapRef}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FeatureGroup>
<EditControl
position="topright"
onCreated={_onCreate}
onEdited={_onEdited}
onDeleted={_onDeleted}
draw={{
rectangle: false,
polyline: false,
circle: false,
circlemarker: false,
marker: false,
}}
/>
</FeatureGroup>
{Array.isArray(filteredLocations) &&
filteredLocations.map((location) => (
<Marker
key={location.id}
position={[
location.attributes.latitude,
location.attributes.longitude,
]}
icon={customIcon}
eventHandlers={{
click: () => handleMarkerClick(location),
dragend: (event) => handleDragMarker(event, location),
}}
draggable
>
<Popup>
<div
style={{ width: "300px", height: "Auto", paddingTop: "10px" }}
>
<h3>{location.attributes.name}</h3>
{selectedLocation?.id === location.id && (
<div>
<p>{location.attributes.description}</p>
{location.photoUrl && (
<img
src={location.photoUrl}
alt={location.attributes.name}
style={{ maxWidth: "100%", height: "auto" }}
/>
)}
</div>
)}
</div>
</Popup>
</Marker>
))}
{polygonPoints.length > 0 && (
<Polygon positions={polygonPoints} color="blue" fillOpacity={0.5} />
)}
</MapContainer>
</div>
);
};

export default Map;

The code above renders your map’s user interface. It consists of several elements: a search input field, a button for filtering locations, a map container initialized with Leaflet, a tile layer for displaying map tiles from OpenStreetMap, and an EditControl component allowing users to draw and edit polygons on the map. For each filtered location, markers are rendered with custom icons.

Rendering the Map Component on the Homepage

Proceed to the index.js file which is located under the pages directory and paste the following code:

// pages/index.js
import React from "react";
import dynamic from "next/dynamic";

const Map = dynamic(() => import("../components/Map"), {
ssr: false,
});
const Home = () => {
return (
<div>
<h1>Interactive Map</h1>
<Map />
</div>
);
};
export default Home;

The code renders the map component with all the interactive features you’ve implemented, such as displaying location markers, enabling search, allowing marker dragging, and drawing polygons.

Look at how the homepage user interface looks:

It shows all the capital cities whose data is stored in Strapi.

Testing the Application

Below is a GIF showing all the features we have implemented:

The GIF shows all the functionalities working seamlessly.

Conclusion

In this tutorial, you have learned to set up Strapi, fetch data, build the interactive map, and integrate it into a Next.js app. This shows how integrating Strapi and React-Leaflet can produce visually powerful maps.

Go ahead and implement more custom functionalities on the map!

Additional Resources

--

--

Strapi
Strapi

The open source Headless CMS Front-End Developers love.