ffrosch
ffrosch

Reputation: 1423

Nextjs with react-leaflet - SSR, webpack | window not defined, icon not found

Goal: I want to display a leaflet map in a nextjs App.

My dependencies are:

"leaflet": "1.9",
"next": "14",
"react": "18",
"react-leaflet": "4",

Problems:

Questions:


This is the standard example for react-leaflet and it throws a window is not defined error when run in nextjs:

const position = [51.505, -0.09]
        
render(
  <MapContainer center={position} zoom={13} scrollWheelZoom={false}>
    <TileLayer
      attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
      url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
    />
    <Marker position={position}>
      <Popup>
        A pretty CSS3 popup. <br /> Easily customizable.
      </Popup>
    </Marker>
  </MapContainer>
)

Status: Eventually I came up with a good setup. Please see my answer below for details and explanations.

Upvotes: 2

Views: 5718

Answers (5)

De_Jr
De_Jr

Reputation: 281

I can re-open this, I tried NetJs 15 + React leaflet 5 and get the marker problem without find a workaround with leaflet compatibility:

./node_modules/leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css:12:10
Module not found: Can't resolve '~leaflet/dist/images/marker-shadow.png'
  10 |  background-image: url(~leaflet/dist/images/marker-shadow.png); /* normal[, Retina] */
  11 |  cursor: url(~leaflet/dist/images/marker-shadow.png), auto; /* normal[, Retina], auto */
> 12 |  width: 41px;
     |          ^
  13 |  height: 41px;
  14 |  margin: -41px -12px; /* margin top and left to reversely position shadowAnchor */
  15 |  }

Here is the import on top of the file:

import "leaflet/dist/leaflet.css";


import "leaflet-defaulticon-compatibility";
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css";
// END: Preserve spaces to avoid auto-sorting
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";

page.tsx is our main server component, importing two separate client component as your example above...

Any advice without creating our proper marker ?

Upvotes: 0

Kris
Kris

Reputation: 1

The way I solved my issue with window is not defined was with the dynamic import from Next and react-leaflet v5.0.0-rc.1

After a long search I found the answer here: https://github.com/PaulLeCam/react-leaflet/issues/1133#issuecomment-2429898837

Upvotes: 0

Paul
Paul

Reputation: 5781

ReferenceError: window is not defined

The other solutions like using next/dynamic with ssr: false did initially work, but doesn't work any more (currently using nextjs 14.2.8).

I solved it by wrapping all components and hooks, which use leaflet, into a wrapper with useEffect and an import:

'use client';
import { FunctionComponent, useEffect, useState } from 'react';

const LayersWrapper: FunctionComponent = () => {
  const [LayersPage, setLayersPage] = useState<FunctionComponent>();

  useEffect(() => {
    (async () => {
      if (typeof global.window !== 'undefined') {
        const newLayersPage = (await import('./Layers')).default;
        setLayersPage(() => newLayersPage);
      }
    })();
  }, []);

  if (typeof global.window === 'undefined' || !LayersPage) {
    return null;
  }

  return LayersPage ? <LayersPage /> : null;
};

export default LayersWrapper;

Upvotes: 0

ffrosch
ffrosch

Reputation: 1423

2024: Nextjs 14 + react-leaflet 4

Find the detailed explanations with examples below or see the solution in action with a live example: codesandbox.


Explanation

After the explanations two examples will follow, one for a client and one for a server component.

window is not defined

This error occurs because nextjs renders every component by default on the server first. This is super useful for things like fetching data. But leaflet needs a browser window to display its content and there is no browser window on the server. So if we don't tell nextjs to not render the map component on the server, leaflet will throw an error, telling us that it couldn't find a browser window.

It is not enough to flag the leaflet component as a client component with use client. We still run into errors. The reason behind this is that leaflet will try to render before react finishes loading. We can postpone loading leaflet with next/dynamic.

marker-icon.png 404 (Not Found)

This error occurs with bundlers like webpack that modify URLs in CSS. This behavior clashes with leaflet's own dynamic creation of image urls. Fortunately this is easily fixable by installing leaflet-defaulticon-compatibility and doesn't require manual tweaks.


Using page.tsx as a client component

Setup nextjs and leaflet, install leaflet-defaulticon-compatibility:

npx create-next-app@latest
npm install leaflet react-leaflet leaflet-defaulticon-compatibility
npm install -D @types/leaflet

Important steps:

  • import js for leaflet
  • import css and js for leaflet-defaulticon-compatibility
  • set width and height for the map container

components/Map.tsx

"use client";

// IMPORTANT: the order matters!
import "leaflet/dist/leaflet.css";
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css";
import "leaflet-defaulticon-compatibility";

import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";

export default function Map() {
  const position = [51.505, -0.09]

  return (
      <MapContainer
        center={position}
        zoom={11}
        scrollWheelZoom={true}
        {/* IMPORTANT: the map container needs a defined size, otherwise nothing will be visible */}
        style={{ height: "400px", width: "600px" }}
      >
        <TileLayer
          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        />
        <Marker position={position}>
          <Popup>
            This Marker icon is displayed correctly with <i>leaflet-defaulticon-compatibility</i>.
          </Popup>
        </Marker>
      </MapContainer>
  );
}

