Recipe: custom map UI with your own map library
Use Yatmo for the data and Leaflet, MapLibre or Google Maps for the rendering. The same places as the JS Map plugin, with your own visual identity.
Architecture
The REST API authenticates with your backend key, which must stay on your servers (see authentication). The browser therefore never calls Yatmo directly:
browser (map) → your backend /api/pois?bound1=…&bound2=…
your backend → https://be.yatmo.com/points (LicenseKey: backend key)
your backend → browser (the places, and the Nz flag)
If you only need Yatmo's own map, the JS Map plugin runs in the browser with your frontend key and needs no backend at all.
1. Load the market's categories once
/simplifiedcategories returns, for the
market of the subdomain, groups of place-type ids ready for /points. The group
names are translated (“Shopping” in English, “Magasins” in French), so do not
select groups by name. Select them by the category digits of their ids: an id is 1,
three digits of category, four of sub-type, six of variant. Education is category 1, transport 2,
shopping 3.
For Belgium, on 21 September 2026, the shopping group was [10030001000001]; an id ending in 000001 means every variant of that type.
2. Your backend relay
Server-side JavaScript (Node.js 18 or later, no dependency). It keeps the key, loads the categories once, and forwards the Nz flag.
// Server-side JavaScript (Node.js 18+): node relay.mjs
import http from 'node:http';
const YATMO = 'https://be.yatmo.com'; // the market is the subdomain
const KEY = process.env.YATMO_BACKEND_KEY; // never sent to the browser
const WANTED_CATEGORIES = [1, 2, 3]; // education, transport, shopping
const categoryOf = id => Math.floor(id / 1e10) % 1000;
async function loadTypeIds() {
const res = await fetch(YATMO + '/simplifiedcategories?language=EN', { headers: { LicenseKey: KEY } });
if (!res.ok) throw new Error('simplifiedcategories ' + res.status);
const groups = await res.json(); // { '<translated name>': [ids] }
return Object.values(groups).flat().filter(id => WANTED_CATEGORIES.includes(categoryOf(id)));
}
const typeIds = await loadTypeIds(); // refresh daily; it rarely changes
http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname !== '/api/pois') { res.writeHead(404).end(); return; }
const qs = new URLSearchParams({
bound1: url.searchParams.get('bound1') ?? '', // 'latitude,longitude' south-west
bound2: url.searchParams.get('bound2') ?? '', // 'latitude,longitude' north-east
language: url.searchParams.get('language') ?? 'EN',
groupSamePositions: 'true',
poiTypesIds: typeIds.join(',')
});
const upstream = await fetch(YATMO + '/points?' + qs, { headers: { LicenseKey: KEY } });
res.writeHead(upstream.status, {
'Content-Type': upstream.headers.get('content-type') ?? 'application/json',
'X-Needs-Zoom': upstream.headers.get('Nz') ?? 'false'
});
res.end(await upstream.text()); // 400 'The requested area is too large.' passes through
}).listen(8080);
3. The map in the browser
Browser JavaScript. It only talks to your backend, and carries no key.
const map = L.map('map').setView([50.846714, 4.352514], 16);
L.tileLayer('https://{your-tile-server}/{z}/{x}/{y}.png').addTo(map);
const layer = L.layerGroup().addTo(map);
const notice = document.getElementById('zoom-notice'); // any element of yours
async function refresh() {
const b = map.getBounds();
const qs = new URLSearchParams({
bound1: b.getSouth() + ',' + b.getWest(),
bound2: b.getNorth() + ',' + b.getEast()
});
const res = await fetch('/api/pois?' + qs);
if (res.status === 400) { // area too large: ask to zoom in
layer.clearLayers();
notice.hidden = false;
return;
}
const places = await res.json(); // n name, la / ln coordinates, t type
notice.hidden = res.headers.get('X-Needs-Zoom') !== 'true';
layer.clearLayers();
places.forEach(p => {
const popup = document.createElement('strong');
popup.textContent = p.n; // text, never HTML from the data
L.marker([p.la, p.ln], { title: p.n }).bindPopup(popup).addTo(layer);
});
}
let timer;
map.on('moveend', () => { clearTimeout(timer); timer = setTimeout(refresh, 250); });
refresh();
Notes
Nz: truemeans the list was truncated for that area. The relay passes it on asX-Needs-Zoom; show a “zoom in to see every place” hint.- An area that is too large returns
400withThe requested area is too large.Treat it as “zoom in”, not as a failure. - An area outside the market returns an empty array, not an error.
- Debounce
moveend: dragging a map fires it constantly. 200 to 300 ms is enough. - Need a travel time on click? Have your backend call /route from the property to the clicked place, in the mode you show.