Create a map with POIs using React, Leaflet, Typescript and Github

Recently I have found myself in the position to want to show a list of addresses on a map and remembered that I haven’t built a map in quite a while. The plan is to build a map with OpenStreetMap and with some general structure for points of interest (POIs), which will be retrieved from external URLs.

We will not get into the details of creating a map. We will also stick to the basic features provided by Leaflet and try to keep things as simple as possible.

The repository and the demo URLs are the following:

Setup the project

We will start with the classic commands to setup the project. This is a purely JS/TS project, so we will not use docker, nor get too much into environment matters.

  • npx create-vite generic-map-with-pois
    • React
    • typescript
  • cd generic-map-with-pois
  • npm i
  • npm run dev

We should remember to init git and make commits as we progress with the project.

  • git init
  • git add .
  • git commit -m "First commit"

Add a basic map

The first thing we need to do is add Leaflet and the types for this library. Leaflet is one of the stronger options to use when you want to build maps with Javascript, and also one of the most friendly for beginners. Since it’s a small project, we will also use react-leaflet, since we are using react. However, for a more enterprise solution, we might want to avoid this and use Leaflet directly.

  • npm i --save react-leaflet leaflet
  • npm i --save-dev @types/leaflet

App.tsx., App.css and the ./assets folders are not needed currently, so we will delete them. Instead we will create a Map.tsx file:

import { MapContainer, TileLayer } from "react-leaflet";
import type { LatLngExpression } from "leaflet";
import "leaflet/dist/leaflet.css";

/**
 * The default center of the map (currently Bucharest :) )
 */
const MAP_CENTER: LatLngExpression = [44.4268, 26.1025];

/**
 * The initial zoom of the map
 */
const MAP_ZOOM = 3;

function Map() {
  return (
    <MapContainer
      center={MAP_CENTER}
      zoom={MAP_ZOOM}
      style={{ height: "100vh" }}
    >
      <TileLayer
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
      />
    </MapContainer>
  );
}

export default Map;
Code language: JavaScript (javascript)

Next, we must update the import in main.tsx to use the new Map component and while we are in this area of the app, we will also change the content of index.css to:

html, body {
  margin: 0;
  padding: 0;
}Code language: CSS (css)

Add some POIs

We will first add a constant with a few points of interest. We aim to show the points on the map, then center the map on the area where they are displayed.

/**
 * Some cities in Romania used as default POIs
 */
const DEFAULT_POINTS: {
  latitude: number;
  longitude: number;
  title: string;
  description?: string;
}[] = [
  { title: "Bucharest", latitude: 44.4268, longitude: 26.1025 },
  { title: "Cluj-Napoca", latitude: 46.7712, longitude: 23.6236 },
  { title: "Iasi", latitude: 47.1585, longitude: 27.6014 },
  {
    title: "Constanta",
    description: "A city by the Black Sea",
    latitude: 44.181,
    longitude: 28.6348,
  },
  { title: "Timisoara", latitude: 45.7489, longitude: 21.2087 },
];Code language: PHP (php)

We will put the markers directly in the MapContainer, given that we don’t need a layer functionality for this project:

{DEFAULT_POINTS.map((poi, index) => (
  <Marker key={index} position={[poi.latitude, poi.longitude]}>
    <Tooltip direction="top" offset={[-15, -15]} opacity={1}>
      <div className="tooltip-content">
        <h3>{poi.title}</h3>
        {!!poi.description && <>{poi.description}</>}
      </div>
    </Tooltip>
  </Marker>
))}Code language: HTML, XML (xml)

In this step we will also handle the map boundaries, depending on the displayed POIs. Since we have a static page, we will calculate the bounds directly in the main useEffect() of the Map.tsx component:

useEffect(() => {
  if (!map) {
    return;
  }

  const bounds = L.latLngBounds(
    DEFAULT_POINTS.map<LatLngExpression>((poi) => [
      poi.latitude,
      poi.longitude,
    ])
  );

  map.fitBounds(bounds.pad(0.1));
  map.setMaxBounds(bounds.pad(0.5));
}, [map]);Code language: JavaScript (javascript)

