A geocoder turns text such as "MG Road, Bengaluru" into coordinates, and coordinates back into an address. Under the hood it is three things: an inverted index from words to places, a ranking function that picks the best match, and a spatial index for reverse lookups. This guide builds a toy geocoder in about 60 lines of JavaScript, then explains how geolith packs a production version, 678,043,458 places for the planet, into one static .glx file that a server reads with HTTP range requests.
1. Normalise and tokenise
Every name and address is cut into comparable words. Lower-case it, strip accents, drop punctuation, split on spaces.
export function tokens(text) {
return text
.normalize('NFKD')
.replace(/\p{M}/gu, '') // strip accents: "São" -> "sao"
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter(Boolean);
}
Real geocoders add more: abbreviations ("rd" → "road"), numbers, and names in other scripts. geolith indexes names in several languages so मुंबई and 東京タワー find their places.
2. A toy geocoder you can run
Save as toy-geocoder.mjs and run node toy-geocoder.mjs.
function tokens(text) {
return text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase()
.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
}
const places = [
{ id: 1, name: 'Gateway of India', city: 'Mumbai', importance: 0.42, lat: 18.92196, lon: 72.83456 },
{ id: 2, name: 'Mumbai', city: 'Mumbai', importance: 0.9, lat: 19.07283, lon: 72.88261 },
{ id: 3, name: 'India Gate', city: 'New Delhi', importance: 0.5, lat: 28.61294, lon: 77.22950 },
{ id: 4, name: 'MG Road', city: 'Bengaluru', importance: 0.3, lat: 12.97560, lon: 77.60500 },
{ id: 5, name: 'Bandra Kurla Complex', city: 'Mumbai', importance: 0.35, lat: 19.06560, lon: 72.86560 },
];
// Inverted index: word -> set of place ids (a posting list).
const index = new Map();
const docLength = new Map();
for (const p of places) {
const words = tokens(`${p.name} ${p.city}`);
docLength.set(p.id, words.length);
for (const w of words) {
if (!index.has(w)) index.set(w, new Set());
index.get(w).add(p.id);
}
}
const avgLen = [...docLength.values()].reduce((a, b) => a + b, 0) / places.length;
// Autocomplete: the last word may be a prefix.
function postings(word, isPrefix) {
if (!isPrefix) return index.get(word) ?? new Set();
const out = new Set();
for (const [w, ids] of index) if (w.startsWith(word)) ids.forEach((id) => out.add(id));
return out;
}
// BM25 score of one place for the query words.
function bm25(id, words, k1 = 1.2, b = 0.75) {
let score = 0;
for (const w of words) {
const df = index.get(w)?.size ?? 0;
if (!df || !index.get(w).has(id)) continue;
const idf = Math.log(1 + (places.length - df + 0.5) / (df + 0.5));
score += idf * ((k1 + 1) / (1 + k1 * (1 - b + (b * docLength.get(id)) / avgLen)));
}
return score;
}
export function search(text, { autocomplete = false, size = 3 } = {}) {
const words = tokens(text);
if (words.length === 0) return [];
const lists = words.map((w, i) => postings(w, autocomplete && i === words.length - 1));
// Every word required: intersect the posting lists, smallest first.
lists.sort((a, b) => a.size - b.size);
const hits = [...lists[0]].filter((id) => lists.every((l) => l.has(id)));
return hits
.map((id) => places.find((p) => p.id === id))
.map((p) => ({ ...p, score: bm25(p.id, words) + p.importance * 2 }))
.sort((a, b) => b.score - a.score)
.slice(0, size);
}
console.log(search('gateway india'));
console.log(search('bandra ku', { autocomplete: true }));
search('gateway india') returns the Gateway of India first. search('bandra ku', { autocomplete: true }) finds Bandra Kurla Complex because the last word is treated as a prefix.
3. Ranking in production
geolith ranks with BM25 + importance × weight, the same idea as the toy above, plus:
- Relaxation. Every word is required first. When that finds fewer than
sizeplaces, about three quarters of the words are required, and very common words do not decide a relaxed match. - Autocomplete prefixes. Prefixes of 4 to 6 characters have their own posting lists; 2- and 3-character prefixes have a short list of the most important places, so the first keystrokes stay fast.
- Filters in the index.
layersandboundary.countryare checked in the posting lists, before any place record is read. - Focus point. With
focus.point.lat/lon, it searches boxes of ±0.05°, ±0.25° and ±0.5° around the point and adds a distance boost of up to +5 that halves every 10 km.
4. Reverse geocoding needs a spatial index
To find the nearest place to a point you cannot scan every place. geolith sorts places along a Hilbert curve (nearby points end up near each other in the file), groups them into blocks, and stores each block's bounding box. A reverse query reads only the blocks whose boxes are near the point, nearest first.
5. Why one static file works
Classic geocoders keep the index in a running database or search cluster. geolith writes the posting lists, the place records, the outlines and the spatial blocks into one file with a small header that says where each part lives. A query then needs a handful of reads:
- Read the header (cached after the first request).
- Read the posting lists for the query words.
- Read the records for the top candidates.
Each of those is an ordinary HTTP range request, which every object store and CDN supports:
# Read 64 KB from the middle of a file on any server that supports ranges
curl -s -r 1048576-1114111 https://example.com/india.glx -o block.bin
So the same file can sit on a laptop disk, in S3 or R2, or behind a CDN. geolith caps the posting bytes, record bytes and read waves of every query, which keeps the cost per query bounded. That is how geolith cloud runs on Cloudflare's edge: a Worker answers the API and range-reads the .glx file from R2.
Numbers from the real thing
- Planet file, 22 Oct 2026: 31.63 GB, 678,043,458 places, built in 5 h 28 min on one Mac.
- 21 world test queries (London, Eiffel Tower, Tokyo Station, 東京タワー, मुंबई, Statue of Liberty, Sydney Opera House…): 21/21 correct, slowest 110 ms on a warm local server.
- India benchmark against Nominatim, Photon and Pelias on the same data: see the comparison pages.
If you would rather call it than build it: GET https://api.geolith.app/v1/search?text=… with a free key.