How to Use Leaflet to Build a Web Map: Step-by-Step (2026)

Building an interactive web map with Leaflet takes one HTML file, a stylesheet link, a script tag and about a dozen lines of JavaScript. You point Leaflet at a div, give it a centre point and a zoom level, drop in a tile layer, and the map is live. This guide walks through that whole build in seven steps, in plain JavaScript, with a test at the end of each one so you know it worked before you move on.

If you are wondering how to use Leaflet to build a web map for a story, a newsroom tool or a client site, the same seven steps apply. The first four are the whole of Leaflet’s core: container, map, tiles, markers. Everything after that is data, controls and polish.

A word on expectations. This is not a build tool. You will need a text editor and a browser, and the whole thing can be assembled in an afternoon once you have the data.

What You Need

To follow this Leaflet tutorial you need four things, and three of them are already on your machine.

  • A text editor. VS Code, Sublime Text or anything that saves plain files. No IDE, no setup.
  • A modern browser. Chrome, Firefox, Safari or Edge, current version. Leaflet 1.9 runs in all of them.
  • An internet connection. Only for pulling the Leaflet files and map tiles from a CDN, unless you self-host both.
  • Map data. Latitude and longitude pairs for simple markers, or a GeoJSON file for boundaries, counts and shapes.

You also need a working grasp of HTML and a little JavaScript. If you can open a <script> tag and read an object literal, you have enough. There is no build step, no package install and no TypeScript required for anything in this guide.

A sensible folder structure for anything beyond a toy demo looks like this:

my-map/
  index.html
  css/
    styles.css
  js/
    map.js
  data/
    incidents.geojson
  img/
    marker-pin.png

On the question of API keys: Leaflet itself needs none, and neither do the standard OpenStreetMap tiles for ordinary traffic levels. Other basemap services do require a key. MapTiler, Stadia, Mapbox, Esri and Google all hand you a key after you register, and all of them have their own pricing model. The free OpenStreetMap public tile server is fine for a prototype or a small newsroom project, but its usage policy discourages heavy commercial traffic, so read it before you launch anything big.

Coordinates, by the way, are always latitude first, then longitude, in decimal degrees. That order catches nearly everyone out on their first attempt.

Step-by-Step: How to Use Leaflet to Build a Web Map

The core of the process comes down to five moves, and then we break all seven steps down properly.

  1. Create an HTML page with a div that has an explicit height.
  2. Load leaflet.css and leaflet.js, then initialise the map with L.map() and setView().
  3. Add a tile layer to give the map a visible basemap.
  4. Add markers and bind popups to them.
  5. Load your own data with L.geoJSON() and style the features.

1. Create the HTML and CSS Map Container

Start with a div that has a real height, because a container with no height is the single most common reason a Leaflet map renders as an empty grey box.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Incident map</title>
  <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css">
  <style>
    html, body { margin: 0; padding: 0; }
    #map { height: 500px; width: 100%; }
  </style>
</head>
<body>
  <div id="map"></div>
  <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
  <script src="js/map.js"></script>
</body>
</html>

Two things in there matter more than the rest. The height: 500px on #map is what makes the container visible, and the viewport meta tag is what stops mobile browsers from zooming in on it and clipping the controls.

You can also link a local stylesheet instead of styling inline once the project grows. Leaflet’s own leaflet.css is not optional either. It carries the positioning rules for the map panes, the popup bubble and the zoom control, and without it your tiles and controls will pile on top of each other.

Test it: open index.html in your browser. You should see a plain grey rectangle, 500 pixels tall, with nothing in it. That rectangle is your map container, and it proves the HTML and CSS are sound before any JavaScript runs.

2. Load Leaflet and a Basemap Tile Layer

Initialise the map against that div, give it a centre and a zoom level, then add a tile layer so there is something to look at.

// js/map.js
const map = L.map('map').setView([51.5072, -0.1276], 13);

L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);

