DEV Community

Nicolas Torres for PlaceKit

Posted on • Edited on • Originally published at placekit.io

Making React-Leaflet work with NextJS

I've ran into some issues implementing React Leaflet with NextJS for our admin panel at PlaceKit. So let's gather my findings into a single article, hoping it'll save you some time.

Because NextJS has an SSR (server-side rendering) layer, importing third-party front-end libraries sometimes results in headaches.

Most of the time, you just have to wrap your front-end component with next/dynamic to make it lazy load, making the SSR pass simply ignore it:

// MyComponent.jsx
import frontLib from '<your-front-end-library>';
const MyComponent = (props) => {
  // do something with frontLib
};
export default MyLazyComponent;


// MyPage.jsx
import dynamic from 'next/dynamic';

const MyComponent = dynamic(
  () => import('./MyComponent'),
  {
    ssr: false,
    loading: () => (<div>loading...</div>),
  }
);

const MyPage = (props) => (
  <MyComponent />
);
Enter fullscreen mode Exit fullscreen mode

But in the case of React Leaflet, you may need to put in some more efforts.

Passing ref

If you simply assign a ref to lazy-loaded <MapContainer>, you'll get a unusable proxy reference coming from dynamic:

// Map.jsx
import dynamic from 'next/dynamic';
import { useEffect, useRef } from 'react';

const MapContainer = dynamic(
  () => import('react-leaflet').then((m) => m.MapContainer),
  { ssr: false }
);

const Map = (props) => {
  const mapRef = useRef(null);
  useEffect(
   () => console.log(mapRef.current), // { retry: fn, ... }
   [mapRef.current]
  );
  return (
    <MapContainer ref={mapRef} ?>
  );
};

export default Map;
Enter fullscreen mode Exit fullscreen mode

The trick is a bit bulky, but you have to wrap it under another component and forward the ref as a standard property (mapRef here), and you lazy load that one instead:

// MapLazyComponents.jsx
import {
  MapContainer as LMapContainer,
} from 'react-leaflet';

export const MapContainer = ({ forwardedRef, ...props }) => (
  <LMapContainer {...props} ref={forwardedRef} />
);

// Map.jsx
import dynamic from 'next/dynamic';
import { forwardRef, useEffect, useRef } from 'react';

const LazyMapContainer = dynamic(
  () => import('./MapLazyComponents').then((m) => m.MapContainer),
  { ssr: false }
);

const MapContainer = forwardRef((props, ref) => (
  <LazyMapContainer {...props} forwardedRef={ref} />
));

const Map = (props) => {
  const mapRef = useRef(null);
  useEffect(
   () => console.log(mapRef.current), // this works!
   [mapRef.current]
  );
  return (
    <MapContainer ref={mapRef} />
  );
};

export default Map;
Enter fullscreen mode Exit fullscreen mode

Organizing your components

As we'll be preparing a few other React Leaflet components in the following examples, let's reorganise this into 3 files:

  • Map.jsx: your final component or page showing the map.
  • MapComponents.jsx: components that will lazy-load the React Leaflet ones. These will be ready to import as-is.
  • MapLazyComponents.jsx: wrappers that forward ref or are using front-end specific features, to be lazy-loaded by MapComponents.jsx.

Let's also add <TileLayer> and <ZoomControl> as we won't need any specific changes apart from loading them with dynamic.

So at this point you get:

// MapLazyComponents.jsx
import {
  MapContainer as LMapContainer,
} from 'react-leaflet';

export const MapContainer = ({ forwardedRef, ...props }) => (
  <LMapContainer {...props} ref={forwardedRef} />
);


// MapComponents.jsx
import dynamic from 'next/dynamic';
import { forwardRef } from 'react';

export const LazyMapContainer = dynamic(
  () => import('./MapLazyComponents').then((m) => m.MapContainer),
  {
    ssr: false,
    loading: () => (<div style={{ height: '400px' }} />),
  }
);
export const MapContainer = forwardRef((props, ref) => (
  <LazyMapContainer {...props} forwardedRef={ref} />
));

// direct import from 'react-leaflet'
export const TileLayer = dynamic(
  () => import('react-leaflet').then((m) => m.TileLayer),
  { ssr: false }
);
export const ZoomControl = dynamic(
  () => import('react-leaflet').then((m) => m.ZoomControl),
  { ssr: false }
);


// Map.jsx
import { useEffect, useRef } from 'react';

// import and use components as usual
import { MapContainer, TileLayer, ZoomControl } from './MapComponents.jsx';

const Map = (props) => {
  const mapRef = useRef(null);
  return (
    <MapContainer
      ref={mapRef}
      touchZoom={false}
      zoomControl={false}
      style={{ height: '400px', zIndex: '0!important' }}
    >
      <TileLayer url="..." attribution="..." style={{ zIndex: '0!important' }} />
      <ZoomControl position="topright" style={{ zIndex: '10!important' }} />
    </MapContainer>
  );
};

export default Map;
Enter fullscreen mode Exit fullscreen mode

Using custom Marker icons

Alright, now that we start having a Map, let's add a marker. But most of the time you'd want to use a custom icon with it.

Custom Marker icons need to use L.Icon() from leaflet itself, which is a library instantiating stuff in window, so it breaks SSR when importing in Next. But it can not be loaded with dynamic() or even with React.lazy() which are exclusive to lazy loading components.