Make a request for POIs

Our intention with this project is to make it able to display POIs from external sources. As a minimal security feature we will use a whitelist with regexes matching the allowed api URLs, but this will be it. The first regex will be one that matches the github pages, the address where we will publish a demo of this page:

/^https:\/\/\w+\.github\.io\/[\w-]+(\/[\w-]+\/)?$/

To implement this, we will first create a custom hook which will make a request to the provided URL, if any. If no URL is provided, then it will fallback to the list of POIs mentioned above. The type on the response should match the one of the DEFAULT_POINTS:

import { useState, useEffect } from "react";
/**
 * Some cities in Romania used as default POIs
 */
const DEFAULT_POINTS: {
  latitude: number;
  longitude: number;
  title: string;
  description?: string | string[];
}[] = [
  { title: "Bucharest", latitude: 44.4268, longitude: 26.1025 },
  { title: "Cluj-Napoca", latitude: 46.7712, longitude: 23.6236 },
  { title: "Iasi", latitude: 47.1585, longitude: 27.6014 },
  {
    title: "Constanta",
    description: "A city by the Black Sea",
    latitude: 44.181,
    longitude: 28.6348,
  },
  { title: "Timisoara", latitude: 45.7489, longitude: 21.2087 },
];
/**
 * List of regular expressions used to whitelist the URL given as a source of POIs.
 * This is a minimal security feature to prevent the most basic abuse.
 */
const ALLOWED_API_URLS: RegExp[] = [
 /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/, // A regex for the Github pages
];

const useGetPOIs = (url?: string) => {
  const [data, setData] = useState<CustomMarker[]>([]);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);

    // No url was provided
    if (!url) {
      setLoading(false);
      setData(DEFAULT_POINTS);
      return;
    }

    // The provided url does not match the regex values in the whitelist
    if (ALLOWED_API_URLS.every((regex) => !regex.test(url))) {
      setLoading(false);
      setError(
        "The URL provided does not match the safety rules in this project's whitelist."
      );
      return;
    }

    // Fetch the data
    fetch(url)
      .then((response) => {
        if (!response.ok) {
          throw new Error(response.statusText);
        }
        return response.json() as Promise<CustomMarker[]>;
      })
      .then((json) => {
        setData(json);
      })
      .catch((error) => {
        setError(error as string);
      })
      .finally(() => {
        setLoading(false);
      });
  }, [url]);

  return { data, loading, error };
};

export default useGetPOIs;
Code language: JavaScript (javascript)

To get the value from the ?api= parameter in the URL, we will create a custom hook. For the fun of it, I have asked the already very famous Chat-GPT for some support, and the code was mostly what you see here. So kudos to it, some requests it completes beautifully:

import { useState, useEffect } from "react";

type URLParams = { [key: string]: string };

function useURLParams(): URLParams {
  const [params, setParams] = useState<URLParams>({});

  useEffect(() => {
    // Function to parse the URL query string and extract parameters
    const parseQueryString = (queryString: string) => {
      const params: URLParams = {};
      const query = new URLSearchParams(queryString);
      query.forEach((value, key) => {
        params[key] = value;
      });
      return params;
    };

    // Get the current URL
    const currentURL = window.location.href;

    // Extract the query string from the URL
    const queryString = currentURL.split("?")[1];

    // Parse the query string and update the state with parameters
    if (queryString) {
      const parsedParams = parseQueryString(queryString);
      setParams(parsedParams);
    }

    // Update the state when the URL changes
    const handleURLChange = () => {
      const newURL = window.location.href;
      const newQueryString = newURL.split("?")[1];
      if (newQueryString) {
        const newParams = parseQueryString(newQueryString);
        setParams(newParams);
      }
    };

    // Add event listener for URL changes
    window.addEventListener("popstate", handleURLChange);

    // Clean up the event listener when the component unmounts
    return () => {
      window.removeEventListener("popstate", handleURLChange);
    };
  }, []);

  return params;
}

export default useURLParams;
Code language: JavaScript (javascript)

The last piece we need to update is the Map.tsx component. We will use the two new hooks to get the parameter and after get the data. We will also have some minimal error handling and some styling.

