Next.js ile React-Leaflet Kullanımı

Mehmet Mutlu
Sahibinden Technology
10 min readNov 21, 2023

Günümüzün popüler framework’lerinden Next.js üzerinde JavaScript ekosisteminin önde gelen açık kaynak kütüphanelerinden olan Leaflet’in kullanımını örneklerle açıklamak ve sürdürülebilir bir proje yapısı kurabilmek için bu yazıyı ele aldım. Burada incelemiş olduğum projeler ve kendi deneyimlerim ile edinmiş olduğum bilgilerimi paylaşarak faydalı olabilmeyi amaçlamaktayım. Yazımın devamında öncelikle Next.js ve react-leaflet’i tanıyıp kurulumları yapacağız. Sonrasında ise küçük örnekler üzerinden kullanım detaylarını gözlemleyeceğiz.

Next.js ve Kurulumu

Next.js, bilindiği üzere React’ın modern framework’lerinden biridir ve güncel React dokümanlarında kullanılması tavsiye edilen ilk framework’tür. Bunun yanı sıra server-side rendering yetenekleri ile React’ın en büyük zorluklarından olan SEO optimizasyonuna kolaylık sağlar. Hibrit mimarisi, geliştiricilere static generation (SSG) ve server-side rendering (SSR) arasında seçim imkanı sunarak muazzam bir esneklik sağlar. Ayrıca, build-in routing, API routes ve otomatik code splitting ile hem geliştirici hem de son kullanıcı deneyimini optimize eder.

Next.js’in dokümanında kurulum için detaylı bilgiye ulaşabilirsiniz. Ben bu bölümde otomatik kurulum hakkında özet bir bilgi vereceğim.

Next.js kurmadan önce bilgisayarımızdaki kurulu Node.js versiyonunun 16.14 veya daha ileride bir versiyon olduğundan emin olmamız gerekir.

Next.js’in de önerdiği gibi create-next-app ile hızlı bir şekilde kurulumu yapabilirsiniz. Kurulumu başlatmak için:

npx create-next-app@latest

Daha sonra karşınıza çıkacak olan sorulara vereceğiniz cevaplar ile projenizi özelleştirip kurulumu tamamlayabilirsiniz. (Aşağıdaki cevaplar örnek amaçlıdır projeyi istediğiniz ayarlar ile ayağa kaldırabilirsiniz.)