So, let's wrap our <Marker> component in MapLazyComponents.jsx as it'll be depending on front-end exclusive features:

// MapLazyComponents.jsx
import { useEffect, useState } from 'react';
import {
  MapContainer as LMapContainer,
  Marker as LMarker,
} from 'react-leaflet';

// ...
export const Marker = ({ forwardedRef, icon: iconProps, ...props }) => {
  const [icon, setIcon] = useState();

  useEffect(
    () => {
      // loading 'leaflet' dynamically when the component mounts
      const loadIcon = async () => {
        const L = await import('leaflet');
        setIcon(L.icon(iconProps));
      }
      loadIcon();
    },
    [iconProps]
  );

  // waiting for icon to be loaded before rendering
  return (!!iconProps && !icon) ? null : (
    <LMarker
      {...props}
      icon={icon}
      ref={forwardedRef}
    />
  );
};

// MapComponents.jsx
// ...
const LazyMarker = dynamic(() => import('./MapLazyComponents').then((m) => m.Marker), { ssr: false });
export const Marker = forwardRef((props, ref) => (
  <LazyMarker {...props} forwardedRef={ref} />
));

// Map.jsx
// ...
import { MapContainer, TileLayer, ZoomControl, Marker } from './MapComponents.jsx';

import CustomIcon from '../public/custom-icon.svg';

const Map = (props) => {
  const mapRef = useRef(null);
  const markerRef = useRef(null);
  return (
    <MapContainer
      ref={mapRef}
      touchZoom={false}
      zoomControl={false}
      style={{ height: '400px', zIndex: '0!important' }}
    >
      <TileLayer url="..." attribution="..." style={{ zIndex: '0!important' }} />
      <ZoomControl position="topright" style={{ zIndex: '10!important' }} />
      <Marker
        ref={markerRef}
        icon={{
          iconUrl: CustomIcon.src,
          iconAnchor: [16,32],
          iconSize: [32,32]
        }}
        style={{ zIndex: '1!important' }}
      />
    </MapContainer>
  );
};
//...
Enter fullscreen mode Exit fullscreen mode

Handling map events

For marker events, you can already pass the eventHandlers property and it'll work. But to handle map events, it can not be done on the <MapContainer> component, you need to use the useMapEvents() hook from React Leaflet in a child component.

Same here, we'll need to wrap it, and we'll do it within a custom <MapConsumer> element to simplify things:

// MapLazyComponents.jsx
//...
import { useMapEvents } from 'react-leaflet/hooks';
export const MapConsumer = ({ eventsHandler }) => {
  useMapEvents(eventsHandler);
  return null;
};

// MapComponents.jsx
//...
export const MapConsumer = dynamic(
  () => import('./MapLazyComponents').then((m) => m.MapConsumer),
  { ssr: false }
);
Enter fullscreen mode Exit fullscreen mode

So in you Map.jsx file, you're now able to add <MapConsumer> in <MapContainer>:

// Map.jsx
//...
const Map = (props) => {
  const mapRef = useRef(null);
  const markerRef = useRef(null);

  const mapHandlers = useMemo(
    () => ({
      click(e) {
        // center view on the coordinates of the click
        // `this` is the Leaflet map object
        this.setView([e.latlng.lat, e.latlng.lng]);
      },
    }),
    []
  );

  return (
    <MapContainer
      ref={mapRef}
      touchZoom={false}
      zoomControl={false}
      style={{ height: '400px', zIndex: '0!important' }}
    >
      <TileLayer url="..." attribution="..." style={{ zIndex: '0!important' }} />
      <ZoomControl position="topright" style={{ zIndex: '10!important' }} />
      <MapConsumer
        eventsHandler={mapHandlers}
      />
      <Marker
        ref={markerRef}
        icon={{
          iconUrl: CustomIcon.src,
          iconAnchor: [16,32],
          iconSize: [32,32]
        }}
        style={{ zIndex: '1!important' }}
      />
    </MapContainer>
  );
};
Enter fullscreen mode Exit fullscreen mode

A few states and CSS later, here's my result:

Image description


So we've seen how to:

  1. lazy load components with next/dynamic,
  2. make ref work with lazy-loaded components,
  3. dynamically load leaflet to access its methods like L.Icon,
  4. wrap react-leaflet custom hooks to handle events.

Adapting these tricks should cover most of your edge cases. I hope breaking down into these specific use-cases will help you work better with React Leaflet on NextJS!

And of course, if you need a reverse geocoding API to get coordinates from an address, have a look at PlaceKit.io :)!

Top comments (3)

Collapse
 
ojtli profile image
Ojtli CA

Did you find this error:
11:29 Error: Component definition is missing display name react/display-name

Seems to be related to the usage of forwardRef, e.g.:
export const MapContainer = forwardRef((props, ref) => (

Suggested solution seems to define the object and separately export it (stackoverflow.com/a/69302038) but since there are several such objects in the file, I wonder if there's an easier way to do it?

Collapse
 
3amfeelin9 profile image
3amfeelin9

Can this also increase the speed of react-leaflet in displaying thousands of markers without cluster-markers? in my case I need to display a lot of markers to mark each travel history point so I don't need cluster-markers but for some reason the Map becomes very slow and renders every time I drag/zoom

Collapse
 
noclat profile image
Nicolas Torres

Hi, that's 100% related to Leaflet, and am not sure if there's a solution for your use-case. There must be some optimization to display more markers, but you'll always hit a performance limit.