import { useState, useEffect, useMemo } from "react";
import { MapContainer, TileLayer, Marker, Tooltip } from "react-leaflet";
import L, { LatLngExpression, Map as MapType } from "leaflet";

import useGetPOIs from "./hooks/useGetPOIs";
import useURLParams from "./hooks/useURLParams";

import "leaflet/dist/leaflet.css";

/**
 * The default center of the map (currently Bucharest :) )
 */
const MAP_CENTER: LatLngExpression = [44.4268, 26.1025];

/**
 * The initial zoom of the map
 */
const MAP_ZOOM = 3;

function Map() {
  const URLParams = useURLParams();
  const [map, setMap] = useState<MapType | null>(null);
  const { data, loading, error } = useGetPOIs(URLParams.api || undefined);

  const bounds = useMemo(
    () =>
      L.latLngBounds(
        data.map<LatLngExpression>((poi) => [poi.latitude, poi.longitude])
      ),
    [data]
  );

  useEffect(() => {
    if (!map) {
      return;
    }

    if (!bounds.isValid()) {
      return;
    }

    map.fitBounds(bounds.pad(0.5));
    map.setMaxBounds(bounds.pad(0.5));
  }, [map, bounds]);

  return (
    <>
      <div
        style={{
          position: "absolute",
          top: 0,
          right: 0,
          zIndex: 999,
          background: "#fff",
          padding: 10,
          marginLeft: 130,
        }}
      >
        {loading && <>The data for the map is loading...</>}
        {!loading && error && <>{error}</>}
      </div>

      <MapContainer
        center={MAP_CENTER}
        zoom={MAP_ZOOM}
        style={{ height: "100vh" }}
        ref={setMap}
      >
        <TileLayer
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        />
        {data.map((poi, index) => (
          <Marker key={index} position={[poi.latitude, poi.longitude]}>
            <Tooltip direction="top" offset={[-15, -15]} opacity={1}>
              <div className="tooltip-content">
                <h3>{poi.title}</h3>
                {!!poi.description && <>{poi.description}</>}
              </div>
            </Tooltip>
          </Marker>
        ))}
      </MapContainer>
    </>
  );
}

export default Map;
Code language: PHP (php)

One piece that is missing is validating that the data inside the API response is in the right format. This can be added as a future improvement.

Add a search input

The most useful tool when browsing data is the search functionality. We would like that implemented in our map project.

Add tailwind

To avoid the hustle of writing CSS inside the style property, we will start using tailwind, a CSS framework quite largely adopted nowadays:

  • npm install -D tailwindcss postcss autoprefixer
  • npx tailwindcss init -p
  • in tailwind.config.js update content to: content: [ "./index.html", "./src/**/*.{js,ts,jsx,tsx}", ]
  • replace the content of the index.css with:
@tailwind base;
@tailwind components;
@tailwind utilities;Code language: CSS (css)
  • update the styles to use className and tailwind classes, except for the style prop to set the map height on the map itself.

More info:

Add SVG imports

In our search input we will use a magnifier icon to indicate that we are searching stuff there. It is recommended that we use the SVG file as a direct import, instead of using it in an image tag. For that we will use a vite plugin:

  • npm i -D vite-plugin-svgr
  • update the vite.config.ts file:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";

// https://vitejs.dev/config/
export default defineConfig({
  base: "/generic-map-with-pois",
  plugins: [react(), svgr()],
});
Code language: JavaScript (javascript)
  • in tsconfig.json add:
"compilerOptions": 
{
    [...rest...]
    "types": ["vite-plugin-svgr/client"] <--- this line
},Code language: JavaScript (javascript)

Now you will be able to import files like:

import { ReactComponent as IconSearchSVG } from "../assets/icon-search.svg";

For compressing *.svg files we will use a custom command which can also be stored as a script in package.json:

npx --yes -- svgo -r ./src/assets

For formatting, when using VSCode, we can update the settings by editing the .vscode/settings.json file:

{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true,
  "files.associations": {
    "*.svg": "html"
  }
}Code language: JSON / JSON with Comments (json)

