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 forwardref
or are using front-end specific features, to be lazy-loaded byMapComponents.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:
So we've seen how to:
- lazy load components with
next/dynamic
, - make
ref
work with lazy-loaded components, - dynamically load
leaflet
to access its methods likeL.Icon
, - 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)
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?
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
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.