Three details hide in those few lines. L.map('map') takes the id of your div, with no hash symbol. setView() takes a latitude and longitude pair followed by a zoom level, where 13 is roughly a city view and 3 is most of the world. And the tile URL uses three placeholders that Leaflet substitutes for every square of the map as you pan and zoom, requesting only the tiles actually in view.

The attribution string is not decoration. It is a licence condition, and it is the single most commonly forgotten line in a Leaflet project.

Test it: reload the page. You should see street tiles, and the zoom buttons in the top left should be live. If you see the grey rectangle instead, the stylesheet or the script tag is the culprit, not your code.

3. Add Markers and Popups

Markers are the quickest proof that your map is wired up correctly, so add one before you touch any data files.

const london = L.marker([51.5072, -0.1276])
  .addTo(map)
  .bindPopup('<strong>Head office</strong><br>Last updated 12 March');

const locations = [
  { name: 'Clapham junction', coords: [51.4680, -0.0410] },
  { name: 'Waterloo station', coords: [51.5033, -0.1146] }
];

locations.forEach(function (place) {
  L.marker(place.coords)
    .addTo(map)
    .bindPopup('<strong>' + place.name + '</strong>');
});

Once you have more than a handful of points, move them into an array like the one above and loop. It is the same three lines per point, but written once. The loop pattern is the one you will reuse in every data-driven map you build afterwards, whether the source is GeoJSON, a JSON endpoint or a CMS fetch.

Popup content is HTML, not plain text, which is convenient but worth handling carefully if any of it comes from user input or an untrusted data file.

Test it: click a marker. A small bubble should open anchored to the pin, with a close button in the corner.

4. Add GeoJSON or Other Location Data

GeoJSON is the format that matters for newsroom work. It is plain text, it is readable, and one file can hold points, lines and polygons with attributes attached to each one.

fetch('data/incidents.geojson')
  .then(function (response) { return response.json(); })
  .then(function (geojson) {
    const layer = L.geoJSON(geojson, {
      style: function (feature) {
        const count = feature.properties.reports;
        if (count > 30) return { color: '#a50f15', weight: 3 };
        if (count > 10) return { color: '#fb6a4a', weight: 2 };
        return { color: '#3182bd', weight: 1 };
      },
      onEachFeature: function (feature, lyr) {
        lyr.bindPopup(
          '<strong>' + feature.properties.area + '</strong><br>' +
          feature.properties.reports + ' reports'
        );
      }
    }).addTo(map);

    if (geojson.features.length) {
      map.fitBounds(layer.getBounds());
    }
  });

Two things in that snippet save a lot of pain later. The style callback receives every feature and returns its own styling, which is how a choropleth gets its colour from a data value. The onEachFeature callback runs per feature, so popups, tooltips and click handlers attach to each shape rather than to the layer as a whole.

Serving that file over fetch() means it has to come from a web server, not from a double-clicked file:// path. Browsers block that kind of request. Any static server will do, and it also solves the marker icon problem described in the mistakes section below.

Test it: the layer should fill the visible area. If nothing appears, log the feature count. Zero means the fetch failed or the file is empty, and a handful of shapes that sit off in a corner almost always means a coordinate order problem or a projection mismatch.

5. Configure Controls, Layers, and Map Events

A map with one basemap and no controls is a picture. Layers, controls and events are what turn it into an interactive graphic.

const streets = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);

const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
  attribution: 'Tiles &copy; Esri'
});

const incidents = L.geoJSON(geojsonData, { onEachFeature: bindPopup });

L.control.layers({ 'Streets': streets, 'Satellite': satellite }, {
  'Reported incidents': incidents
}).addTo(map);

L.control.scale({ imperial: false }).addTo(map);

map.on('click', function (event) {
  document.getElementById('readout').textContent =
    event.latlng.lat.toFixed(4) + ', ' + event.latlng.lng.toFixed(4);
});

map.on('zoomend', function () {
  console.log('now showing zoom ' + map.getZoom());
});

The map.getCenter() and map.getZoom() calls are the read side of the same API, and they are how you capture a reader’s current view so a story panel, a search box or a “share this view” link can stay in sync with the map.