What is your project named? my-app
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like to use `src/` directory? No / Yes
Would you like to use App Router? (recommended) No / Yes
Would you like to customize the default import alias (@/*)? No / Yes
What import alias would you like configured? @/*

Bu aşamadan sonra Next.js projemiz kurulmuş olacak ve oluşturduğumuz projemize react-leaflet kütüphanesini kurarak devam edeceğiz.

React-leaflet ve Kurulumu

React-leaflet, popüler harita kütüphanesi olan Leaflet’in kullanımı daha basit ve React dostu haline getirilmiş bir versiyonudur. React-leaflet ile geliştiriciler component tabanlı mimarinin React paradigmasına bağlı kalarak Leaflet’in kapsamlı özelliklerinden faydalanabilirler.

React-leaflet’in kurulumunu yapmak için projemiz içerisinde aşağıdaki scripti çalışmamız gerekmektedir.

npm install leaflet react-leaflet
yarn add leaflet react-leaflet

React-leaflet Kullanımı

Paketlerin indirilmesi tamamlandıktan sonra düzgün bir klasör yapısı (folder-structure) kurmak adına “app” klasörü altında “component” adında bir klasör oluşturalım. Oluşturmuş olduğumuz “component” klasörü altına ise “Map” adında bir folder oluşturalım ve içerisine “DynamicMap.tsx” şeklinde ilk component’imizi oluşturalım.


import { MapOptions } from "leaflet";
import { memo, ReactNode } from "react";
import * as ReactLeaflet from "react-leaflet";

interface MapProps extends MapOptions {
children: ReactNode;
}

const DynamicMap = ({ children, ...other}: MapProps) => {

return (
<ReactLeaflet.MapContainer style={{width: "100%", height: "100%"}} {...other}>
{children}
</ReactLeaflet.MapContainer>
);
};

export default memo(DynamicMap);

Projeniz eğer leaflet’i görmezse aşağıdaki scripti çalıştırabilirsiniz.

npm i --save-dev @types/leaflet

yarn add --dev @types/leaflet

Burada kullanmış olduğumuz MapContainer ile birlikte haritamızın temellerini atmış oluyoruz. İçerisinde vermiş olduğumuz “{children}” ile yazımızın ilerleyen kısımlarında marker, layer vb. component’leri ekleyebileceğiz. Ayrıca burada MapContainer’a özel CSS kodlarımızı verebilir (className) veya “Leaflet.Icon.Default” üzerinden iconUrl değişikliği gibi ekstra işlerimizi yapabiliriz. (Marker’ınız gözükmezse veya custom bir marker kullanmak isterseniz aşağıdaki kodlardan faydalanabilirsiniz.)

useEffect(() => {
if (typeof window !== "undefined") {
(async function init() {
// @ts-ignore
delete Leaflet.Icon.Default.prototype._getIconUrl;
Leaflet.Icon.Default.mergeOptions({
iconSize: [<width>, <height>],
iconAnchor: [<width>, <height>],
iconRetinaUrl: "<icon-url>",
iconUrl: "<icon-url>",
});
})();
}
}, []);

Yukarıdaki örnekte de olduğu gibi useEffect, useState vb. hooklar veya client-side çalışacan yapılar kullanılması gerekirse componentimizin en üstüne “use client” bilgisini eklememiz gerekmektedir.

Sıradaki adımımızda “MapContent.tsx” adında bir component oluşturacağız. Daha önce oluşturmuş olduğumuz “DynamicMap.tsx”i burada kullanıp içerisine de TileLayer, Marker vb. component’leri geçerek haritamızın ve marker’ımızın gözükmesini sağlayacağız.

"use client";

import { LatLngBounds, LatLngTuple, latLng, latLngBounds } from "leaflet";
import React from "react";
import { Marker, Popup, TileLayer } from "react-leaflet";
import DynamicMap from "./DynamicMap";

import "leaflet/dist/leaflet.css";

const MapContent = () => {
const centerLocation: LatLngTuple = [40.9610678, 29.1104779];
const zoom: number = 10;
const mapBoundaries = {
southWest: latLng(34.025514, 25.584519),
northEast: latLng(42.211024, 44.823563),
};
const bounds = latLngBounds(mapBoundaries.southWest, mapBoundaries.northEast);

return (
<DynamicMap
zoomControl={true}
center={centerLocation}
zoom={zoom}
minZoom={7}
maxBounds={bounds}
maxZoom={18}
>
<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={centerLocation}>
<Popup>
A pretty CSS3 popup. <br /> Easily customizable.
</Popup>
</Marker>
</DynamicMap>
);
};

export default MapContent;

MapContent.tsx içerisinde özellikle react-leaflet kütüphanesinden component’ler kullandığımız için “use client” bilgisini eklememiz gerekiyor (component’ler içerisinde useState, useRef gibi hook’lar kullanılıyor). Ardından haritamızın görüntüsünde sorunlar olmaması adına Leaflet’in CSS kütüphanesini import etmemiz gerekiyor.

Kullanmış olduğumuz “centerLocation” değerini örnek olması için şirketim sahibinden.com’un lokasyon bilgisi ile doldurdum. Bu değeri hem marker’ımızın olduğu konumu göstermek hem de haritamızın ortalanması için kullanıyoruz. Buradaki değer istenirse browser yardımı ile kullanıcıdan alınarak dinamik bir şekilde kullanılabilir. Bu kullanım için aşağıda bir custom hook örneği paylaşıyorum.

import { useState, useEffect } from "react";

function useUserLocation() {
const [location, setLocation] = useState<[number, number] | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);

useEffect(() => {
if (!navigator.geolocation) {
setError("Geolocation is not supported by this browser.");
setLoading(false);
return;
}

navigator.geolocation.getCurrentPosition((position) => {
const { latitude, longitude } = position.coords;
setLocation([latitude, longitude]);
setLoading(false);
}, (err) => {
setError(err.message);
setLoading(false);
});
}, []);

return { location, error, loading };
}

export default useUserLocation;

Kullanmış olduğumuz diğer bir değer olan “zoom”a örnek olması adına 10 değeri verilmiştir. Bu değer adından da anlaşıldığı üzere harita yüklendiği esnada hangi oranda zoomlanmış bir şekilde gözükeceğinin bilgisi tutmaktadır. Haritanın kullanım amacına göre dinamik bir değer verilirse zoom oranını dinamik olarak değiştirme imkanı sunar. Bu değerle bağlantı olarak “minZoom” ve “maxZoom” attribute’ları ile yapılacak minimum ve maksimum zoom oranlarını belirleyebiliriz. Ek olarak da “zoomControl” attribute’u ile birlikte zoom yapmak için “+” ve “—” butonlarını UI’a ekleyebiliriz.

Son olarak ise “maxBounds” attribute’una verdiğimiz değer ile haritanın hangi sınırlar içerisinde kalmasını gerektiğini belirliyoruz. Şu anki değerler ile haritamız Türkiye sınırları içerisinde kalmaktadır. Bu attribute’ları react-leaflet’in dokümanında detaylı olarak inceleyebilir ekstra attribute’lar ekleyebilir veya kullanmak istemediklerinizi çıkarabilirsiniz.

“TileLayer” component’ini “MapContent.tsx” içerisine vererek kullanacağımız harita sağlayıcısını belirliyoruz. Şu an react-leaflet’in de önerdiği şekilde Open Street Map’i kullandık fakat google gibi farklı harita sağlayıcıları da kullanılabilir.

Google Map Haritası için kullanılabilecek “TileLayer”:

<TileLayer
attribution="Google Maps"
url="https://www.google.cn/maps/vt?lyrs=m@189&gl=cn&x={x}&y={y}&z={z}"
/>

“Marker” component’i ve position attribute’u ile marker’ımızı harita üzerinde istediğimiz lokasyona yerleştiriyoruz. Birden fazla marker göstermek istersek elimizdeki datayı map’leyebiliriz fakat çok fazla marker’ımız olacak ise bunu cluster yapısı kurarak yapmamız hem performans hem de görüntü açısından daha sağlıklı olacaktır. Ayrıca “Popup” component’i yardımıyla da marker’lara tıklayınca üzerine açılacak popup’ları kolayca yönetebiliriz.

const markerData = [
[40.9610678, 29.1104779],
[40.28323, 30],
[39.9610678, 28.1104779],
];

{markerData.map((item, index) => (
<Marker position={item as LatLngTuple}>
<Popup>
{index}. Popup
</Popup>
</Marker>
))}

Cluster Yapısının Kurulması

Cluster yapısı aslında marker’ların hepsini tek tek göstermek yerine belirli bölgelerde gruplayarak göstermeye verilen addır. Bu yapıyı kurmak için öncelikle app/components altına “Cluster” adında bir klasör oluşturarak başlayabiliriz. Kuracağımız yapı altında, “use-supercluster” kütüphanesinden cluster’larımızı kolay bir şekilde oluşturmak amacıyla faydalanacağız. Ayrıca Leaflet’in markercluster kütüphanesini kurmamız da gerekecek. Bu kütüphaneleri aşağıdaki script ile birlikte projemize kurabiliriz.

npm install supercluster use-supercluster
yarn add supercluster use-supercluster

npm i leaflet.markercluster
yarn add leaflet.markercluster

Kurulumdan sonra “Cluster” klasörümüz altına “style.scss” adında style dosyamızı oluşturarak başlayabiliriz.

.custom-cluster {
&-inner {
border-radius: 100px;
color: #212121;
width: 36px;
height: 36px;
opacity: 0.9;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;

&-safe {
background-color: #ffff00;
border: #ffff00 2px solid;
}

&-low {
background-color: #ffd700;
border: #ffd700 2px solid;
}

&-mid-low {
background-color: #ffaf00;
border: #ffaf00 2px solid;
}

&-mid {
background-color: #ff8700;
border: #ff8700 2px solid;
}

&-mid-high {
background-color: #ff5f00;
border: #ff5f00 2px solid;
}

&-high {
background-color: #eb2f2f;
border: #eb2f2f 2px solid;
}
}
}

Burada, oluşturacağımız cluster’larımızın stillerini oluşturduk. Anlaşıldığı üzere farklı classlar vererek farklı yoğunluktaki cluster’ları ayırt edeceğiz. Hemen ardından tiplerimizi ve cluster’ın yapısını oluşturacağımız “ClusterData.ts” dosyamızı oluşturabiliriz.

export type ClusterDataType = {
id: number;
intensity: string;
minClus: number;
maxClus?: number;
};

export interface IClusterData {
[key: string]: ClusterDataType;
}

export const ClusterData: IClusterData = {
safe: {
id: 1,
intensity: "safe",
minClus: 0,
maxClus: 0
},
low: {
id: 2,
intensity: "low",
minClus: 1,
maxClus: 15
},
"mid-low": {
id: 3,
intensity: "mid-low",
minClus: 16,
maxClus: 35
},
mid: {
id: 4,
intensity: "mid",
minClus: 36,
maxClus: 65
},
"mid-high": {
id: 5,
intensity: "mid-high",
minClus: 66,
maxClus: 85
},
high: {
id: 6,
intensity: "high",
minClus: 86
}
};

export function findClusterData(clusterCount: number): ClusterDataType {
const data = Object.values(ClusterData).find(
(item) =>
clusterCount >= item.minClus && clusterCount <= (item.maxClus ?? Number.MAX_SAFE_INTEGER)
);

return data || ClusterData.safe;
}

Burada ise gerekli tiplerimizi belirleyerek oluşturacağımız cluster yapısının hatlarını da belirlemiş olduk. “minClus” ve “maxClus” ile birlikte içinde barındıracağı marker aralığını belirleyip buna uygun yoğunluğu temsil edecek “intensity” değerini oluşturduk.

Ardından “Cluster.tsx” componentimizi oluşturup cluster’larımızı uygulamamıza entegre edebilecek duruma gelebiliriz.

import L from "leaflet";
import { Marker, useMap } from "react-leaflet";
import useSupercluster from "use-supercluster";
import { findClusterData } from "./ClusterData";

import "./style.scss";

const getIcon = (count: number) => {
const data = findClusterData(count);

return L.divIcon({
html: `<div class="custom-cluster-inner-${data.intensity}"><span>${count}</span></div>`,
className: `leaflet-marker-icon marker-cluster leaflet-interactive custom-cluster`,
});
};

type Props = {
data: <data-response-interface> | null;
};

const Cluster = ({ data }: Props) => {
const map = useMap();
const bounds = map.getBounds();

const geoJSON = data
?.filter(item => item?.location?.lat && item?.location?.lon)
?.map((item) => {
return {
type: "Feature",
geometry: {
type: "Point",
coordinates: [item.location.lon, item.location.lat],
},
item,
properties: { cluster: false, id: item.id, category: item.category },
};
}) ?? [];

const { clusters, supercluster } = useSupercluster({
points: geoJSON,
bounds: [
bounds.getSouthWest().lng,
bounds.getSouthWest().lat,
bounds.getNorthEast().lng,
bounds.getNorthEast().lat,
],
zoom: map.getZoom(),
options: { radius: 300, maxZoom: 17 },
});

return (
<>
{clusters.map((cluster) => {
const [longitude, latitude] = cluster.geometry.coordinates;
const { cluster: isCluster, point_count: pointCount, id } = cluster.properties;

if (isCluster) {
return (
<Marker
key={`cluster-${cluster.id}`}
position={[latitude, longitude]}
icon={getIcon(pointCount)}
eventHandlers={{
click: () => {
const expansionZoom = Math.min(
supercluster.getClusterExpansionZoom(cluster.id),
18
);
map.setView([latitude, longitude], expansionZoom, {
animate: true,
});
},
}}
/>
);
}

return (
<Marker key={`cluster-${cluster.properties.id}`} position={[latitude, longitude]} />
);
})}
</>
);
};

export default Cluster;

“Cluster.tsx” component’ini oluştururken data’dan dönecek bilgiler dummy olarak düşünülmüştür. Entegre edeceğiniz yere göre datanın tipi ile kullanım yerlerini değiştirip ilerleyebiliriz.

Bu component’in içerisinde öncelikle “getIcon” fonksiyonunu oluşturduk ve bu fonksiyon ile oluşturacağımız cluster’ın rengi, içerdiği marker sayısı gibi değerler ile yapısına karar vermiş olduk. Hemen ardından react-leaflet’in içerisinden almış olduğumuz “useMap” ile birlikte, göreceğimiz ekrandaki haritanın sınırlarını belirledik ve bu değerleri “useSupercluster” metodu içerisinde kullandık. Sonrasında gelen data’mızın “useSupercluster” metoduna uygun olması için map’leme işleminden geçirdik (bu durum sizin kullanacağınız data için gerekli olmayabilir veya farklı bir map’leme işlemine tabi tutulabilir). Ardından “useSupercluster” metodu ile birlikte cluster’larımızı oluşturup component’in return kısmında kullandık. Son durumda “isCluster” değeri true dönen data için cluster’ımızı render ederken false dönenler için ise normal marker’ımızı render ettik.

Kurmuş olduğumuz yapıyı projemizde görmek için app/components/Map/MapContent.tsx içerisindeki “Marker” component’ini kaldırıp “Cluster” component’ini eklememiz gerekir.

"use client";

import { LatLngBounds, LatLngTuple, latLng, latLngBounds } from "leaflet";
import React from "react";
import { TileLayer } from "react-leaflet";
import DynamicMap from "./DynamicMap";
import Cluster from "../Cluster/Cluster";

import "leaflet/dist/leaflet.css";

const MapContent = () => {
const centerLocation: LatLngTuple = [40.9610678, 29.1104779];
const zoom: number = 10;
const mapBoundaries = {
southWest: latLng(34.025514, 25.584519),
northEast: latLng(42.211024, 44.823563),
};
const bounds = latLngBounds(mapBoundaries.southWest, mapBoundaries.northEast);

return (
<DynamicMap
zoomControl={true}
center={centerLocation}
zoom={zoom}
minZoom={7}
maxBounds={bounds}
maxZoom={18}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Cluster data={<your-data>} />
</DynamicMap>
);
};

export default MapContent;

Bu kullanımı uyguladıktan sonra clusterlarınıza tıkladığınızda veya sayfa hareketleriniz sonrasında clusterların yeniden hesaplanmadığını fark edebilirsiniz. Bu durumu çözmenin temel mantığı DynamicMap component’indeki zoom değerini dinamik tutmak olacaktır. Benim uygulamış olduğum çözüm yöntemini görmek için bir sonraki konuyu takip edebilirsiniz.

useMapEvents Çözümü

Bu durumun çözümü için birçok farklı çözüm uygulanabilir fakat ben burada önce “Zustand” kütüphanesini kullanarak zoom bilgilerini global state’te (evrensel değişken) tutacağım. Ardından bir custom hook oluşturup react-leaflet’in useMapEvents metodundan faydalanarak harita üzerindeki hareketleri global state’lere kaydedeceğim. Son olarak da global state’lerde tuttuğumuz bu değeri “MapContent” componentinde kullanarak sorunu çözmüş olacağız.

Burada Zustand kullanmamın ve custom bir hook oluşturmamın amacı projenin ileri aşamalarında kullanılabilir ve sürdürülebilir yapılar kurmak istememden dolayıdır.

Bu işlerimleri yaparken kullanacağımız kütüphaneleri (Zustand, use-debounce ve lodash) projemize indirerek başlayabiliriz.

npm i zustand
yarn add zustand

npm i --save use-debounce
yarn add use-debounce

npm i --save lodash
yarn add lodash

Ardından “app” klasörü altına “store” adında bir klasör oluşturup içerisinde Zustand ile ilgili yapılarımızı oluşturacağız. İlk olarak “mapGeoStore.ts” adında bir dosya oluşturup başlayabiliriz.

import omit from "lodash.omit";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { getHashStorage } from "../utils/zustand";

interface State {
zoom: number;
actions: {
setZoom: (_zoom: number) => void;
};
}

export const useMapGeoStore = create<State>()(
persist(
(set) => ({
zoom: 8,
actions: {
setZoom: (zoom) => set(() => ({ zoom })),
},
}),
{
name: "mg",
getStorage: () => getHashStorage(),
partialize: (state) => ({ ...omit(state, "actions") }),
}
)
);

Kısaca burada kullanacağımız global state’leri belirleyip bunların kayıt edilmesi için gerekli olan action metodunu oluşturduk. Şimdi app/hooks altına “useMapEvents.ts” adı ile custom hook oluşturacağız.

import { useMapEvents as useLeafletMapEvents } from "react-leaflet";
import { useDebouncedCallback } from "use-debounce";
import { useMapGeoStore } from "../stores/mapGeographyStore";

export const useMapEvents = () => {
const { actions } = useMapGeoStore();

const debouncedZoom = useDebouncedCallback(() => {
const zoom = map.getZoom();
actions.setZoom(zoom);
}, 100);

const map = useLeafletMapEvents({
moveend: () => {
debouncedZoom();
},
zoomend: () => {
debouncedZoom();
}
});

return map;
};

Oluşturmuş olduğumuz “useMapEvents” hook’u ile birlikte harita üzerinde yapılan aksiyonları global state’e kayıt ederek “MapContent” component’inde kullanacağız.

"use client";

import { LatLngBounds, LatLngTuple, latLng, latLngBounds } from "leaflet";
import React from "react";
import { TileLayer } from "react-leaflet";
import DynamicMap from "./DynamicMap";
import Cluster from "../Cluster/Cluster";
import { useMapEvents } from "@/app/hooks/useMapEvents";
import { useMapGeoStore } from "@/app/store/mapGeoStore";

import "leaflet.markercluster/dist/MarkerCluster.css";
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
import "leaflet/dist/leaflet.css";

const MapEvents = () => {
useMapEvents();
return null;
};

const MapContent = () => {
const centerLocation: LatLngTuple = [40.9610678, 29.1104779];
const { zoom } = useMapGeoStore();
const mapBoundaries = {
southWest: latLng(34.025514, 25.584519),
northEast: latLng(42.211024, 44.823563),
};
const bounds = latLngBounds(mapBoundaries.southWest, mapBoundaries.northEast);

return (
<DynamicMap
zoomControl={true}
center={centerLocation}
zoom={zoom}
minZoom={7}
maxBounds={bounds}
maxZoom={18}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapEvents />
<Cluster data={<your-data>} />
</DynamicMap>
);
};

export default MapContent;

Son durumda haritamız üzerindeki yapılan hareketlerin cluster’larımız ile düzgün bir şekilde çalışmasını sağlamak adına “zoom” değerini “useMapGeoStore”dan alarak kullandık. Yaptığımız son işlemler ile birlikte react-leaflet’in cluster ve marker yapılarını Next.js üzerinde nasıl kullanabileceğimize dair örneklerimi tamamlamış oldum.

Sonuç

Burada uygulamış olduğum yapı ve kullanmış olduğum kütüphaneler, uygulayacağınız çözümlerde farklılık gösterebilir. İncelemiş olduğum projeler ile kendi deneyimlerim sonucunda sürdürülebilir ve kolay entegre edilebilir olduğunu düşündüğüm çözümleri paylaşmak istedim. Yazımda eksik olduğunu veya düzeltilmesi gerektiği düşündüğünüz kısımları paylaşabilirsiniz. Hem kendimi geliştirmek hem de topluluğa daha faydalı olabilmek adına her türlü geri bildirime açık olduğumu bilmenizi isterim.

--

--