When a map makes a page better
A map is the fastest way to answer "where?" You can describe "corner of 5th and Main" in words — or you can show it. For any page tied to a physical place, a map earns its spot in the layout.
Contact pages
Give visitors an instant sense of where you are. Bonus: one-click directions on mobile.
Store / location finders
Show every location at a glance. Let users filter or click a pin for details.
Event & venue pages
Weddings, concerts, meetups — embed the venue and let guests zoom in to plan.
Location picker forms
Real estate, delivery addresses, service areas — let users click the map instead of typing coordinates.
Data visualization
Color regions by value, plot incidents, show flow lines. Maps turn data into stories.
Travel & routes
Itineraries, hiking trails, road trips — show the path as a line and let users zoom in anywhere along it.
Four ways to put a map on a page
Pick based on what you need: zero setup? iframe embed. Free and flexible? Leaflet. Premium and polished? Mapbox or Google.
Google Maps iframe Easiest
Copy an embed URL from Google Maps and paste it into your page. No JavaScript. No account. Works forever. Limited styling.
Leaflet + OpenStreetMap Free, flexible
Open source library, no API key, full control over markers & interactions. Used in this tutorial. Start here.
Mapbox GL JS Premium look
Beautiful custom styles (dark, satellite, minimalist), 3D buildings, smooth pan/zoom. Free up to 50K loads/month.
Google Maps JS API Industry standard
The familiar Google look, Street View, Place Autocomplete, Directions service. Requires an API key + billing account (free tier is generous).
Quickest possible embed — Google Maps iframe
No JS, no library, no setup. Open Google Maps → pick a place → Share → Embed a map → Copy HTML. Paste into your page. Done in 30 seconds.
<iframe src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d12344..." width="600" height="450" style="border:0; border-radius: 12px;" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade"> </iframe>
Leaflet — follow along, one step at a time
Follow these 8 steps in order and you'll have a working map in about 5 minutes. Each step adds one small thing to your file, so it's easy to see what each piece does.
Create a new HTML file
Make a new folder for your project. Inside it, create a file called
index.html and paste this starter template.
Open it in your browser — you should see a blank white page. That's correct.
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My first map</title>
</head>
<body>
</body>
</html>
Link Leaflet's CSS in the <head>
Leaflet needs its own stylesheet to render the map tiles, zoom buttons, and popups
correctly. Add this one line above </head>.
<head>
<meta charset="UTF-8">
<title>My first map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
</head>
Add Leaflet's JavaScript library
This is the actual library — the thing that gives us the L
variable we use to build maps. Add it just below the CSS link.
<head>
<meta charset="UTF-8">
<title>My first map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
</head>
unpkg.com is a free CDN — no signup, no API key, just paste the URL and it works.Add the map container
Leaflet needs a box to draw the map into. Add a <div>
inside <body> with any id
you like — we'll use map.
<body>
<div id="map"></div>
</body>
Give the map a height
This is the #1 reason beginners see a blank screen. A div with no height is invisible.
Add a <style> tag (or link a stylesheet) and give
your map container a height.
<head>
...
<style>
#map {
height: 400px;
width: 100%;
}
</style>
</head>
<div id="map" style="height: 400px;"></div> works the same.Initialize the map with JavaScript
Add a <script> tag at the end of
<body> (after the div). Tell Leaflet to create a
map inside #map, centered on some coordinates, at a zoom
level.
<body>
<div id="map"></div>
<script>
const map = L.map('map').setView([40.7580, -73.9855], 13);
</script>
</body>
[40.7580, -73.9855] is [latitude, longitude]. That's Times Square. Zoom: 0 = whole world, 10 = city, 15 = streets, 19 = buildings.Add a tile layer (the actual map image)
At this point you'll see a gray box. That's because there's no map imagery yet. Add OpenStreetMap tiles — free, open source, no API key.
<script>
const map = L.map('map').setView([40.7580, -73.9855], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
</script>
Drop a pin and open a popup
A map without a marker is just geography. Add a pin at the same coordinates with a little popup bubble.
<script>
const map = L.map('map').setView([40.7580, -73.9855], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
L.marker([40.7580, -73.9855])
.addTo(map)
.bindPopup('<strong>Times Square</strong><br>New York, NY')
.openPopup();
</script>
bindPopup() attaches the popup to the marker. openPopup() opens it on load — remove it if you want users to click the pin to see it.Done — you have a working map 🎉
Save the file, reload, and you should see a map of Times Square with a pin and a popup. From here, the 4 examples below show how to swap in a contact-page layout, multiple stores, a click-to-pin picker, or a multi-stop route.
The whole thing in one file
If you'd rather copy the finished version all at once — here you go. Paste this into
index.html, open it in a browser, and you have a working map.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My first map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
#map { height: 400px; width: 100%; border-radius: 12px; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const map = L.map('map').setView([40.7580, -73.9855], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
L.marker([40.7580, -73.9855])
.addTo(map)
.bindPopup('<strong>Times Square</strong><br>New York, NY')
.openPopup();
</script>
</body>
</html>// Create a map, centered on NYC, zoom level 13
const map = L.map('map').setView([40.7580, -73.9855], 13);
// Add the OpenStreetMap tile layer (this is what makes it look like a map)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
// Drop a marker with a popup
L.marker([40.7580, -73.9855])
.addTo(map)
.bindPopup('Times Square')
.openPopup();/* The map container must have a height — otherwise you see nothing */
#map {
height: 400px;
width: 100%;
border-radius: 12px; /* optional but nice */
overflow: hidden;
}Contact page with a location map
The most common use case. A two-column layout: info on the left, map on the right with a marker on the business location. Click the pin for the address + directions link.
Visit Acme Coffee
Open 7am – 6pm, every day of the week. Dogs welcome on the patio.
<section class="contact">
<div class="info">
<h3>Visit Acme Coffee</h3>
<p>123 Bryant Park, New York, NY</p>
<p>(212) 555-0134</p>
</div>
<div id="map"></div>
</section>const shopLocation = [40.7536, -73.9832]; // [lat, lng]
const map = L.map('map').setView(shopLocation, 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap'
}).addTo(map);
L.marker(shopLocation)
.addTo(map)
.bindPopup(`
<strong>Acme Coffee</strong><br>
123 Bryant Park<br>
<a href="https://www.google.com/maps/dir/?api=1&destination=40.7536,-73.9832"
target="_blank">Get directions →</a>
`)
.openPopup();.contact {
display: grid;
grid-template-columns: 1fr 1.4fr;
border-radius: 12px;
overflow: hidden;
}
@media (max-width: 720px) {
.contact { grid-template-columns: 1fr; }
}
.contact .info { padding: 32px; }
#map { height: 340px; width: 100%; }Store locator with clickable list
List of stores on the left, map with numbered pins on the right. Click a store name — the map zooms to it. Click a pin — it highlights the store. Core pattern for any multi-location business.
<div class="stores"> <aside class="list" id="storeList"></aside> <div id="map"></div> </div>
const stores = [
{ id: 1, name: 'Bryant Park', address: '123 Bryant Park', coords: [40.7536, -73.9832] },
{ id: 2, name: 'Greenwich Ave',address: '88 Greenwich Ave', coords: [40.7365, -74.0018] },
{ id: 3, name: 'DUMBO', address: '45 Water St', coords: [40.7033, -73.9881] },
{ id: 4, name: 'Park Slope', address: '200 7th Ave', coords: [40.6687, -73.9816] },
];
const map = L.map('map').setView([40.72, -73.99], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
const listEl = document.getElementById('storeList');
const markers = {};
stores.forEach(s => {
// Numbered pin
const pin = L.marker(s.coords).addTo(map);
pin.bindPopup(`<strong>${s.name}</strong><br>${s.address}`);
markers[s.id] = pin;
// Sidebar entry
const el = document.createElement('div');
el.className = 'store';
el.innerHTML = `
<div class="pin">${s.id}</div>
<div><strong>${s.name}</strong><span>${s.address}</span></div>`;
el.addEventListener('click', () => {
map.flyTo(s.coords, 15, { duration: 0.8 });
pin.openPopup();
});
listEl.appendChild(el);
});Location picker — click anywhere to drop a pin
Instead of making users type coordinates or addresses, let them click the map. Useful for real estate forms, delivery drop-off points, service-area selectors, or event locations. The lat/lng updates live.
let pin; // we'll reuse one marker and move it
const map = L.map('map').setView([40.7580, -73.9855], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
// Drop a starting pin
pin = L.marker([40.7580, -73.9855], { draggable: true }).addTo(map);
// Click anywhere → move the pin + update the form
map.on('click', e => {
pin.setLatLng(e.latlng);
updateCoords(e.latlng);
});
// Drag the pin → same update
pin.on('dragend', e => updateCoords(e.target.getLatLng()));
function updateCoords({ lat, lng }) {
document.getElementById('lat').textContent = lat.toFixed(5);
document.getElementById('lng').textContent = lng.toFixed(5);
// Also stash them in your form fields:
// document.querySelector('input[name="lat"]').value = lat;
// document.querySelector('input[name="lng"]').value = lng;
}Route display — connect pins with a line
For an itinerary, a delivery route, or a hiking trail. Drop pins for each stop, then draw a polyline connecting them. Leaflet handles auto-zoom to fit the whole route.
const stops = [
{ name: 'Washington Square', coords: [40.7308, -73.9975] },
{ name: 'Union Square', coords: [40.7359, -73.9911] },
{ name: 'Flatiron Building', coords: [40.7411, -73.9897] },
{ name: 'Empire State', coords: [40.7484, -73.9857] },
{ name: 'Bryant Park', coords: [40.7536, -73.9832] },
];
const map = L.map('map').setView([40.74, -73.99], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
// 1. Drop a pin at each stop
stops.forEach((s, i) => {
L.marker(s.coords)
.addTo(map)
.bindPopup(`<strong>${i + 1}. ${s.name}</strong>`);
});
// 2. Draw a polyline connecting them
const path = stops.map(s => s.coords);
const line = L.polyline(path, { color: '#0ea5e9', weight: 4, opacity: 0.8 }).addTo(map);
// 3. Zoom to fit the whole route
map.fitBounds(line.getBounds(), { padding: [30, 30] });API keys — how not to get your credit card drained
If you use Google Maps, Mapbox, or any paid map provider, you have an API key. Keys are like passwords — if someone copies yours, they can run up your bill, exhaust your free quota, or impersonate your app. Here's how to stay safe.
The three threats
Key in public code
Your JS runs in the browser — anyone can open DevTools and copy your API key from the source. If it's unrestricted, they can use it from anywhere.
Quota + billing abuse
Google Maps and Mapbox charge per-use above free limits. A scraped key used across 10,000 requests/day can become a real bill fast.
Committing to Git
Once an API key lands in a public GitHub repo, bots find it within minutes. Even if you delete it, the commit history still has it.
Three layers of protection
No single layer is enough on its own. Use all three for anything with a paid plan.
Lock the key to your domain (referrer restrictions)
This is the single most important thing you can do. In the Google / Mapbox dashboard, restrict the key so it only works on requests that come from your domain. Even if someone copies it, it's useless on their site.
# Google Cloud Console → APIs & Services → Credentials → your key Application restrictions: HTTP referrers (websites) Website restrictions (allow list): https://yoursite.com/* https://www.yoursite.com/* http://localhost:*/* # for local dev
Never commit keys to Git — use environment variables
Store the key in a .env file (not committed), and have
your build tool inject it at deploy time. On Netlify / Vercel / GitHub Pages, set
env vars in the dashboard so they never touch your repo.
// .env (add to .gitignore — do NOT commit) VITE_MAPBOX_TOKEN=pk.eyJ1Ijoi... // .gitignore — add this line: .env .env.local
// In your JS (Vite example) — the build replaces this // with the actual value at build time const map = new mapboxgl.Map({ accessToken: import.meta.env.VITE_MAPBOX_TOKEN, container: 'map', style: 'mapbox://styles/mapbox/streets-v12' });
For sensitive usage: proxy through your own server
For premium features (geocoding billed per-request, directions, places search), never put the key in the browser at all. Have your backend hold the key and make the API calls. Your frontend calls your server, which calls the provider.
// Browser — no key in sight async function geocode(address) { const res = await fetch(`/api/geocode?q=${encodeURIComponent(address)}`); return res.json(); } // Your server (Node.js example) — key stays server-side app.get('/api/geocode', async (req, res) => { const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/` + `${encodeURIComponent(req.query.q)}.json` + `?access_token=${process.env.MAPBOX_TOKEN}`; const r = await fetch(url); res.json(await r.json()); });
Set a billing cap on your account
Even with every protection in place — a bug in your own code could hit an infinite loop and blow through your quota. Both Google and Mapbox let you cap spending. Set it on day one.
Mapbox: Account → Statistics → you can set an alert when you hit % of plan.
Or — skip all of this with Leaflet + OpenStreetMap
Every demo on this page uses Leaflet. Zero keys. Zero billing. Zero quota. OpenStreetMap tiles are free for normal usage (< 1 req/second per user). If you don't need the Google or Mapbox visual style, skip them entirely.
Do's and don'ts
Do
- Give the map a fixed height — Leaflet renders nothing without one
- Include an OpenStreetMap attribution — required by their license
- Add a "Get directions" link so mobile users can launch their nav app
- Lazy-load the map script if it's below the fold
- Provide the address as text too — for SEO, screen readers, and printing
- Pick a zoom level that shows neighborhood context (13–16 is sweet spot)
Don't
- Hardcode an API key in public JavaScript (Google/Mapbox)
- Forget
loading="lazy"on iframe maps - Show 50 pins when 5 will do — declutter
- Ship a map without attribution — OSM requires it
- Use scroll-wheel zoom on a page users are trying to scroll past
- Make the map the only way to find an address
L.map('map', { scrollWheelZoom: false })
— users can still zoom with the +/- buttons, and your page scrolls normally.
Where to go next
- leafletjs.com — docs for the library used in every demo here
- openstreetmap.org — the free map data that powers OSM tiles
- Mapbox GL JS — when you need beautiful custom styles
- Google Maps JS API — for Street View, Place Autocomplete, Directions
- Carto basemaps — drop-in dark / light / minimal tile styles for Leaflet