Guide

Property listings on a map

Flats and houses with their price on the map, a card for each that links to the listing, and filters for rent or buy and the number of rooms. The same code works for holiday homes, offices to let, or used cars at several dealers.

Loading the map…
The code below with ten listings in Zürich that do not exist. Click a price.

Three steps

  1. Create a free account and, in the dashboard, create a key of the type “Maps only”, with your website as allowed referer.
  2. Copy the code below and put in your key.
  3. Fill listings with your flats and houses, and paste the code into your page.

The code

<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.css">
<style>
  #map { height: 520px; border-radius: 8px; }
  .filters { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 8px 0; font: 15px system-ui, sans-serif; }
  .price { padding: 3px 8px; border: 2px solid #fff; border-radius: 12px; background: #1f5fbf; color: #fff;
           font: 600 13px system-ui, sans-serif; white-space: nowrap; cursor: pointer; box-shadow: 0 1px 3px rgb(0 0 0 / 0.3); }
  .price.buy { background: #2e7d32; }
</style>

<div class="filters">
  <label><input type="radio" name="deal" value="" checked> All</label>
  <label><input type="radio" name="deal" value="rent"> To rent</label>
  <label><input type="radio" name="deal" value="buy"> To buy</label>
  <label>Rooms from <select id="rooms"><option>1</option><option>2</option><option>3</option><option>4</option><option>5</option></select></label>
</div>
<div id="map"></div>

<script type="module">
import * as maplibregl from "https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.mjs";

// your listings, for example from your database or CMS
const listings = [
  { title: "3.5 rooms with balcony", deal: "rent", price: 2850, rooms: 3.5, url: "/listings/101", lon: 8.5281, lat: 47.3936 },
  { title: "2.5 rooms near the lake", deal: "rent", price: 2400, rooms: 2.5, url: "/listings/102", lon: 8.5547, lat: 47.3552 },
  { title: "4.5 rooms, family flat", deal: "rent", price: 3650, rooms: 4.5, url: "/listings/103", lon: 8.4883, lat: 47.3874 },
  { title: "1.5 room studio", deal: "rent", price: 1690, rooms: 1.5, url: "/listings/104", lon: 8.5181, lat: 47.3702 },
  { title: "5.5 rooms, attic flat", deal: "rent", price: 4900, rooms: 5.5, url: "/listings/105", lon: 8.5612, lat: 47.3714 },
  { title: "3.5 rooms, new building", deal: "buy", price: 1290000, rooms: 3.5, url: "/listings/106", lon: 8.5452, lat: 47.4108 },
  { title: "4.5 rooms with garden", deal: "buy", price: 1850000, rooms: 4.5, url: "/listings/107", lon: 8.4962, lat: 47.4031 },
  { title: "6.5 room house", deal: "buy", price: 2750000, rooms: 6.5, url: "/listings/108", lon: 8.5735, lat: 47.4061 },
  { title: "2.5 rooms, old town", deal: "buy", price: 1150000, rooms: 2.5, url: "/listings/109", lon: 8.5432, lat: 47.3715 },
  { title: "4.5 rooms, lake view", deal: "buy", price: 2390000, rooms: 4.5, url: "/listings/110", lon: 8.5312, lat: 47.3641 }
];

const map = new maplibregl.Map({
  container: "map",
  style: "https://api.overpass.ch/v1/YOUR_KEY/maps/styles/light.json?lang=en",
  center: [8.535, 47.385],
  zoom: 12,
  cooperativeGestures: true
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }));

const chf = (n) => "CHF " + n.toLocaleString("de-CH");

// a price label on the map for each listing, with a popup
const markers = listings.map((l) => {
  const label = document.createElement("div");
  label.className = `price ${l.deal}`;
  label.textContent = l.deal === "rent" ? `${chf(l.price)}/mo` : `CHF ${(l.price / 1e6).toFixed(2)} M`;

  const card = document.createElement("div");
  const title = document.createElement("strong");
  title.textContent = l.title;
  const facts = document.createElement("div");
  facts.textContent = `${l.rooms} rooms · ${chf(l.price)}${l.deal === "rent" ? " a month" : ""}`;
  const link = document.createElement("a");
  link.href = l.url;
  link.textContent = "Details";
  card.append(title, facts, link);

  const marker = new maplibregl.Marker({ element: label })
    .setLngLat([l.lon, l.lat])
    .setPopup(new maplibregl.Popup({ offset: 18 }).setDOMContent(card));
  return { l, marker };
});

// only the listings that match the filters are on the map
function filter() {
  const deal = document.querySelector("input[name=deal]:checked").value;
  const rooms = +document.getElementById("rooms").value;
  for (const { l, marker } of markers) {
    if ((!deal || l.deal === deal) && l.rooms >= rooms) marker.addTo(map);
    else marker.remove();
  }
}
for (const input of document.querySelectorAll("input[name=deal], #rooms")) {
  input.addEventListener("change", filter);
}
filter();
</script>

Your listings

On a real site, the listings come from your database or CMS. If it can return them as JSON in the same form, replace the array with one line:

const listings = await (await fetch("/api/listings.json")).json();

Each listing needs title, deal (rent or buy), price, rooms, url and its position as lat and lon (how to find it). Title and facts go into the card as text, never as HTML, so a title cannot run as code.

What it costs

The first view of Zürich with ten listings loaded 6 map tiles, in a window of 1100 × 640 pixels with MapLibre GL JS 6.13.0; each move of the map adds a few.

PlanMap tiles a monthFor a business website
Every account, free100'000✗ No, for non-commercial websites only
Maps Small, CHF 9 a month2 million✓ Yes
Maps Medium, CHF 29 a month10 million✓ Yes

Prices plus VAT. All plans: maps plans.

Good to know

  • Price labels as HTML elements suit up to a few hundred listings on one map. For thousands, draw them as a GeoJSON layer with clusters, as in the store locator.
  • The map sets no cookies: neither MapLibre nor our map server uses any.
  • The © OpenStreetMap credit must stay on the map. The (i) button in the corner opens it.