Plugins extend the core through the same addTo(map) pattern. A fullscreen button, a geolocation button, a heatmap layer and a marker cluster group all drop in that way, and the official plugin directory is the place to look before you write anything from scratch.

Test it: switch between the two basemaps, confirm the scale bar appears, and click the map to watch the coordinate readout change.

6. Add Geolocation and Responsive Behavior

Ask the reader where they are, but handle all three answers: granted, denied and unavailable.

function locateReader() {
  if (!navigator.geolocation) {
    map.setView([51.5072, -0.1276], 11);
    return;
  }

  navigator.geolocation.getCurrentPosition(
    function (position) {
      const here = [position.coords.latitude, position.coords.longitude];
      map.setView(here, 13);
      L.circleMarker(here, { radius: 8, color: '#2563eb', fillOpacity: 0.8 })
        .addTo(map)
        .bindPopup('You are here');
    },
    function () {
      map.setView([51.5072, -0.1276], 11);
    },
    { enableHighAccuracy: false, timeout: 8000 }
  );
}

Geolocation only works on https:// origins and on localhost. It fails silently over plain HTTP, and it fails silently in most in-app browsers, so always keep the fallback branch.

For layout, the map container needs a height on every screen size, and it needs map.invalidateSize() after any container change, such as a side panel opening or the window resizing. A map that renders correctly on desktop and collapses on a phone is almost always a percentage height problem rather than a Leaflet one.

Test it: run the page through your browser’s device toolbar at 375 pixels wide, rotate it once, and grant or deny the location prompt. The map should stay usable either way.

7. Optimize, Deploy, and Troubleshoot the Map

Most newsroom maps are fine until a data file arrives with four thousand points in it, at which point the page stutters. Three levers fix almost every case.

  1. Turn on the canvas renderer. L.map('map', { preferCanvas: true }) moves vector layers from SVG elements to a single canvas element, which changes large polygon counts from painful to fine.
  2. Cluster your markers. The Leaflet.markercluster plugin groups points that overlap at the current zoom, so a reader sees ten pins instead of four thousand.
  3. Simplify the geometry. Precision of six decimal places is about 10 centimetres. Round your coordinates before you export the GeoJSON, and the file gets smaller for free.

Tile requests deserve a mention too. Every pan and zoom pulls new square images from the tile server, so a page with several maps or a fast scrubbing interaction can add up. If that becomes a problem, self-host your tiles or switch to a provider with predictable pricing.

For deployment, the output is static files, which makes this the easy part. Upload the folder to any static host and open the .geojson file over HTTP rather than through the GitHub or CodePen preview, both of which add a cross-origin layer you did not ask for. If the map lives inside a CMS story, embed the iframe and keep the map page as its own file, so the same map can be shared, archived and re-published later.

Before you call it done, run this checklist against the published page, not your local copy:

  • The container has an explicit height on mobile and desktop.
  • leaflet.css is loading; popups and controls are positioned correctly.
  • Attribution is visible and linked.
  • Every GeoJSON fetch returns 200 in the network tab.
  • No console errors, and no 404s for marker images.
  • Keyboard focus reaches the map, and popups carry readable text.

Common Mistakes

Every one of these has been asked on r/webdev, r/gis or GIS Stack Exchange more than once, and every one has a short fix.

The map is a blank grey box. Almost always one of two things. The container div has no height, so Leaflet is rendering into something with zero pixels of room; or leaflet.css never loaded, so the map panes stack on top of each other. Check the height first, because it is the more common of the pair.

Markers show as broken images. This appears the moment you move from a CDN script tag to a bundler such as Vite or webpack, because the default icon URLs get rewritten to paths that do not exist. The repair is to point Leaflet at the right assets explicitly:

L.Icon.Default.mergeOptions({
  iconRetinaUrl: 'node_modules/leaflet/dist/images/marker-icon-2x.png',
  iconUrl: 'node_modules/leaflet/dist/images/marker-icon.png',
  shadowUrl: 'node_modules/leaflet/dist/images/marker-shadow.png'
});