More info:

Create the search component

The search component will be an input with a magnifier icon and an “x” icon to cancel the value, which can be extended upon in the future.

import {
  InputHTMLAttributes,
  FunctionComponent,
  useEffect,
  useRef,
} from "react";
import { ReactComponent as IconSearchSVG } from "../assets/icon-search.svg";
import { ReactComponent as IconCloseSVG } from "../assets/icon-close.svg";

interface SearchInputProps extends InputHTMLAttributes<HTMLInputElement> {
  onCancel?: () => void;
}

const SearchInput: FunctionComponent<SearchInputProps> = ({
  className,
  onCancel,
  value,
  ...rest
}) => {
  const inputRef = useRef<HTMLInputElement | null>(null);

  useEffect(() => {
    const inputElement = inputRef.current;

    if (!inputElement) {
      return;
    }

    const handleEscapeKey = (e: KeyboardEvent) => {
      if (e.key === "Escape" && onCancel) {
        onCancel();
      }
    };

    // Add the event listener when the component mounts
    inputElement.addEventListener("keydown", handleEscapeKey);

    // Clean up the event listener when the component unmounts
    return () => {
      inputElement.removeEventListener("keydown", handleEscapeKey);
    };
  }, [onCancel]);

  return (
    <div className={`relative ${className || ""}`}>
      <input
        ref={inputRef}
        type="text"
        value={value}
        className="w-full p-1 pl-8 pr-8 border-[2px] bg-white hover:bg-gray-50 border-gray-400 rounded-md focus:outline-none focus:border-gray-800"
        {...rest}
      />
      <div className="absolute inset-y-0 left-0 flex items-center pl-2 text-black">
        <IconSearchSVG className="fill-gray-800" width={20} />
      </div>
      {!!value && (
        <div className="absolute inset-y-0 right-0 flex items-center pr-2 text-black cursor-pointer">
          <IconCloseSVG
            className="fill-gray-800"
            width={20}
            onClick={() => onCancel && onCancel()}
          />
        </div>
      )}
    </div>
  );
};

export default SearchInput;Code language: JavaScript (javascript)

We will then import it in the Map.tsx component, which will continue to handle the whole state of the application. The search will be performed in the title and the description of each marker, before representing them on the map:

const [search, setSearch] = useState<string>("");

const filteredData = useMemo(
  () =>
    data.filter((poi) => {
      const allText = `${poi.title} ${
        Array.isArray(poi.description)
          ? poi.description.concat(" ")
          : poi.description
      }`;
      return allText.toLowerCase().includes(search.toLowerCase());
    }),
  [data, search]
);Code language: JavaScript (javascript)

For the moment we will not debounce the data, but we might find that it is a useful addition in the future.

Create a modal for POI details

So far, our map as one big inconvenient: because it uses a tooltip to show the details of a POI, we do not have the option to select any details.

The fix for this will be implementing a modal window to show all the details when double clicking on a point. We use double click mainly because on a mobile view the tooltip is displayed on click, since hover is not available there.

We will first create a new component in ./components/Modal.tsx:

import { FunctionComponent, PropsWithChildren, useEffect, useRef } from "react";
import { ReactComponent as IconCloseSVG } from "../assets/icon-close.svg";

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
}

const Modal: FunctionComponent<PropsWithChildren<ModalProps>> = ({
  isOpen,
  onClose,
  children,
}) => {
  const overlayRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const overlayElement = overlayRef.current;

    if (!overlayElement) {
      return;
    }

    const handleEscapeKey = (e: KeyboardEvent) => {
      if (e.key === "Escape" && onClose) {
        onClose();
      }
    };

    // Add the event listener when the component mounts
    window.addEventListener("keydown", handleEscapeKey);

    // Clean up the event listener when the component unmounts
    return () => {
      window.removeEventListener("keydown", handleEscapeKey);
    };
  }, [onClose]);

  return (
    <div
      className={`fixed inset-0 flex items-center justify-center z-[99999] ${
        isOpen ? "" : "hidden"
      }`}
    >
      <div className="fixed inset-0 z-50 flex items-center justify-center">
        <div
          ref={overlayRef}
          className="absolute w-full h-full bg-gray-900 opacity-50"
          onClick={() => onClose()}
        />
        <div className="relative bg-white w-full max-w-md mx-auto rounded shadow-lg z-50 overflow-y-auto">
          <div className="absolute top-0 right-0 cursor-pointer p-1">
            <IconCloseSVG
              className="w-6 h-6 text-gray-600 hover:text-gray-800 transition duration-300 ease-in-out"
              onClick={onClose}
            />
          </div>
          <div className="p-4">{children}</div>
        </div>
      </div>
    </div>
  );
};

