Skip to content

Guide

How to build a geocoder (and why a single static file works)

How a geocoder turns text into coordinates, from tokenising and inverted indexes to ranking, autocomplete prefixes and spatial indexes, with a runnable toy geocoder in JavaScript and how geolith packs it all into one .glx file read with HTTP range requests.

By · 18 min · updated

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 size places, 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. layers and boundary.country are 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:

  1. Read the header (cached after the first request).
  2. Read the posting lists for the query words.
  3. 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.

Questions

Quick answers

Do I need Elasticsearch to build a geocoder?
No. A geocoder is an inverted index plus ranking plus a spatial index. Elasticsearch is one way to host an inverted index; a static file read with range requests is another.
How big is a planet geocoding file?
geolith's planet .glx file measured on 22 Oct 2026 is 31.63 GB with 678,043,458 places, built in 5 h 28 min on one Mac.
Why does a static file make a good geocoder?
There is nothing to keep running except a small reader. You deploy by copying a file, roll back by pointing at the previous file, and each query reads a bounded number of bytes.

Geocode your first address in a minute.

100,000 requests a month free. Commercial use with attribution. No card.