Tiles do not load and the console shows a 403. You are being rate limited or blocked by the tile provider. The free OpenStreetMap tile server is not intended for production traffic, so either slow down, reduce the number of simultaneous maps, or move to a hosted provider with your own key.

The GeoJSON layer is empty. Open the network tab first. A failed request means you are loading the file from file:// or hitting a CORS restriction, and the fix is to serve it over HTTP. If the request succeeds and the shapes are off screen, check that you have not swapped latitude and longitude.

Nothing happens on click. A transparent overlay is sitting above the map, usually a full-width <div> with a high z-index. Drop the z-index or add pointer-events: none to the overlay.

Geolocation does nothing. You are on an insecure origin, you are testing in an in-app browser, or the reader declined and your fallback branch is missing. Handle the error callback properly instead of leaving it empty.

The map is unusable on a phone. Usually a percentage height on the container plus a missing viewport meta tag. Add height: 60vh or a fixed pixel value, and call map.invalidateSize() after any layout change.

The map has no attribution line. That is both a bug and a licence breach. Every tile layer needs a visible, linked attribution string in its options.

Frequently Asked Questions

Is Leaflet free to use for a newsroom project?

Yes. Leaflet is an open-source JavaScript library released under the BSD-2-Clause licence, which means no fee, no API key and no per-load charge for commercial or editorial use. The catch is the map tiles you point it at. The free OpenStreetMap tiles are fine for prototypes and light traffic, but their usage policy asks heavy or commercial projects to use a dedicated provider, so budget for hosted tiles before you publish something big.

Is Leaflet an API or a library?

It is a library, not a web service API. Leaflet is a set of JavaScript files you download or link from a CDN, and it runs entirely inside the reader’s browser. It has no backend, no key and nothing to configure on someone else’s server. The only external part is the tile imagery, which does come from a server and may require a key depending on the provider you choose.

Does Leaflet work with Google Maps tiles?

Yes, technically. Leaflet is provider-neutral: you can point L.tileLayer at Google, Mapbox, Esri, Stadia or a custom tile server, and the rest of the API behaves the same. The practical warning is licensing. Google’s terms restrict how their imagery is displayed and cached, and Mapbox has its own account limits. For most published work, OpenStreetMap or a dedicated tile provider is the cleaner path.

Is Leaflet better than the Google Maps JavaScript API?

For a small interactive map inside a story, usually yes. Leaflet is about 42 KB gzipped, needs no key, costs nothing to load and gives you full control of the markup and styling. Google wins on street-level data quality, geocoding, Street View and satellite imagery you get for free. Pick Google when you need its data services, and Leaflet when you need control, open data and a light page.

What does map.getCenter() actually do?

It returns the current centre of the map as a Leaflet LatLng object holding a lat and an lng property. Call it on the map.on(‘moveend’) event to read where the reader has panned to, then sync a story panel, update the URL or run a new data query for the visible area. Its companions are map.getZoom() and map.getBounds(), which returns the visible rectangle in the same coordinate system.

How do I load a local .geojson file instead of hard-coding coordinates?

Use fetch against a URL served over HTTP, not from a file opened directly on your machine. Call fetch(‘data/your-file.geojson’), convert the response with .json(), then pass the result to L.geoJSON(data) and addTo(map). Browsers block file:// requests for security reasons, so run a small local server such as python -m http.server while you work, and upload the file alongside your page when you deploy.

Conclusion

Start with four things today: a div with an explicit height, the Leaflet stylesheet and script tags, one L.map('map').setView() call, and a single marker with a popup. If those render, the hard part is over, because everything after that is the same pattern repeated.

Then point the map at your actual data. Load a GeoJSON file, colour it by a value, wire a click handler to a story panel, and check the result on a phone before you ship it. From there, the pieces that make a map feel like a story rather than a widget are a layer control, a legend and a fallback for readers whose browser blocks geolocation.

Leave a Comment