export default Modal;Code language: JavaScript (javascript)

Then we need to update the Map.tsx component to handle selection of a POI when clicked:

...
import Modal from "./components/Modal";
...

function Map () {
  const [selectedPOI, setSelectedPoi] = useState<CustomMarker | null>(null);
  ...

  return (
    <>
      ...
      {filteredData.map((poi, index) => (
          <Marker key={index} position={[poi.latitude, poi.longitude]} eventHandlers={{
            dblclick: () => {
              setSelectedPoi(poi);
            },
          }}>
            <Tooltip direction="top" offset={[-15, -15]} opacity={1}>
              <div className="tooltip-content">
                <h3>{poi.title}</h3>
                {!!poi.description && <>{poi.description}</>}
              </div>
            </Tooltip>
          </Marker>
        ))}
      ...

      <Modal isOpen={selectedPOI !== null} onClose={() => setSelectedPoi(null)}>
        {selectedPOI && (
          <>
            <h3 className="font-bold mb-2">{selectedPOI.title}</h3>
            {!!selectedPOI.description && !Array.isArray(selectedPOI.description) && (
              <div>{selectedPOI.description}</div>
            )}
            {!!description && Array.isArray(selectedPOI.description) && (
              <>
                {selectedPOI.description.map((descriptionItem) => (
                  <div>{descriptionItem}</div>
                ))}
              </>
            )}
          </>
        )}
      </Modal>
    </>
  );
}Code language: HTML, XML (xml)

At this point, the code is rather messy. Therefore, some refactoring is in order:

  • we will first create a POIDetails.tsx component which will receive as props the details of the point of interest and return the current content of the tooltip;
  • then we will create a MarkerElement..tsx component with children, which will receive as props the marker details, the onClick handler which will set the selected POI and the POIDetails.tsx component as a child;
  • finally, we will use the POIDetails.tsx component to create the content of the modal as well and by doing so, we will reduce the code complexity and redundancy.

Create custom buttons

At this point, we might feel the need to add a refresh button to reload the data in the map, without refreshing the page. In order to keep the styling aligned, we will remove the existing two buttons used for zooming and replace them with our own.

The first component we will create is ButtonWrapper, which is a simple component only used to put a border around the group of buttons:

import { FunctionComponent } from "react";

const ButtonWrapper: FunctionComponent<{
  children?: JSX.Element | JSX.Element[];
  className?: string;
}> = ({ children, className }) => {
  return (
    <div className={`z-[9999] ${className}`}>
      <div className="flex flex-col bg-white border-[2px] border-gray-700 border-opacity-50 rounded-md [&>*:first-child]:rounded-t-md [&>*:last-child]:rounded-b-md [&>*:last-child]:border-none">
        {children}
      </div>
    </div>
  );
};

export default ButtonWrapper;
Code language: JavaScript (javascript)

The second component we need is the button MapButton.tsx itself:

import {
  FunctionComponent,
  ButtonHTMLAttributes,
  PropsWithChildren,
} from "react";

interface MapButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {}

const MapButton: FunctionComponent<PropsWithChildren<MapButtonProps>> = ({
  children,
  className,
  disabled,
  ...rest
}) => {
  return (
    <button
      className={`flex items-center	justify-center min-w-[30px] min-h-[30px] p-[2px] text-2xl leading-5 text-black bg-white hover:bg-gray-100 border-b-[1px] border-gray-700 border-opacity-50 cursor-pointer ${className} ${
        disabled ? "opacity-50" : ""
      }`}
      {...rest}
      disabled={disabled}
    >
      {children}
    </button>
  );
};

