Geocoding
Address search for MapLibre maps: drop-in search controls, the geocoding engines behind them, and hosted geocoding APIs, compared on price, features, and terms.
Try it
A live search box: maplibre-gl-geocoder on a MapLibre map, searching OpenStreetMap through Photon, komoot's open-source geocoder, on its free public instance.
Loading the map…
Demo code
// The live search box on the Geocoding page: maplibre-gl-geocoder on a
// MapLibre map, searching OpenStreetMap through Photon. The page loads this
// module only when the map scrolls into view.
import MaplibreGeocoder from "@maplibre/maplibre-gl-geocoder";
import "@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css";
import type { FitBoundsOptions, FlyToOptions } from "maplibre-gl";
import { maplibregl } from "./maplibre";
import { photonGeocoderApi } from "./photon";
// The plugin hands these to fitBounds for results with an area and to flyTo
// for points, so they may use either one's options. Without the cap, a
// building-sized result would zoom in to about 20.
const flyTo: FlyToOptions & FitBoundsOptions = { maxZoom: 16 };
export function mountGeocoder(container: HTMLElement): void {
const map = new maplibregl.Map({
container,
style: "https://tiles.openfreemap.org/styles/liberty",
center: [10, 30],
zoom: 1.4,
cooperativeGestures: true,
attributionControl: {
compact: true,
customAttribution: 'Search: <a href="https://photon.komoot.io">Photon</a> by komoot',
},
});
map.addControl(
new MaplibreGeocoder(photonGeocoderApi, {
maplibregl,
placeholder: "Search a place, like Kyoto",
showResultsWhileTyping: true,
minLength: 3,
debounceSearch: 300,
flyTo,
}),
"top-left",
);
}The Photon adapter turns Photon's results into the plugin's format:
// maplibre-gl-geocoder backed by Photon (https://photon.komoot.io), komoot's
// open-source geocoder for OpenStreetMap data. Its public instance allows use
// "as long as the number of requests stay in a reasonable limit" and needs no
// key; the map must credit OpenStreetMap, which the basemap already does.
import type {
CarmenGeojsonFeature,
MaplibreGeocoderApi,
MaplibreGeocoderApiConfig,
MaplibreGeocoderFeatureResults,
} from "@maplibre/maplibre-gl-geocoder";
const PHOTON = "https://photon.komoot.io";
// The public instance answers HTTP 400 for any other language.
const LANGUAGES = ["en", "de", "fr"];
interface PhotonProperties {
osm_type?: string;
osm_id?: number;
osm_value?: string;
type?: string;
name?: string;
street?: string;
housenumber?: string;
postcode?: string;
city?: string;
state?: string;
country?: string;
extent?: [number, number, number, number]; // [west, north, east, south]
}
interface PhotonFeature {
geometry: { type: "Point"; coordinates: [number, number] };
properties: PhotonProperties;
}
// Photon's extent is [west, north, east, south]; Carmen's bbox is [west, south, east, north].
function toBbox([x1, y1, x2, y2]: [number, number, number, number]): [
number,
number,
number,
number,
] {
return [Math.min(x1, x2), Math.min(y1, y2), Math.max(x1, x2), Math.max(y1, y2)];
}
function toCarmen(feature: PhotonFeature): CarmenGeojsonFeature {
const p = feature.properties;
const street = [p.street, p.housenumber].filter(Boolean).join(" ");
const text = p.name || street || p.city || p.state || p.country || "";
// "Station", "City": tells apart places that share a name.
const kind = p.osm_value && p.osm_value !== "yes" ? p.osm_value.replaceAll("_", " ") : "";
const label = kind.charAt(0).toUpperCase() + kind.slice(1);
const parts = [
text,
label,
street,
[p.postcode, p.city].filter(Boolean).join(" "),
p.state,
p.country,
];
return {
type: "Feature",
id: `${p.osm_type}${p.osm_id}`,
geometry: feature.geometry,
properties: { ...p },
text,
place_name: [...new Set(parts.filter(Boolean))].join(", "),
place_type: [p.type || "place"],
center: feature.geometry.coordinates,
...(p.extent && { bbox: toBbox(p.extent) }),
};
}
async function photon(
path: string,
params: URLSearchParams,
): Promise<MaplibreGeocoderFeatureResults> {
const response = await fetch(`${PHOTON}${path}?${params}`);
if (!response.ok) throw new Error(`Photon ${path}: HTTP ${response.status}`);
const json = (await response.json()) as { features: PhotonFeature[] };
return { type: "FeatureCollection", features: json.features.map(toCarmen) };
}
// The plugin's types say comma-separated strings, but it passes arrays at runtime.
const list = (value: string | string[] | undefined): string[] =>
(Array.isArray(value) ? value : (value?.split(",") ?? []))
.map((item) => item.trim())
.filter(Boolean);
function withLanguage(params: URLSearchParams, config: MaplibreGeocoderApiConfig) {
const code = list(config.language)[0]?.slice(0, 2).toLowerCase();
if (code && LANGUAGES.includes(code)) params.set("lang", code);
return params;
}
export const photonGeocoderApi: MaplibreGeocoderApi = {
forwardGeocode(config) {
const params = new URLSearchParams({
q: String(config.query),
limit: String(config.limit ?? 5),
});
if (config.proximity) {
params.set("lon", String(config.proximity[0]));
params.set("lat", String(config.proximity[1]));
}
if (config.bbox) params.set("bbox", config.bbox.join(","));
for (const country of list(config.countries)) params.append("countrycode", country);
return photon("/api/", withLanguage(params, config));
},
reverseGeocode(config) {
const [lon, lat] = config.query as number[];
const params = new URLSearchParams({ lon: String(lon), lat: String(lat), limit: "1" });
return photon("/reverse", withLanguage(params, config));
},
};Search controls
Drop-in address search components for MapLibre GL JS maps.
maplibre-gl-geocoderOfficial
Geocoding search control
A geocoder control for MapLibre GL JS that adds an address search box to the map with support for multiple geocoding providers.
Used by 4 products
MapTiler Geocoding Control
Address search by MapTiler
A geocoding control for MapLibre GL JS powered by the MapTiler API, with autocomplete and multi-language support.

