Lab

Put a map on any web page

Four real-world examples — contact page, store locator, click-to-pin picker, and route display. All built with Leaflet, free, no API key, no quota.

Step 1

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.

Step 2

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).

Heads up on API keys: Mapbox and Google both need a signed-in account and API key. Leaflet + OpenStreetMap does not — you can ship a working map with nothing but HTML, CSS, and JS. Every demo below uses Leaflet.
Method 1

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>
Pros: Fastest way to ship. Cons: Can't style it, can't add custom markers, can't respond to clicks. For a contact page that's fine. For anything interactive, keep reading.
Method 2

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.

1

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>
Why this matters: Having a clean blank file means if something breaks in the next steps, you know exactly what caused it.
2

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>
Forget this step and your map will look broken — pins misaligned, tiles overlapping, zoom buttons huge. Always load Leaflet's CSS.
3

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>
Tip: unpkg.com is a free CDN — no signup, no API key, just paste the URL and it works.
4

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>
Save your file and reload — you still see a blank page. That's normal — the div has no height yet. Next step fixes that.
5

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>
You can also inline it: <div id="map" style="height: 400px;"></div> works the same.
6

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>
Coordinates: [40.7580, -73.9855] is [latitude, longitude]. That's Times Square. Zoom: 0 = whole world, 10 = city, 15 = streets, 19 = buildings.
7

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: '&copy; OpenStreetMap contributors',
    maxZoom: 19
  }).addTo(map);
</script>
Reload now — you should see a real map of Manhattan. 🗺️ Try dragging, scrolling to zoom, and double-clicking.
8

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: '&copy; 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.

Finding your own coordinates: open Google Maps, right-click any location, and the latitude + longitude appear at the top of the menu. Copy-paste straight into your code.

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: '&copy; 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: '&copy; 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;
}
Example 1

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.

acmecoffee.com/contact

Visit Acme Coffee

Open 7am – 6pm, every day of the week. Dogs welcome on the patio.

Address123 Bryant Park, New York, NY 10018
Phone(212) 555-0134
Emailhi@acmecoffee.com
<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: '&copy; 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%; }
Example 2

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.

acmecoffee.com/locations
<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);
});
Example 3

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.

acmecoffee.com/delivery
Latitude:40.7580
Longitude:-73.9855
Click anywhere on the map to move the pin. The coordinates update instantly — pair with a hidden form input to submit a precise location.
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;
}
Example 4

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.

acmecoffee.com/nyc-walking-tour
Distance: 3.8 mi (6.1 km)
Stops: 5
Duration: ~1h 20m walking
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] });
Security

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.

1

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
Mapbox equivalent: Account → Tokens → edit token → URL restrictions. Same idea — list your domains, block everyone else.
2

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'
});
Already committed a key? Rotate it immediately in the dashboard (delete + regenerate). Removing it from later commits isn't enough — bots have already scraped the history.
3

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());
});
When to bother: paid geocoding, directions, autocomplete, any API call that costs real money per request. For showing a static map with a pin on it, referrer restrictions (step 1) are enough.
4

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.

Google: Cloud Console → Billing → Budgets & alerts → set a max spend.
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.

Fair use: OpenStreetMap's free tile server assumes personal / small-site usage. If you're building a popular app, switch to a paid tile provider like Carto, Stadia Maps, or Mapbox (they're cheap — <$50/mo covers thousands of users).
The 30-second checklist before you ship a map-powered page: ✅ Restricted the API key to your domain · ✅ Removed any key from Git history (or rotated it) · ✅ Set a billing cap · ✅ Used environment variables, not hardcoded strings · ✅ Tested that it still works after all that lockdown.
Step 3

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
Disable scroll-zoom: L.map('map', { scrollWheelZoom: false }) — users can still zoom with the +/- buttons, and your page scrolls normally.
Resources

Where to go next