A search box over a MapLibre map. Typing calls /v1/autocomplete with the map centre as the focus point, results show as markers, and picking one calls /v1/place?geometry=true to draw the place outline when the place has one. Tested with MapLibre GL JS 6.15.0 and Vite 8.
Use a publishable key (gc_pk_…) whose allowed origins include your site and http://localhost:5173 for development.
Countries, regions, counties and venues mapped as areas return a Polygon or MultiPolygon; a city without an OSM city boundary returns a Point. The code below draws the outline when there is one and flies to the point otherwise.
Set up
npm create vite@latest geolith-map -- --template vanilla-ts
cd geolith-map
npm install maplibre-gl@6
echo "VITE_GEOLITH_KEY=gc_pk_your_publishable_key" > .env.local
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>geolith search + MapLibre</title>
<style>
html, body, #map { margin: 0; height: 100%; }
#search { position: absolute; top: 12px; left: 12px; z-index: 1; width: min(360px, calc(100% - 24px)); font: 14px system-ui, sans-serif; }
#search input { box-sizing: border-box; width: 100%; padding: 10px 12px; border: 1px solid #ccc; border-radius: 6px; }
#search ul { margin: 4px 0 0; padding: 0; list-style: none; background: #fff; border-radius: 6px; box-shadow: 0 4px 16px rgb(0 0 0 / 0.15); }
#search li button { display: block; width: 100%; padding: 8px 12px; border: 0; background: none; text-align: left; cursor: pointer; }
#search li button:hover { background: #f1f5f9; }
</style>
</head>
<body>
<div id="search">
<input id="q" type="search" placeholder="Search places" autocomplete="off" />
<ul id="results"></ul>
</div>
<div id="map"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
src/main.ts
import { LngLatBounds, Map, Marker, Popup, setWorkerUrl, type GeoJSONSource } from 'maplibre-gl';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
import 'maplibre-gl/dist/maplibre-gl.css';
// MapLibre 6 runs its worker from a separate module; let Vite emit it.
setWorkerUrl(workerUrl);
const KEY = import.meta.env.VITE_GEOLITH_KEY as string; // gc_pk_…, allowed origin: this site
const API = 'https://api.geolith.app';
const ATTRIBUTION = '© OpenStreetMap contributors (ODbL), Overture Maps Foundation';
interface Place {
type: 'Feature';
geometry:
| { type: 'Point'; coordinates: [number, number] }
| { type: 'Polygon'; coordinates: [number, number][][] }
| { type: 'MultiPolygon'; coordinates: [number, number][][][] };
bbox?: [number, number, number, number];
properties: { gid: string; label: string; center?: [number, number] };
}
const map = new Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: [78.9629, 20.5937],
zoom: 4,
attributionControl: { customAttribution: ATTRIBUTION },
});
map.on('load', () => {
map.addSource('outline', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({
id: 'outline-fill',
type: 'fill',
source: 'outline',
paint: { 'fill-color': '#14b8a6', 'fill-opacity': 0.2 },
});
map.addLayer({
id: 'outline-line',
type: 'line',
source: 'outline',
paint: { 'line-color': '#0d9488', 'line-width': 2 },
});
});
async function geolith(path: string, params: Record<string, string>): Promise<Place[]> {
const url = new URL(path, API);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
url.searchParams.set('api_key', KEY);
const res = await fetch(url);
if (!res.ok) return [];
return ((await res.json()) as { features: Place[] }).features;
}
let markers: Marker[] = [];
function showMarkers(places: Place[]) {
for (const marker of markers) marker.remove();
markers = places.flatMap((place) => {
if (place.geometry.type !== 'Point') return [];
return [
new Marker()
.setLngLat(place.geometry.coordinates)
.setPopup(new Popup().setText(place.properties.label))
.addTo(map),
];
});
}
async function select(place: Place) {
showMarkers([place]);
// geometry=true returns the outline when the place has one; otherwise a point.
const [full] = await geolith('/v1/place', { ids: place.properties.gid, geometry: 'true' });
const source = map.getSource<GeoJSONSource>('outline');
if (full && full.geometry.type !== 'Point') {
source?.setData({ type: 'FeatureCollection', features: [full] });
if (full.bbox) map.fitBounds(LngLatBounds.convert(full.bbox), { padding: 40 });
return;
}
source?.setData({ type: 'FeatureCollection', features: [] });
if (place.geometry.type === 'Point') map.flyTo({ center: place.geometry.coordinates, zoom: 14 });
}
const input = document.querySelector<HTMLInputElement>('#q');
const list = document.querySelector<HTMLUListElement>('#results');
let timer: ReturnType<typeof setTimeout> | undefined;
input?.addEventListener('input', () => {
clearTimeout(timer);
const text = input.value.trim();
if (text.length < 3 || !list) return;
timer = setTimeout(async () => {
const center = map.getCenter();
const places = await geolith('/v1/autocomplete', {
text,
size: '5',
'focus.point.lat': center.lat.toFixed(5),
'focus.point.lon': center.lng.toFixed(5),
});
showMarkers(places);
list.replaceChildren(
...places.map((place) => {
const item = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.textContent = place.properties.label;
button.addEventListener('click', () => {
input.value = place.properties.label;
list.replaceChildren();
void select(place);
});
item.append(button);
return item;
}),
);
}, 200);
});
Run npm run dev and open http://localhost:5173.
Notes
- MapLibre 6 loads its worker as a separate module. The
?urlimport andsetWorkerUrllet Vite emit and serve it. - The base map here is MapLibre's demo style. Swap in your own style URL for street-level detail.
customAttributionputs the required line,© OpenStreetMap contributors (ODbL), Overture Maps Foundation, in the map's attribution control.- Vector tiles from
GET https://api.geolith.app/v1/tiles/{set}.jsoncan be added as a source: the TileJSON's tile URLs already carry your key.