Short answer: you can build a custom interactive map for a website in roughly 20 lines of code using Leaflet.js and OpenStreetMap tiles. No Google Maps API key, no billing account, no credit card on file, and no third party tracking cookies loaded on your pages. There is more on it in Free Map Maker & Generator.
Most articles on this topic push you toward a hosted map builder or toward the Google Maps Platform, where you have to enable billing before a single tile renders. That is fine for some projects, but it is overkill when all you need is a map of your offices, your store locations, your delivery zones or your event venues. This tutorial shows the developer route: a lightweight open source library, open data tiles, and full control over the markup.
What You Will Build
- A full width, responsive map block that works on mobile and desktop
- Custom branded markers (your own PNG or SVG pin)
- Clickable popups with a title, address and link
- Multiple locations loaded from a JSON array or from an Express API route
- Auto zoom that fits every marker in the viewport
- A map that does not hijack page scrolling on mobile
Total page weight: about 42 KB of JavaScript plus CSS, gzipped, versus several hundred KB for the typical commercial map SDK.

Why Leaflet and OpenStreetMap Instead of Google Maps
Leaflet is an open source mapping library (BSD licensed) that renders map tiles and vector overlays in the browser. OpenStreetMap is a community built, openly licensed map database. Together they give you a production grade map with zero account setup.
| Solution | API key / billing | Customisation | Typical weight | Privacy |
|---|---|---|---|---|
| Leaflet + OpenStreetMap | None | Full (your HTML and CSS) | ~42 KB JS | No ad cookies |
| Google Maps JS API | Key + billing account required | Good, via cloud styling | 200 KB+ | Consent banner usually needed |
| Mapbox GL JS | Key required, usage based pricing | Excellent (vector styles) | 200 KB+ | Telemetry by default |
| No code embeds (map builders) | Account, often paid plan | Limited to their editor | Heavy iframe | Third party scripts |
If you have a marketing team that needs to drag pins around without touching code, a hosted builder is a reasonable choice. If you own the codebase, Leaflet wins on speed, cost and control. The team at flourish.studio reached a similar conclusion.
Before You Start
- A page you can edit (static HTML, a template, a CMS block or a React/Vue component)
- The latitude and longitude of each location. Right click a point on openstreetmap.org and choose Show address to read the coordinates, or geocode your addresses once with Nominatim and store the results.
- Optional: a marker image, ideally a 32×42 px PNG or an SVG
Important: store your coordinates in your own database or JSON file. Geocoding on every page load is slow, and public geocoding services rate limit that behaviour.
Step 1: Add the Leaflet CSS and JS
Use the current stable 1.9.x line from a CDN, or install it locally with npm install leaflet if you bundle your assets. Self hosting the two files is the privacy friendly option and removes a third party request.
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css">
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
The CSS must load before the map is initialised, otherwise tiles stack in a broken column. If you are serving files yourself, copy leaflet.css, leaflet.js and the images/ folder from the package into your public directory.
Note on Leaflet 2.x
Leaflet 2 moves to native ES modules and drops the global L object in favour of named imports. The concepts in this tutorial are identical, only the import syntax changes:
import { Map, TileLayer, Marker } from 'leaflet';
If you are on a classic script tag setup, the 1.9.x line remains the safest pick today.
Step 2: Create the Map Container and Give It a Height
This is where 90 percent of blank map bugs come from. A div with no height renders nothing.
<div id="map" role="region" aria-label="Map of our locations"></div>
<style>
#map {
width: 100%;
height: 60vh;
min-height: 320px;
border-radius: 12px;
z-index: 0;
}
@media (max-width: 600px) {
#map { height: 55vh; min-height: 280px; }
}
</style>
Using vh with a min-height keeps the map usable on small screens without pushing the rest of the page below the fold. The z-index: 0 prevents Leaflet panes from floating above sticky headers and modals.