Stadia Maps Search Box
Autocomplete search by Stadia Maps
A search-as-you-type control for MapLibre GL JS powered by the Stadia Maps autocomplete search API, with debouncing, result caching, and zoom to the selected place.
Geocoding engines
Self-hosted backends for address search and reverse geocoding.
Nominatim
OpenStreetMap geocoding engine
The official geocoding engine for OpenStreetMap data. Nominatim powers address search and reverse geocoding from OSM, deployable as a self-hosted service.
Used by 3 products
Photon
Fast open-source geocoder
A high-performance open-source geocoder built on OpenStreetMap data by Komoot. Uses Elasticsearch for fast search with typo tolerance and multilingual support.
Used by 2 products
Pelias
Modular open-source geocoder
A modular, open-source geocoding engine that combines data from OpenStreetMap, Who's on First, GeoNames, and OpenAddresses into a single searchable index.
Hosted geocoding APIs
Geocoding services you can call from a MapLibre map without running an engine.
Compare the options
Free tiers, features, and what the terms let you do with the results. Storing matters more than it seems: saving geocoded addresses in your own database is often restricted or billed separately.
Three of these can't be shown on a MapLibre map
Google's terms forbid using Geocoding API results "in conjunction with a non-Google map" (Service Specific Terms 6.2). Mapbox allows its results "only in conjunction with a Mapbox map" (Geocoding API docs). Azure Maps forbids displaying results "on any third-party content or geographical map database" (Product Terms).
Hosted APIs
| Name | Free tier | Needs a key | Autocomplete | Reverse | Storing results | Data | Open source |
|---|---|---|---|---|---|---|---|
| MapTiler Geocoding ↗ | 100,000 API requests/month, shared across MapTiler APIs (non-commercial use only) | Yes | Yes | Yes | Allowed | Mixed | No |
| Mapbox Geocoding ↗ | 100,000 requests/month (Temporary geocoding only; Permanent geocoding has no free tier) | Yes | Yes | Yes | Restricted1 | Mixed | ? |
| Stadia Maps Geocoding ↗ | 200,000 credits/month, non-commercial only (a geocode costs 20 credits, a v2 autocomplete 1) | Yes | Yes | Yes | Restricted2 | Mixed | Yes |
| Geoapify Geocoding ↗ | 3,000 credits/day (1 credit per geocoding request), commercial use allowed with attribution | Yes | Yes | Yes | Allowed | Mixed | ? |
| OpenCage ↗ | None for production; free trial of 2,500 requests/day for testing only | Yes | Yes | Yes | Allowed | Mixed | ? |
| HERE Geocoding ↗ | 30,000 geocode and reverse geocode requests/month | Yes | Yes | Yes | Restricted3 | Proprietary | No |
| Esri Geocoding ↗ | 20,000 geocodes/month, not stored (stored geocodes have no free tier) | Yes | Yes | Yes | Restricted4 | Mixed | No |
| TomTom Geocoding ↗ | 20,000 geocoding and 20,000 reverse geocoding requests/month | Yes | Yes | Yes | Restricted5 | Mixed | No |
| Amazon Location Service ↗ | None standing; a 3-month free trial includes 20,000 geocode or reverse geocode requests and 10,000 autocomplete requests per month | Yes | Yes | Yes | Restricted6 | Proprietary | No |
| Azure Maps ↗ | 5,000 Search transactions/month (10 autocomplete requests count as 1 transaction) | Yes | Yes | Yes | Restricted7 | Mixed | No |
| Google Geocoding ↗ | 10,000 requests/month (billing must be enabled) | Yes | Yes | Yes | Restricted8 | Proprietary | No |
| Jawg Places ↗ | 5,000 place searches/month, non-commercial use only | Yes | Yes | Yes | Forbidden9 | Mixed | Yes |
| Maptoolkit Geocoding ↗ | 500 API requests/month, shared with Maptoolkit's other APIs (Test & Develop plan, hard limit) | Yes | ? | Yes | Allowed | Mixed | Yes |
1Temporary results may not be cached; storing needs Permanent geocoding, a paid mode with a card on file.
2Permanent storage needs a Standard plan or higher; below that, only client-side caching for as long as the HTTP cache headers allow, or 7 days without one (Terms, section 8).
3Results may not be cached or stored outside the platform for more than 30 days (Platform Terms 6.4(j)); permanent geocoding is sold separately.
4Storing results is contractually prohibited unless requested with forStorage=true, which is billed as stored geocodes.
5Storing results is prohibited; clients may cache them only for the max-age in the response's cache headers (Terms 11.4).
6Results may be stored only when the request sets IntendedUse to Storage, billed at a higher rate (AWS Service Terms 82.4).
7Caching only to reduce latency, for at most 6 months, though geocodes may be kept while the Azure account is active (Product Terms, Azure Maps).
8Latitude and longitude may be cached for up to 30 days; longer caching only per end user, for that user's own feature (Service Specific Terms 6.3).
9The terms (section 5.5) name storing Transactions results as a prohibited way of reducing the fees you owe.
From each provider's pricing, docs, and terms, checked September 2026. "?" means the provider's pages don't say. Prices and terms change, so check them before you commit.
Engines you run yourself
| Name | Cost | Autocomplete | Reverse | Storing results | Data | Open source |
|---|---|---|---|---|---|---|
| Nominatim | Self-hosted: unlimited (the public OSM server allows at most 1 request/second) | No | Yes | Allowed | OpenStreetMap | Yes |
| Photon | Self-hosted: unlimited (public instance photon.komoot.io: fair use, heavy use throttled) | Yes | Yes | Allowed | OpenStreetMap | Yes |
| Pelias | Self-hosted: unlimited | Yes | Yes | Allowed | Mixed | Yes |
From each project's docs and data licenses, checked September 2026.
Know of another geocoding tool?
Suggest search controls, geocoding engines, or hosted geocoding services.









