back to Blog

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 />
);

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;

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;

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;

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>
  );
};
//...

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 }
);

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>
  );
};

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 :)!