Import the map component with next/dynamic. This has to be done in a different file from the one where the map component lives and it has to be done in the top level of the file, not inside another component. Otherwise it won't work:

app/page.tsx

"use client";

import dynamic from "next/dynamic";

const LazyMap = dynamic(() => import("@/components/Map"), {
  ssr: false,
  loading: () => <p>Loading...</p>,
});

export default function Home() {
  return (
    <main>
      <LazyMap />
    </main>
  );
}

Using page.tsx as a server component

If you want your page to be a server component, you can move the client component down the tree and pass data as props.

components/Map.tsx

"use client";
import { useState } from 'react';

/* ... more imports ... */

export default function Map({ initialData }) {
  const [data, setData] = useState(initialData);
  
  /* ... more code ... */
}

components/MapCaller.tsx

'use client';

import dynamic from 'next/dynamic';

const LazyMap = dynamic(() => import("@/components/Map"), {
  ssr: false,
  loading: () => <p>Loading...</p>,
});

function MapCaller(props) {
  return <LazyMap {...props} />;
}

export default MapCaller;

app/page.tsx

import MapCaller from '@/components/MapCaller';
/* your fetch function that fetches data server side */
import { fetchData } from '@/lib/data';

export default async function Page() {
  const data = await fetchData();

  return <MapCaller initialData={data} />;
}

Upvotes: 16

Seeker
Seeker

Reputation: 220

We can use simple custom SVG icon which can be compatible with next.js.

Custom icons can add more control to your maps to represent with different colors subject to your requirements such as status.

Here is an example use case to use custom markers.

import { useEffect, useRef } from "react";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { Box } from "@mui/material";

interface MarkerProps {
  lat: number;
  lng: number;
  status?: string;
  popupData?: { [key: string]: any };
}

interface MapProps {
  markers: MarkerProps[];
  width?: string | number;
  height?: string | number;
  coordinates?: [number, number];
}

const LeafletMap: React.FC<MapProps> = ({
  markers,
  width = "100%",
  height = 400,
  coordinates = [42.505, -1.12],
}) => {
  const mapRef = useRef<L.Map | null>(null);
  const mapContainerRef = useRef<HTMLDivElement | null>(null);
  const markersRef = useRef<L.Marker[]>([]);

  const getIcon = (status: string) => {
    const baseSvg = `
      <svg
        width="24"
        height="24"
        viewBox="0 0 24 24"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
      >
        <path d="M12 2.16125C7.8 2.16125 4 5.38125 4 10.3612C4 13.5413 6.45 17.2813 11.34 21.5913C11.72 21.9213 12.29 21.9213 12.67 21.5913C17.55 17.2813 20 13.5413 20 10.3612C20 5.38125 16.2 2.16125 12 2.16125ZM12 12.1613C10.9 12.1613 10 11.2613 10 10.1613C10 9.06125 10.9 8.16125 12 8.16125C13.1 8.16125 14 9.06125 14 10.1613C14 11.2613 13.1 12.1613 12 12.1613Z" />
      </svg>
    `;

    const getColor = () => {
      switch (status) {
        case "Active":
          return "blue";
        case "Inactive":
          return "red";
        default:
          return "grey";
      }
    };

    const finalSvg = baseSvg.replace('fill="none"', `fill="${getColor()}"`);

    return L.icon({
      iconUrl: `data:image/svg+xml,${encodeURIComponent(finalSvg)}`,
      iconSize: [20, 32], // size of the icon
    });
  };

  useEffect(() => {
    if (mapContainerRef.current && !mapRef.current) {
      mapRef.current = L.map(mapContainerRef.current, {
        attributionControl: false,
      }).setView(coordinates, 13);

      L.tileLayer(
        "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
        {}
      ).addTo(mapRef.current);
    }
  }, []);

  useEffect(() => {
    if (mapRef.current) {
      // To Clear existing markers
      markersRef.current.forEach((marker) => {
        mapRef.current!.removeLayer(marker);
      });
      markersRef.current = [];

      markers.forEach((markerProps) => {
        const marker = L.marker([markerProps.lat, markerProps.lng], {
          icon: getIcon(markerProps.status || "default"),
        });

        if (markerProps.popupData) {
          const popupContent = Object.entries(markerProps.popupData)
            .map(([key, value]) => `<b>${key}:</b> ${value}`)
            .join("<br/>");
          marker.bindPopup(popupContent);
        }

        marker.addTo(mapRef.current!);
        markersRef.current.push(marker);
      });
    }
  }, [markers]);

  return <Box ref={mapContainerRef} style={{ height, width }} />;
};

export default LeafletMap;

and you can call this component and add props as follows:

<LeafletMap
        markers={markers}
        width="100%"
        height="300px"
        coordinates={[51.505, -0.09]}
      />

This input markers props and other props can be adjusted to your requirement.

Upvotes: 1

Related Questions