React, Leaflet, and SSR

React, Leaflet, and SSR

· 10 min read
A guide on using Leaflet for adding OpenStreetMap to a React application, with a hint of SSR and Preact thrown in the mix.

IntroductionSection titled Introduction

OpenStreetMap is a great free alternative to Google Maps and Apple Maps. In this blog post, I demonstrate how to use Leaflet for adding interactive maps to React websites, implementing performant markers, and finally supporting SSR environments such as Astro and Next.js.

Please note that I’m using type imports of Preact instead of React, since it decreases the size of my blog’s client-side JavaScript bundles. Preact has a React compatibility mode, so the imports are the only difference from a React implementation. In the following code snippets, I’ll add notes about differences to React as comments.

Creating a Reusable ComponentSection titled Creating a Reusable Component

InstallationSection titled Installation

First, we need to install the packages leaflet, @types/leaflet, and react-leaflet:

yarn install leaflet react-leaflet
yarn install -D @types/leaflet

Leaflet will handle the visualization and interactivity of our maps. react-leaflet includes various React components that provide bindings to Leaflet’s DOM rendering, making it a lot simpler to integrate with a React application.

ImplementationSection titled Implementation

Below is the code for a basic reusable map component. I’ll explain the important points in detail.

  1. The children prop is used to make the component reusable. It enables us to include various elements, such as markers.
  2. By also using MapOptions as the props type, it’s possible to pass any of Leaflet’s options directly to the map.
  3. It’s important to give the MapContainer a height. In this example, I used Tailwind’s utility h-[200px].
  4. TileLayer handles the visualization and design of our map. Leaflet allows us to specify a provider for tile layers. Previews and instructions for various providers can be found here. Keep in mind that not all of them are free, so always check their individual terms and conditions. (And don’t forget to add the correct attribution!) In this blog, I am using Basemaps styles from CARTO.
  5. Styling! Leaflet’s styles have to be imported manually, as described next in Styling.
import type { MapOptions } from 'leaflet'
// React: import type { FC, ReactNode } from 'react'
import type { ComponentChildren, FunctionalComponent } from 'preact'
import { MapContainer, TileLayer } from 'react-leaflet'

const LeafletMap: FunctionalComponent<
  {
    center: [number, number]
    children: ComponentChildren
    zoom: number
  } & MapOptions
> = ({ children, ...options }) => {
  return (
    <MapContainer
      className="h-[200px] w-full relative"
      maxZoom={18}
      {...options}
    >
      <TileLayer
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
        url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
      />
      {children}
    </MapContainer>
  )
}

export default LeafletMap

Our new LeafletMap component can be used as seen below. For now, we will not make use of the children prop and pass an empty fragment instead.

<LeafletMap center={center} zoom={13}>
  <></>
</LeafletMap>

StylingSection titled Styling

Setting up Leaflet’s is straight forward. Just add the following import to your project:

import 'leaflet/dist/leaflet.css'

Note: With Next.js, you may need to import the styles in your project’s global.css.

If you have any modals, sticky elements or similar content on your website, maps will render above these elements. For some reason, certain elements of Leaflet’s maps come with z-index of 400! A simple fix for this issue is settings the z-index of the offending elements back to 0.

.leaflet-bottom,
.leaflet-control,
.leaflet-pane,
.leaflet-top {
  z-index: 0 !important;
}

Adding MarkersSection titled Adding Markers

The default marker icons of Leaflet will not work with Next.js out of the box, since they are imported with require statements. In this section, I will create a custom marker icon for my maps.

In this example, I copied the required files to the public/static/leaflet directory of my Astro project and specified them in the code below.