export default MapButton;
Code language: JavaScript (javascript)

We can then add the zoomControl={false} prop in Map.tsx at the MapContainer element and do the rest of the necessary changes:

const [isZoomInDisabled, setIsZoomInDisabled] = useState(false);
const [isZoomOutDisabled, setIsZoomOutDisabled] = useState(false);

...

useEffect(() => {
  if (!map) {
    return;
  }
  ...
  map.on("zoom", () => {
    const zoom = map.getZoom();
    const maxZoom = map.getMaxZoom();

    setIsZoomOutDisabled(zoom === 0);
    setIsZoomInDisabled(zoom === maxZoom);
  });
}, [map, bounds, setMapBounds]);
...
return (
  <>
    ...
    <MapContainer
      ...
      zoomControl={false}
    >
       ...
    </MapContainer>
    ...
    <ButtonWrapper className="absolute left-[10px] top-[10px]">
      <MapButton
        onClick={() => map && map.zoomIn()}
        disabled={isZoomInDisabled}
      >
        <IconPlusSVG width={15} height={15} />
      </MapButton>
      <MapButton
        onClick={() => map && map.zoomOut()}
        disabled={isZoomOutDisabled}
      >
        <IconMinusSVG width={15} height={15} />
      </MapButton>
    </ButtonWrapper>
  </>
);Code language: JavaScript (javascript)

Now we can add the new button button wrapper below the other one, containing the functionality we want:

import { ReactComponent as IconRefreshSVG } from "./assets/icon-refresh.svg";
...
<ButtonWrapper className="absolute left-[10px] top-[80px]">
  <MapButton
    onClick={() => {
      setSearch("");
    }}
    disabled={loading}
  >
    <IconRefreshSVG width={15} height={15} />
  </MapButton>
</ButtonWrapper>
...Code language: HTML, XML (xml)

Publish to Github

As we already have git initialized with our project, what we need to do first is create a new repository on Github. We won’t go into details about that, as there are many tutorials out there to show how it’s done. What we will do, though, is add the a new remote to our git repo, once it is created.

Please remember to change the address to your repo, should you use the commands below:

  • git remote add origin git@github.com:cristidraghici/generic-map-with-pois.git
  • git branch -M master
  • git push -u origin master

The next step is to create the github action in ./.github/workflows/publish-master.yml:

# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages

on:
  # Runs on pushes targeting the default branch
  push:
    branches: ["master"]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages
permissions:
  contents: read
  pages: write
  id-token: write

# Allow one concurrent deployment
concurrency:
  group: "pages"
  cancel-in-progress: true

jobs:
  # Single deploy job since we're just deploying
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Set up Node
        uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: "npm"
      - name: Install dependencies
        run: npm install
      - name: Build
        run: npm run build
      - name: Setup Pages
        uses: actions/configure-pages@v3
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v1
        with:
          # Upload dist repository
          path: "./dist"
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v1
Code language: PHP (php)

In vite.config.js we might need to add a base for the project, to match the address where it will be published:

export default defineConfig({
  base: "/generic-map-with-pois",
  plugins: [react()],
});Code language: CSS (css)

More info:

Fix the leaflet marker

We must also add a fix for the Leaflet marker, as it will not be shown in the build. The images are added to the project from node_modules/leaflet/dist/images/. The following code needs to be added in Map.tsx or some util function:

// Fix for github pages not showing the icon
import MARKER_ICON_URL from "./assets/images/marker-icon.png";
import MARKER_ICON_RETINA_URL from "./assets/images/marker-icon-2x.png";
import MARKER_SHADOW_URL from "./assets/images/marker-shadow.png";

const DefaultIcon = L.icon({
  iconUrl: MARKER_ICON_URL,
  iconRetinaUrl: MARKER_ICON_RETINA_URL,
  shadowUrl: MARKER_SHADOW_URL,
  iconSize: [35, 46],
  iconAnchor: [17, 46],
});

L.Marker.prototype.options.icon = DefaultIcon;Code language: JavaScript (javascript)

More info: