Build a Store Locator with GeoJSON
Step-by-step tutorial: geocode addresses create GeoJSON features and build a proximity search map with Leaflet.
What We're Building
This tutorial builds a fully functional store locator: a web page with an interactive map that displays store locations and lets users click anywhere to find all stores within a specified radius. The data layer uses GeoJSON as the interchange format throughout — from the raw CSV import to the Leaflet rendering layer.
The finished result is a single HTML file with no build step. It uses Leaflet (200 kB gzipped) for mapping, Turf.js (97 kB for the modules we need) for spatial math, and vanilla JavaScript for everything else. No React, no webpack, no server required.
Step 1: Prepare Your Data
Start with a CSV file containing at minimum these four columns: name, address, lat, lng. If your data only has addresses (no coordinates), use a geocoding service like the Census Bureau Geocoder (free) or Geocodio ($0.001/lookup) to add lat/lng columns first.
javascriptname,address,lat,lng
Starbucks Times Square,1585 Broadway New York NY 10036,40.7590,-73.9845
Starbucks Grand Central,45 Grand Central Terminal New York NY 10017,40.7527,-73.9772
Starbucks Union Square,815 Broadway New York NY 10003,40.7353,-73.9910
Starbucks Columbus Circle,1841 Broadway New York NY 10023,40.7681,-73.9816
Starbucks Rockefeller Center,45 Rockefeller Plaza New York NY 10111,40.7587,-73.9787Convert this CSV to GeoJSON using the Lat/Long to GeoJSON converter. Paste the CSV rows, map the columns, and download the resulting FeatureCollection. The converter handles the coordinate column mapping and wraps each row in the correct Feature structure.
Step 2: Create the GeoJSON
The converter produces a FeatureCollection with 5 Point features. Each row becomes one Feature with a geometry (the coordinates) and a properties object (the name and address). Here is the resulting GeoJSON:
json{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-73.9845, 40.7590] },
"properties": { "name": "Starbucks Times Square", "address": "1585 Broadway New York NY 10036" }
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-73.9772, 40.7527] },
"properties": { "name": "Starbucks Grand Central", "address": "45 Grand Central Terminal New York NY 10017" }
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-73.9910, 40.7353] },
"properties": { "name": "Starbucks Union Square", "address": "815 Broadway New York NY 10003" }
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-73.9816, 40.7681] },
"properties": { "name": "Starbucks Columbus Circle", "address": "1841 Broadway New York NY 10023" }
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-73.9787, 40.7587] },
"properties": { "name": "Starbucks Rockefeller Center", "address": "45 Rockefeller Plaza New York NY 10111" }
}
]
}Paste this into the GeoJSON Validator before writing any code. The validator checks that every coordinate is in [longitude, latitude] order, that all Feature objects have a type field, and that the FeatureCollection structure is complete. Catching structural errors now saves debugging time later.
Step 3: View on a Map
Paste the GeoJSON into the GeoJSON Viewer. You should see 5 markers clustered in Midtown and Lower Manhattan. If the markers appear in the ocean near the equator, your coordinates are in [lat, lng] order instead of [lng, lat] — swap them. If markers appear in China, the longitude signs are wrong — add a minus sign to make them negative (Western hemisphere = negative longitude).
The viewer also shows the bounding box and feature count in the sidebar, which confirms all 5 features loaded correctly. This visual check takes 10 seconds and prevents a whole class of map rendering bugs.
Step 4: Add the Leaflet Map
Create an HTML file. Load Leaflet from CDN (84 kB JS + 5 kB CSS), define a map container div, and render the GeoJSON as circle markers with popups.
html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Store Locator</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
html, body { margin: 0; height: 100%; }
#map { height: 100vh; }
#info { position: absolute; top: 10px; right: 10px; z-index: 1000;
background: white; padding: 12px; border-radius: 4px;
box-shadow: 0 2px 6px rgba(0,0,0,0.3); max-width: 240px; }
</style>
</head>
<body>
<div id="map"></div>
<div id="info">Click the map to find nearby stores.</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/@turf/turf@6/turf.min.js"></script>
<script>
const STORES = {/* paste FeatureCollection here */};
const RADIUS_KM = 1.5;
const map = L.map("map").setView([40.754, -73.984], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap contributors",
}).addTo(map);
// Render all store markers
const storeLayer = L.geoJSON(STORES, {
pointToLayer: (feature, latlng) =>
L.circleMarker(latlng, {
radius: 8, fillColor: "#00704A", fillOpacity: 0.9,
color: "#fff", weight: 1,
}),
onEachFeature: (feature, layer) => {
layer.bindPopup(
`<strong>${feature.properties.name}</strong><br>${feature.properties.address}`
);
},
}).addTo(map);
map.fitBounds(storeLayer.getBounds().pad(0.2));
</script>
</body>
</html>Step 5: Add Proximity Search
When the user clicks the map, use turf.distance() to measure the great-circle distance from the clicked point to each store. Filter to stores within RADIUS_KM and display the results in the info panel. Turf's distance() uses the Haversine formula and returns kilometers by default.
javascript// Add inside the <script> block, after map setup
let nearbyLayer = null;
map.on("click", function (e) {
const clicked = turf.point([e.latlng.lng, e.latlng.lat]);
// Filter stores within RADIUS_KM
const nearby = STORES.features.filter(store => {
const d = turf.distance(clicked, store, { units: "kilometers" });
return d <= RADIUS_KM;
});
// Update info panel
const infoEl = document.getElementById("info");
if (nearby.length === 0) {
infoEl.innerHTML = `No stores within ${RADIUS_KM} km.`;
} else {
const names = nearby
.map(f => `<li>${f.properties.name}</li>`)
.join("");
infoEl.innerHTML = `
<strong>${nearby.length} store${nearby.length > 1 ? "s" : ""} within ${RADIUS_KM} km:</strong>
<ul style="margin:6px 0 0; padding-left:16px">${names}</ul>
`;
}
// Highlight nearby stores in red
if (nearbyLayer) map.removeLayer(nearbyLayer);
nearbyLayer = L.geoJSON(
{ type: "FeatureCollection", features: nearby },
{
pointToLayer: (feature, latlng) =>
L.circleMarker(latlng, {
radius: 10, fillColor: "#e63946", fillOpacity: 0.9,
color: "#fff", weight: 2,
}),
}
).addTo(map);
});Step 6: Add a Search Radius Visual
Use turf.circle() to draw the search radius as a GeoJSON Polygon, then render it as a Leaflet layer. turf.circle() accepts a center point, radius, and options — it returns a 64-sided polygon approximating the circle. This is the same operation the GeoJSON Buffer tool performs visually.
javascript// Inside the map.on("click") handler, after filtering
// Draw the search radius circle
let radiusLayer = null;
// Remove previous circle
if (radiusLayer) map.removeLayer(radiusLayer);
// turf.circle returns a Feature<Polygon>
const circle = turf.circle(
[e.latlng.lng, e.latlng.lat],
RADIUS_KM,
{ units: "kilometers", steps: 64 }
);
radiusLayer = L.geoJSON(circle, {
style: {
color: "#457b9d",
weight: 2,
fillColor: "#457b9d",
fillOpacity: 0.08,
dashArray: "6 4",
},
}).addTo(map);
// Add a marker at the clicked point
L.circleMarker(e.latlng, {
radius: 5, fillColor: "#457b9d", fillOpacity: 1,
color: "#fff", weight: 1,
}).addTo(map);Step 7: Calculate the Bounding Box
Use turf.bbox() to compute the axis-aligned bounding box of the FeatureCollection, then pass it to map.fitBounds() to set the initial view. A bounding box is an array of [minLng, minLat, maxLng, maxLat]. For these 5 NYC locations, it is approximately [-73.991, 40.735, -73.977, 40.768]. The online Bounding Box calculator shows this visually for any GeoJSON.
javascript// Replace map.fitBounds(storeLayer.getBounds().pad(0.2)) with this:
const bbox = turf.bbox(STORES);
// bbox = [minLng, minLat, maxLng, maxLat]
// For our 5 stores: [-73.991, 40.735, -73.979, 40.768]
map.fitBounds(
[[bbox[1], bbox[0]], [bbox[3], bbox[2]]], // Leaflet uses [[lat,lng],[lat,lng]]
{ padding: [40, 40] }
);Note the coordinate flip: Turf uses [lng, lat] throughout, but Leaflet's fitBounds() expects [[lat, lng], [lat, lng]]. This is one of the most common bugs when mixing the two libraries.
Complete Code
Here is the full working HTML file combining all steps. Paste your FeatureCollection into the STORES variable and open the file in a browser — no server needed.
html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Store Locator</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
html, body { margin: 0; height: 100%; font-family: sans-serif; }
#map { height: 100vh; }
#info {
position: absolute; top: 10px; right: 10px; z-index: 1000;
background: white; padding: 12px; border-radius: 4px;
box-shadow: 0 2px 6px rgba(0,0,0,0.3); max-width: 240px;
font-size: 14px;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="info">Click the map to find stores within 1.5 km.</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/@turf/turf@6/turf.min.js"></script>
<script>
const RADIUS_KM = 1.5;
const STORES = {
"type": "FeatureCollection",
"features": [
{ "type": "Feature", "geometry": { "type": "Point", "coordinates": [-73.9845, 40.7590] }, "properties": { "name": "Starbucks Times Square", "address": "1585 Broadway" } },
{ "type": "Feature", "geometry": { "type": "Point", "coordinates": [-73.9772, 40.7527] }, "properties": { "name": "Starbucks Grand Central", "address": "45 Grand Central Terminal" } },
{ "type": "Feature", "geometry": { "type": "Point", "coordinates": [-73.9910, 40.7353] }, "properties": { "name": "Starbucks Union Square", "address": "815 Broadway" } },
{ "type": "Feature", "geometry": { "type": "Point", "coordinates": [-73.9816, 40.7681] }, "properties": { "name": "Starbucks Columbus Circle", "address": "1841 Broadway" } },
{ "type": "Feature", "geometry": { "type": "Point", "coordinates": [-73.9787, 40.7587] }, "properties": { "name": "Starbucks Rockefeller Center", "address": "45 Rockefeller Plaza" } }
]
};
const map = L.map("map");
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap contributors",
}).addTo(map);
// Render stores
const storeLayer = L.geoJSON(STORES, {
pointToLayer: (f, ll) => L.circleMarker(ll, {
radius: 8, fillColor: "#00704A", fillOpacity: 0.9, color: "#fff", weight: 1,
}),
onEachFeature: (f, layer) =>
layer.bindPopup(`<strong>${f.properties.name}</strong><br>${f.properties.address}`),
}).addTo(map);
// Fit to bounding box
const bbox = turf.bbox(STORES);
map.fitBounds([[bbox[1], bbox[0]], [bbox[3], bbox[2]]], { padding: [40, 40] });
let nearbyLayer = null, radiusLayer = null;
map.on("click", function (e) {
const clicked = turf.point([e.latlng.lng, e.latlng.lat]);
// Filter nearby stores
const nearby = STORES.features.filter(s =>
turf.distance(clicked, s, { units: "kilometers" }) <= RADIUS_KM
);
// Update info panel
document.getElementById("info").innerHTML = nearby.length === 0
? `No stores within ${RADIUS_KM} km.`
: `<strong>${nearby.length} store${nearby.length > 1 ? "s" : ""} within ${RADIUS_KM} km:</strong>
<ul style="margin:6px 0 0;padding-left:16px">
${nearby.map(f => `<li>${f.properties.name}</li>`).join("")}
</ul>`;
// Draw radius circle
if (radiusLayer) map.removeLayer(radiusLayer);
radiusLayer = L.geoJSON(turf.circle([e.latlng.lng, e.latlng.lat], RADIUS_KM, { steps: 64 }), {
style: { color: "#457b9d", weight: 2, fillColor: "#457b9d", fillOpacity: 0.08, dashArray: "6 4" },
}).addTo(map);
// Highlight nearby stores
if (nearbyLayer) map.removeLayer(nearbyLayer);
nearbyLayer = L.geoJSON({ type: "FeatureCollection", features: nearby }, {
pointToLayer: (f, ll) => L.circleMarker(ll, {
radius: 10, fillColor: "#e63946", fillOpacity: 0.9, color: "#fff", weight: 2,
}),
}).addTo(map);
});
</script>
</body>
</html>Next Steps
Three common enhancements to add after the basic locator is working:
Address Search with Geocoding
Replace click-to-search with an address input. Use the Nominatim API (free, OpenStreetMap data) to convert a typed address to coordinates: send a GET request to https://nominatim.openstreetmap.org/search?q=ENCODED_ADDRESS&format=json and read response[0].lat and response[0].lon. For production use, Nominatim's terms require a user-agent header and rate-limit to 1 request/second; consider Geocodio ($0.001/lookup) or the Census Geocoder (free, US only) for higher volume.
Marker Clustering
For more than ~50 markers, clustering prevents overlapping icons and improves initial load performance. Add <script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script> and wrap L.geoJSON() in a L.markerClusterGroup(). Clusters automatically split on zoom and show a count badge. The library handles 10,000+ markers smoothly in most browsers.
Driving Directions
Add a "Directions" link in each popup pointing to https://www.google.com/maps/dir/?api=1&destination={lat},{lng}. For in-map routing, the Leaflet Routing Machine plugin (leaflet-routing-machine) integrates with the OSRM API (free, open-source) or Mapbox Directions API ($0.50 per 1,000 requests) to draw turn-by-turn routes directly on the map.
To extend the data side, explore the Lat/Long to GeoJSON converter for bulk imports, the Bounding Box tool to pre-compute map extents, and the GeoJSON Viewer to inspect your data at any stage of development.