You may use Leaflet’s files for marker icons by copying them from node_modules/leaflet/dist/images/*.png to your public directory.

While the iconSize and shadowSize should obviously match the dimensions of provided images, the values of iconAnchor and shadowAnchor are not trivial. The former should be equal to [iconWidth / 2, iconHeight / 2], if your marker has a centered tip at the bottom, like the one used in this example. I had to experiment with Leaflet’s marker shadow to find the right anchor, and in the end shadowAnchor: [12, 41] resulted in perfect alignment. Your mileage may vary with other images!

import { Marker, icon } from 'leaflet'

Marker.prototype.options.icon = icon({
  iconUrl: '/static/leaflet/map-marker.svg',
  iconRetinaUrl: '/static/leaflet/map-marker.svg',
  iconSize: [24, 24],
  iconAnchor: [12, 24],
  shadowUrl: '/static/leaflet/marker-shadow.png',
  shadowRetinaUrl: '/static/leaflet/marker-shadow.png',
  shadowSize: [41, 41],
  shadowAnchor: [12, 41],
})

Now, our markers can be added to maps as seen in the snippet below:

import { Marker } from 'react-leaflet'

const center: [number, number] = [48.2082, 16.3738]
const markers: [number, number][] = [center, [48.2, 16.37], [48.1987, 16.3685]]

<LeafletMap center={center} zoom={13}>
  {markers.map((position, index) => (
    <Marker key={index} position={position} />
  ))}
</LeafletMap>

Improving PerformanceSection titled Improving Performance

The code above works fine for the few example markers, but it has various downsides:

Fortunately, Leaflet.markercluster fixes this issue by grouping markers that are too close together in clusters! By using marker clusters, we prevent too many markers being rendered at once, as well as markers being too close together.

There are a few React-specific wrappers for Leaflet.markercluster, but most of them will not work with React 18. After scouring GitHub issues for some time, I found @changey/react-leaflet-markercluster. While it doesn’t provide type definitions (how unfortunate!), it works with React 18. It can be installed as follows:

yarn install @changey/react-leaflet-markercluster

And there are more good news; using this library is not difficult either. Analogously to our map, we’ll create a reusable <MarkerCluster> component. The library comes with a styled cluster icon by default. The custom icon below is for demonstration, as it allows me to align the style of my marker and cluster icons.

// @ts-expect-error Missing type definitions
import BaseMarkerCluster from '@changey/react-leaflet-markercluster'
import { divIcon, point } from 'leaflet'
// React: import type { FC, ReactNode } from 'react'
import type { ComponentChildren, FunctionalComponent } from 'preact'

const createClusterCustomIcon = (cluster: any) => {
  return divIcon({
    html: `<span>${cluster.getChildCount()}</span>`,
    className:
      'bg-[#e74c3c] bg-opacity-100 text-white font-bold !flex items-center justify-center rounded-3xl border-white border-4 border-opacity-50',
    iconSize: point(40, 40, true),
  })
}

export const MarkerCluster: FunctionalComponent<{
  children: ComponentChildren
}> = ({ children }) => {
  return (
    <BaseMarkerCluster
      iconCreateFunction={createClusterCustomIcon}
      showCoverageOnHover={false}
    >
      {children}
    </BaseMarkerCluster>
  )
}

To use marker clusters, simply wrap your <Marker> elements in a <MarkerCluster>:

<LeafletMap center={center} zoom={13}>
  <MarkerCluster>
    {markers.map((position, index) => (
      <Marker key={index} position={position} />
    ))}
  </MarkerCluster>
</LeafletMap>

Supporting SSRSection titled Supporting SSR

If you have tried using react-leaflet in an SSR context, you’ll also have encountered the dreaded window is not defined. I won’t make any false promises, the maps won’t actually be rendered on the server; Leaflet just doesn’t support that. However, we will be able to skip Leaflet in SSR environments and hydrate our maps client-side.

To achieve that, we will use React 18’s React.lazy. A separate file is required for lazily importing our existing components, e.g., LeafletMap.lazy.tsx.

// React: import React, { Suspense, lazy } from 'react'
import { Suspense, lazy } from 'preact/compat'

const LazyLeafletMap = lazy(() => import('./LeafletMap'))
const LazyMarker = lazy(async () => (await import('react-leaflet')).Marker)
const LazyMarkerCluster = lazy(
  async () => (await import('./LeafletMap')).MarkerCluster
)

In the same file, declare a new component as follows:

import type { MapOptions } from 'leaflet'
// React: import type { FC } from 'react'
import type { FunctionalComponent } from 'preact'
// React: import React, { Suspense, lazy } from 'react'
import { Suspense, lazy } from 'preact/compat'

export const LeafletMapWithClusters: FunctionalComponent<
  {
    center: [number, number]
    markers: [number, number][]
  } & MapOptions
> = ({ center, markers, ...options }) => {
  return (
    <Suspense fallback={<div className="h-[200px]" />}>
      <LazyLeafletMap center={center} zoom={13} {...options}>
        <Suspense fallback={<></>}>
          <LazyMarkerCluster>
            {markers.map((position, index) => (
              <LazyMarker key={index} position={position} />
            ))}
          </LazyMarkerCluster>
        </Suspense>
      </LazyLeafletMap>
    </Suspense>
  )
}

Update all usages of our initial map component to use the newly created component, and you’re good to go! … unless you’re using Next.js or Astro. If that’s the case, I still got you covered. Just keep reading!

AstroSection titled Astro

With Astro, you also need to update the usage of your lazy components, to include client:only="preact" (or client:only="react" depending on your setup). For example, an import of the first example map in this .mdx blog looks like this:

import { LeafletMap } from './LeafletMap.lazy.tsx'

<LeafletMap client:only="preact" />

Note: At the time of writing, Astro may throw an error when importing your Preact component this way, see withastro/astro#3833. As described here, a workaround is importing preact in the offending file.

13th January 2023: This issue has been fixed.

Next.jsSection titled Next.js

Making Leaflet work with Next.js is not much different. Instead of using React’s lazy, we can make use of Next’s dynamic with ssr: false to skip rendering Leaflet components on the server:

import dynamic from 'next/dynamic'

export const LazyMap = dynamic(() => import('./LeafletMap'), { ssr: false })

export const LazyMarker = dynamic(
  async () => (await import('react-leaflet')).Marker,
  {
    ssr: false,
  }
)

export const LazyMarkerCluster = dynamic(
  async () => (await import('./LeafletMap')).MarkerCluster,
  {
    ssr: false,
  }
)

Now, all the <Lazy...> components can be used even without <Suspense> in any other component or page.

React Leaflet HooksSection titled React Leaflet Hooks

13th January 2023: This section has been added to showcase the usage of react-leaflet hooks in an SSR environment.

A reader asked me how to use the react-leaflet hooks (e.g., useMap, useMapEvents) in an SSR environment. Because the hook imports also use window, they will throw an error in SSR environments if used directly. I added the following example to showcase this scenario, using the same lazy-loading approach as above.

I chose a simple example, where the current center of the map is displayed in the top right corner of the map. Again, it’s not rendered on the server, but it will be hydrated client-side.

The following Preact component uses the useMap hook to get the initial center of the map, which is then displayed as formatted text. The listeners registered with useMapEvents will update the center whenever the map is moved (i.e, panned) or zoomed. As a bonus, the text is clickable and will reset the zoom level to 13 (i.e., the default I chose for the map).

import { useMap, useMapEvents } from 'react-leaflet'

export const MapCenter: FunctionalComponent = () => {
  const map = useMap()
  const [location, setLocation] = useState(map.getCenter())
  const { lat, lng } = location
  const text = `${lat.toFixed(4)}, ${lng.toFixed(4)}`
  useMapEvents({
    move(event) {
      setLocation(event.target.getCenter())
    },
    zoom(event) {
      setLocation(event.target.getCenter())
    },
  })
  return (
    <span
      class="button absolute top-2 right-2 z-101 bg-white text-black rounded px-2 py-1 border-2 border-neutral-400"
      onClick={() => map.setZoom(13)}
    >
      {text}
    </span>
  )
}

Just as before, the component has to be imported lazily, and wrapped in a <Suspense> boundary. Reference the Next.js guide on how this import should look like for Next.js.

const LazyMapCenter = lazy(async () => (await import('./LeafletMap')).MapCenter)

export const LeafletMapWithCenterText: FunctionalComponent<
  Omit<MapOptions, 'center' | 'zoom'>
> = (props) => {
  return (
    <Suspense fallback={<div className="h-[200px]" />}>
      <LazyLeafletMap center={center} zoom={13} {...props}>
        <Suspense fallback={<></>}>
          <LazyMapCenter />
          <LazyMarkerCluster>
            {markers.map((position, index) => (
              <LazyMarker key={index} position={position} interactive={false} />
            ))}
          </LazyMarkerCluster>
        </Suspense>
      </LazyLeafletMap>
    </Suspense>
  )
}

Try it out in the map below, by moving, zooming, or clicking the text in the top right corner (after changing the zoom level).

ConclusionSection titled Conclusion

Leaflet and react-leaflet are powerful libraries for working with OpenStreetMap. With some tweaking regarding icon configurations, lazy loading, and custom providers, they enable great looking and interactive maps on any React environment.

Markers in combination with marker clusters make it possible to render large amounts of locations with good performance and usability in mind.

For an implementation in a real project, see WienerTime’s map of public transit stations.