# Leaflet Map — Implementation Guide
## Dependencies
Add to the project:
```bash
npm install leaflet react-leaflet
npm install -D @types/leaflet
```
Import the Leaflet CSS in the component file (not in global CSS — keeps it co-located):
```tsx
import "leaflet/dist/leaflet.css";
```
---
## TypeScript declaration (if @types/leaflet is unavailable)
If the project cannot install `@types/leaflet`, create a declaration file:
```ts
// types/leaflet.d.ts
declare module "leaflet" {
const L: unknown;
export default L;
}
declare module "leaflet/dist/leaflet.css";
```
---
## Reusable map component
Create a generic map component at `components/MapView.tsx` (or similar):
```tsx
import { useMemo, useState, useEffect } from "react";
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
const Leaflet = L as {
divIcon: (opts: {
className?: string;
html?: string;
iconSize?: [number, number];
iconAnchor?: [number, number];
popupAnchor?: [number, number];
}) => unknown;
};
const pinIcon = Leaflet.divIcon({
className: "map-pin",
html: ``,
iconSize: [28, 40],
iconAnchor: [14, 40],
popupAnchor: [0, -40],
});
export interface MapMarker {
lat: number;
lng: number;
label?: string;
}
interface MapViewProps {
center: [number, number];
zoom?: number;
markers?: MapMarker[];
className?: string;
}
function MapCenterUpdater({ center, zoom = 13 }: { center: [number, number]; zoom?: number }) {
const map = useMap() as { setView: (center: [number, number], zoom: number) => void };
useEffect(() => {
map.setView(center, zoom);
}, [map, center[0], center[1], zoom]);
return null;
}
export default function MapView({
center,
zoom = 13,
markers = [],
className = "h-[400px] w-full rounded-xl overflow-hidden",
}: MapViewProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const effectiveCenter = useMemo((): [number, number] => {
if (markers.length > 0) {
const sum = markers.reduce((acc, m) => [acc[0] + m.lat, acc[1] + m.lng], [0, 0]);
return [sum[0] / markers.length, sum[1] / markers.length];
}
return center;
}, [center, markers]);
if (!mounted || typeof window === "undefined") {
return (
Loading map…
);
}
return (
{markers.map((m, i) => (
{m.label ?? "Location"}
))}
);
}
```
---
## Tile provider: OpenStreetMap
The default tile URL is free and requires no API key:
```
https://tile.openstreetmap.org/{z}/{x}/{y}.png
```
| Property | Value |
|----------|-------|
| Cost | Free |
| API key | None |
| Max zoom | 19 |
| Attribution | Required — `© OpenStreetMap` |
CSP `img-src` must include `https://tile.openstreetmap.org` if CSP is enforced.
---
## Custom pin icon via CSS
The `divIcon` approach avoids external marker image dependencies. Add these styles to your global CSS or a co-located CSS file:
```css
.map-pin {
background: transparent !important;
border: none !important;
}
.map-pin-shape {
display: block;
width: 28px;
height: 40px;
background: hsl(var(--primary));
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.map-pin-shape::after {
content: "";
display: block;
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
```
---
## SSR safety
Leaflet requires `window` and `document`. The component guards against SSR with:
1. A `mounted` state that only becomes `true` in `useEffect` (client-side).
2. A `typeof window === "undefined"` check.
3. A loading placeholder rendered during SSR / before mount.
This prevents "window is not defined" errors in SSR or static builds.
---
## MapCenterUpdater pattern
`react-leaflet`'s `MapContainer` does not re-center when the `center` prop changes after initial render. The `MapCenterUpdater` child component uses `useMap()` to call `map.setView()` reactively:
```tsx
function MapCenterUpdater({ center, zoom = 13 }: { center: [number, number]; zoom?: number }) {
const map = useMap();
useEffect(() => {
map.setView(center, zoom);
}, [map, center[0], center[1], zoom]);
return null;
}
```
Place it as a direct child of ``.
---
## Multi-marker center calculation
When rendering multiple markers, auto-center the map on their centroid:
```tsx
const effectiveCenter = useMemo((): [number, number] => {
if (markers.length > 0) {
const sum = markers.reduce((acc, m) => [acc[0] + m.lat, acc[1] + m.lng], [0, 0]);
return [sum[0] / markers.length, sum[1] / markers.length];
}
return fallbackCenter;
}, [markers, fallbackCenter]);
```
---
## Split-panel layout (map + list)
For a search page with map on the left and scrollable results on the right:
```tsx
{/* Search bar */}
{/* search input */}
{/* Map + List */}
```
Key points:
- `h-[calc(100vh-4rem)]` fills viewport minus header.
- `lg:flex-row` stacks vertically on mobile, side-by-side on desktop.
- List panel uses `overflow-y-auto` for independent scrolling.
---
## Accessibility
- Wrap the map `` with `aria-label="Map"` or a descriptive label.
- Pin icon inner HTML uses `aria-hidden="true"`.
- Popups contain text labels for each marker.
- Provide a text-based alternative (address list) for screen readers.