Step 3: Initialise the Map and Load Tiles
const map = L.map('map', {
scrollWheelZoom: false, // avoids hijacking page scroll
zoomControl: true
}).setView([48.8584, 2.2945], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// let users zoom with the wheel only after they click the map
map.on('click', () => map.scrollWheelZoom.enable());
map.on('mouseout', () => map.scrollWheelZoom.disable());
setView takes [latitude, longitude] and a zoom level: 3 for a continent, 12 to 14 for a city, 16 and above for a street. Keeping the attribution line is a licence requirement, not a suggestion.
Step 4: Add Markers and Popups
L.marker([48.8584, 2.2945])
.addTo(map)
.bindPopup(
'<strong>Paris office</strong><br>5 Avenue Anatole France<br>' +
'<a href="/contact/paris">Opening hours</a>'
);
Popups accept raw HTML, so you can drop in an image, a phone link or a button. Add .openPopup() if you want one marker open on load. For permanent labels, use bindTooltip('Paris', { permanent: true }) instead.
Step 5: Use Your Own Branded Pin
const brandPin = L.icon({
iconUrl: '/img/pin.svg',
iconSize: [32, 42], // width, height
iconAnchor: [16, 42], // the tip of the pin
popupAnchor: [0, -38], // where the popup opens
shadowUrl: null
});
L.marker([48.8584, 2.2945], { icon: brandPin, title: 'Paris office' })
.addTo(map);
iconAnchor is the detail everyone gets wrong. It defines which pixel of your image sits on the coordinate. For a classic teardrop pin, that is the horizontal centre and the bottom edge.
Step 6: Loop Through Multiple Locations and Auto Fit the View
Hard coding one marker is fine for a contact page. For a store locator, drive the map from data:
const locations = [
{ name: 'Paris office', lat: 48.8584, lng: 2.2945, url: '/contact/paris' },
{ name: 'Lisbon hub', lat: 38.6916, lng: -9.2160, url: '/contact/lisbon' },
{ name: 'Berlin studio', lat: 52.5163, lng: 13.3777, url: '/contact/berlin' }
];
const markers = locations.map(loc =>
L.marker([loc.lat, loc.lng], { icon: brandPin, title: loc.name })
.bindPopup(`<strong>${loc.name}</strong><br><a href="${loc.url}">Details</a>`)
);
const group = L.featureGroup(markers).addTo(map);
map.fitBounds(group.getBounds().pad(0.2));
fitBounds removes the need to guess a centre point and a zoom level: the map always frames every pin, whether you have 3 locations or 300. The .pad(0.2) adds breathing room around the edge markers.
Dealing With Hundreds of Markers
Past roughly 200 pins, the map starts to feel sluggish and visually messy. Add the leaflet.markercluster plugin, which groups nearby pins into numbered bubbles that split apart as you zoom in. It is a drop in replacement for the feature group above.

Step 7: Serve the Location Data From Your Backend
Keeping coordinates inside your JavaScript bundle means a deploy every time a location changes. Exposing a small JSON endpoint is cleaner, and it lets the same data feed your sitemap, your schema markup and your map.
// server.js
const express = require('express');
const app = express();
const locations = require('./data/locations.json');
app.get('/api/locations', (req, res) => {
res.set('Cache-Control', 'public, max-age=3600');
res.json(locations);
});
app.listen(3000);
And on the client:
fetch('/api/locations')
.then(r => r.json())
.then(data => {
const markers = data.map(loc =>
L.marker([loc.lat, loc.lng], { icon: brandPin })
.bindPopup(`<strong>${loc.name}</strong>`)
);
const group = L.featureGroup(markers).addTo(map);
map.fitBounds(group.getBounds().pad(0.2));
})
.catch(() => console.warn('Locations could not be loaded'));
If your data is already GeoJSON, skip the loop entirely and use L.geoJSON(data, { onEachFeature }), which also handles polygons and lines for coverage areas or delivery zones.
Step 8: The Complete Copy and Paste Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Our locations</title>
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css">
<style>
#map { width: 100%; height: 60vh; min-height: 320px; border-radius: 12px; z-index: 0; }
@media (max-width: 600px) { #map { height: 55vh; } }
</style>
</head>
<body>
<div id="map" role="region" aria-label="Map of our locations"></div>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<script>
const locations = [
{ name: 'Paris office', lat: 48.8584, lng: 2.2945 },
{ name: 'Lisbon hub', lat: 38.6916, lng: -9.2160 }
];
const map = L.map('map', { scrollWheelZoom: false }).setView([46, 2], 5);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
const brandPin = L.icon({
iconUrl: '/img/pin.svg',
iconSize: [32, 42],
iconAnchor: [16, 42],
popupAnchor: [0, -38]
});
const markers = locations.map(loc =>
L.marker([loc.lat, loc.lng], { icon: brandPin, title: loc.name })
.bindPopup(`<strong>${loc.name}</strong>`)
);
const group = L.featureGroup(markers).addTo(map);
map.fitBounds(group.getBounds().pad(0.2));
map.on('click', () => map.scrollWheelZoom.enable());
map.on('mouseout', () => map.scrollWheelZoom.disable());
</script>
</body>
</html>
Choosing a Tile Provider
Leaflet itself does not supply map imagery, it only draws tiles that come from a provider. The default OpenStreetMap tile servers are run by donations and have a usage policy aimed at low volume sites. For a busy commercial site, move to a provider with a free tier or host your own.
| Provider | Key needed | Best for |
|---|---|---|
| OpenStreetMap standard tiles | No | Prototypes, small business sites, low traffic pages |
| CARTO basemaps (Positron, Dark Matter) | No for light use | Clean minimal styles that let your markers stand out |
| MapTiler / Stadia Maps | Yes, free tier | Higher traffic, styled vector maps, an SLA |
| Self hosted (Protomaps, TileServer GL) | No | Full privacy, offline apps, predictable cost |
Always read the provider terms before launch. Attribution requirements and traffic limits differ, and a blocked tile server shows up as a grey map on your production site.
Performance and Privacy Tips
- Lazy load the map. If the map sits below the fold, initialise it only when it enters the viewport with an
IntersectionObserver. That saves the tile requests entirely for users who never scroll down. - Self host Leaflet. Two static files on your own domain mean one less third party dependency and better caching control.
- Preconnect to the tile host with
<link rel="preconnect">to shave off the handshake delay. - No consent banner headaches. OpenStreetMap tiles do not set advertising cookies, which keeps the map out of most cookie consent debates. Tile requests still carry an IP address, so mention your provider in your privacy policy.
- Set
maxZoomandminZoomso visitors cannot zoom to a level where no tiles exist.
Lazy loading snippet
const el = document.getElementById('map');
const io = new IntersectionObserver((entries, obs) => {
if (entries[0].isIntersecting) {
initMap(); // everything from step 3 onwards
obs.disconnect();
}
}, { rootMargin: '200px' });
io.observe(el);

Accessibility Checklist
- Give the container a
role="region"and a descriptivearia-label. - Pass a
titleoption to each marker so keyboard and screen reader users get a label. Leaflet markers are focusable and respond to Enter. - Always publish the same information as text under the map: a plain list of addresses with links. Search engines index that list, and so do users who cannot interact with a canvas.
- Check contrast between your pin colour and the basemap. A dark pin on Dark Matter tiles disappears.
Troubleshooting the Classic Leaflet Bugs
| Symptom | Cause and fix |
|---|---|
| Map area is completely blank | The container has no height. Set an explicit height in CSS. |
| Tiles render in a diagonal staircase | leaflet.css is missing or loaded after the script. |
| Only the top left quarter of the map loads | Map was built inside a hidden tab or accordion. Call map.invalidateSize() when it becomes visible. |
| Default marker icon is a broken image | The images/ folder was not copied when self hosting. Use a custom L.icon or fix the path. |
| Markers land in the ocean near Africa | Latitude and longitude are swapped. Leaflet expects [lat, lng], GeoJSON stores [lng, lat]. |
| Map covers the sticky header | Add z-index: 0 to the container or raise the header z-index. |
Adding the Map to WordPress or Another CMS
You do not need a plugin. Enqueue the Leaflet assets in your theme, then drop a Custom HTML block containing the <div id="map"></div> wherever you want it. For a headless or JAMstack setup, wrap the init code in a component and call map.remove() on unmount so hot reloads do not create duplicate instances.
FAQ
How do I create an interactive map for my website?
Add the Leaflet CSS and JS to your page, create a div with a defined height, initialise the map with L.map(), load OpenStreetMap tiles with L.tileLayer(), then add markers with L.marker().bindPopup(). The whole thing fits in about 20 lines and needs no account.
How do I integrate an interactive map into a website without coding?
You have two no code options: paste an iframe from an open tool such as uMap or a hosted map builder, or use a CMS plugin. Both are faster to set up but add third party scripts, branding and often a monthly fee. The Leaflet route stays free at any traffic level you can serve.
Are there free interactive map tools?
Yes. Leaflet and MapLibre GL JS are free open source libraries. uMap, MapHub and Flourish offer free tiers for simple embeds, and Canva covers static illustrated maps. Only the library route gives you complete control over markup, styling and data.
Is there a free map API I can use for my website?
OpenStreetMap provides free tiles under an open licence with a fair use policy, Nominatim offers free geocoding with strict rate limits, and Overpass serves point of interest data. For heavier production traffic, MapTiler, Stadia Maps and similar providers have free tiers that require a key.
Do I need an API key for Leaflet?
No. Leaflet is a client side library, not a service. A key is only required if you choose a commercial tile provider.
Can I use OpenStreetMap tiles on a commercial website?
The data licence allows commercial use with attribution. The public tile servers, however, are a donated resource with a usage policy aimed at modest traffic. For a high traffic commercial site, switch to a paid or self hosted tile source and keep the attribution visible.
Does an interactive map hurt my Core Web Vitals?
Not if you lazy load it and reserve its space with a fixed height, which prevents layout shift. Leaflet plus OSM tiles is one of the lightest map setups available, and deferring initialisation until the map scrolls into view keeps your Largest Contentful Paint untouched.
Wrapping Up
Building an interactive map for a website does not require a billing account, a quota dashboard or a monthly subscription. Leaflet plus OpenStreetMap gives you custom pins, rich popups, responsive sizing and a map that loads in milliseconds, all from code you own. Start with the copy and paste example above, swap in your coordinates and your pin graphic, then move your location data behind an API route once it starts changing regularly.
