A reverse geocoding API built on an OpenStreetMap database in PostgreSQL/PostGIS. Given a latitude and longitude, it answers with the city, the named place or building at that point, the nearest road, and the state, county and country. A click-to-identify endpoint also returns a building's address and footprint geometry, ready to outline on a map.
The server is a single Express application. It ships with a React dashboard for trying queries in the browser and a token-protected admin panel with live request statistics and a server log.
- City, place, building, road and full-context lookups, over GET or POST
- Batch endpoints for up to 100 points per request
- Building lookups with footprint GeoJSON and composed street addresses
- In-process LRU caching of results, keyed to roughly 11 m
- Admin panel with live requests per second, usage history and a streaming server console
- No external services: everything is answered from your own database
- Node.js 20.12 or newer
- PostgreSQL with the PostGIS and hstore extensions
- An OpenStreetMap extract imported with osm2pgsql
The server reads the planet_osm_point, planet_osm_line and planet_osm_polygon tables produced by osm2pgsql's classic pgsql output (the default output, not the flex output), in the default EPSG:3857 projection. The import must use --hstore, because country codes, state codes and postal codes are read from the tags column.
Download an extract (I used https://download.geofabrik.de/), then:
createdb osm
psql -d osm -c "CREATE EXTENSION postgis; CREATE EXTENSION hstore;"
osm2pgsql --create --slim --hstore -d osm your-region-latest.osm.pbfosm2pgsql creates a spatial index on each table, which is enough to run. On larger imports, these optional partial indexes keep the most frequent queries small:
CREATE INDEX planet_osm_polygon_admin_way_gist ON planet_osm_polygon
USING gist (way) WHERE boundary = 'administrative' AND name IS NOT NULL;
CREATE INDEX planet_osm_polygon_building_way_gist ON planet_osm_polygon
USING gist (way) WHERE building IS NOT NULL;
CREATE INDEX planet_osm_point_place_way_gist ON planet_osm_point
USING gist (way) WHERE place IN ('city', 'town', 'village') AND name IS NOT NULL;
ANALYZE;The server inspects the table columns on first use to find out whether address tags such as addr:housenumber were imported as columns or into the hstore, so both layouts work.
From the repository root:
npm ci
cp .env.example .envEdit .env with your database connection details (see Configuration below), then build the dashboard and start the server:
npm run build
npm startOpen http://localhost:3007.
Building the dashboard is optional. The page is really just for internal testing. Without a build, the server serves public/index.html at / instead: a single-file test page with no build step or dependencies, useful for checking a new setup.s
npm run dev # API server, restarts on changes to server.js
cd frontend && npm run dev # Vite dev server on port 5173, proxies API calls to port 3007If the API runs elsewhere, set API_URL (for example API_URL=http://localhost:4000) before starting the Vite dev server.
Settings are read from environment variables. A .env file next to server.js is loaded automatically; variables already set in the environment take precedence over it.
| Variable | Default | Description |
|---|---|---|
PORT |
3007 |
HTTP port |
HOST |
all interfaces | Interface to bind, for example 127.0.0.1 behind a reverse proxy |
PGHOST |
localhost |
PostgreSQL host |
PGPORT |
5432 |
PostgreSQL port |
PGDATABASE |
osm |
Database name |
PGUSER |
postgres |
Database user |
PGPASSWORD |
none | Database password |
DATABASE_URL |
none | Connection string; overrides the PG* variables when set |
ADMIN_TOKEN |
none | Enables the admin panel. Must be at least 16 characters |
STATS_FILE |
stats.json beside server.js |
Where request counters are persisted |
Generate an admin token with:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"All endpoints return JSON. Coordinates are WGS84 decimal degrees; lat must be between -90 and 90 and lon between -180 and 180. GET endpoints take lat and lon as query parameters; POST endpoints take a JSON body.
| Method | Path | Returns |
|---|---|---|
| GET, POST | /city |
City name |
| GET, POST | /place |
Name of the building, point of interest or area at the point |
| GET, POST | /building |
The building at the point, with address and footprint |
| GET | /road |
Nearest named road |
| GET, POST | /lookup |
City, place, road, state, county and country together |
| POST | /cities/batch |
City for each of up to 100 points |
| POST | /lookup/batch |
Full lookup for each of up to 100 points |
| GET | /health |
Database connectivity check |
curl "http://localhost:3007/lookup?lat=38.981997&lon=-76.937028"Example response:
{
"city": "Cupertino",
"place_name": "Apple Park",
"state": "California",
"state_code": "CA",
"county": "Example County",
"country": "United States",
"country_code": "US",
"road": "Road Drive",
"road_type": "tertiary",
"road_distance_m": 41.7,
"lat": 38.981997,
"lon": -76.937028
}Fields that cannot be resolved are null; city and place_name are "unknown" instead.
This returns { city, lat, lon }. The city is the smallest municipal boundary (OSM admin_level=8) containing the point. Where there is none, the nearest city, town or village point within 50 km is used.
Returns { place_name, lat, lon }, chosen in this order of priority:
- A named building containing the point
- The nearest named building within 200 m
- A named point-of-interest area containing the point (amenity, shop, tourism or leisure; universities, colleges and schools are skipped so the building inside them wins)
- The nearest point of interest within 200 m
- Any named area containing the point
The click-to-identify endpoint. Unlike /place, which always widens its search until it finds a name, this reports what the point actually landed on.
Query parameters (or body fields): lat, lon, and an optional radius in metres (default 25, maximum 250). If the point is not inside a footprint, the nearest named building within radius is returned and marked as nearby. radius=0 turns that off.
| Field | Description |
|---|---|
found |
Whether a building or point-of-interest area was matched |
name |
Its name, if it has one |
kind |
building or poi |
match |
inside or nearby |
inside |
true when the point is inside the footprint |
distance_m |
Distance to the footprint in metres (0 when inside) |
address |
Street line, for example "4131 Campus Drive" |
address_full |
One-line address with city, state and postcode |
address_parts |
{ unit, housenumber, street, city, state, postcode } |
address_source |
footprint when tagged on the building, node when taken from an address point inside it |
postcode |
Postcode from the building, its address point, or the postal boundary |
postcode_tag |
Postcode tagged on the building or its address point only, without the boundary fallback |
building_type |
The OSM building value (or the amenity, shop, tourism or leisure value for an area), when more specific than yes |
geometry |
Footprint as GeoJSON in EPSG:4326 |
geometry_omitted |
true when the footprint was too large (over 400 KB) to include |
radius_m |
The snap radius that was applied |
The response also includes the same city, state, county, country and road fields as /lookup. An address is only reported when it has both a house number and a street, and an address point is only used when it lies inside the matched footprint, so a neighbour's address is never returned.
Returns { road, road_type, road_distance_m, lat, lon } for the nearest road with a name or reference number within 200 m. road_type is the OSM highway value.
curl -X POST http://localhost:3007/lookup/batch \
-H "Content-Type: application/json" \
-d '{"points":[{"lat":38.98,"lon":-76.94},{"lat":39.30,"lon":-76.81}]}'Returns { results: [{ index, lat, lon, ... }] } in request order. If any point is invalid, nothing is looked up and the response is a 400 listing the bad entries by index.
| Status | Body |
|---|---|
| 400 | { "error": "lat must be between -90 and 90" } and similar validation messages, or { "error": "Invalid request body" } for malformed JSON |
| 413 | { "error": "Request body too large" } for bodies over 100 KB |
| 500 | { "error": "Database query failed" }; the underlying error is logged on the server only. Queries are cancelled after 15 seconds |
| 503 | /health only: { "status": "error", "db": "disconnected" } |
Results are cached in memory, keyed to coordinates rounded to four decimal places (about 11 m). /building results are keyed to five decimal places plus the radius, and their cache is also capped at 32 MB of footprint GeoJSON. The cache is cleared on restart.
Set ADMIN_TOKEN and restart the server, then open /#/admin and sign in with the token. While ADMIN_TOKEN is unset, the panel, its statistics socket and the log stream are all disabled.
The panel shows requests per second, a five-minute request graph, totals over periods from one hour to all time, a per-endpoint breakdown, and a live server console. Request counts are saved to STATS_FILE every 30 seconds and on shutdown, and per-minute history is kept for one year. /building requests are counted under place, and a batch request counts once regardless of how many points it contains.
Signing in sets an HttpOnly, SameSite=Strict session cookie that lasts seven days and is marked Secure over HTTPS. Signing out clears the cookie in that browser only; changing ADMIN_TOKEN and restarting revokes every session.
-
TLS and proxying. Put the server behind a reverse proxy such as nginx or Caddy that terminates TLS, and set
HOST=127.0.0.1. The proxy has to pass WebSocket upgrades for/ws/stats, must not buffer/console/stream(in nginx,proxy_buffering off), and should sendX-Forwarded-Protoso the admin cookie is marked Secure. -
Rate limiting. There is none built out-of-the-box. I'll probably add a feature later for this. A single
/lookup/batchrequest can run more than a thousand queries, so limit requests at the proxy, and limitPOST /admin/logintightly as well. -
Read-only database role. The server only reads, so connect with a role that can do nothing else:
CREATE ROLE origin LOGIN PASSWORD 'change-me'; GRANT CONNECT ON DATABASE osm TO origin; GRANT USAGE ON SCHEMA public TO origin; GRANT SELECT ON planet_osm_point, planet_osm_line, planet_osm_polygon TO origin;
-
CORS. The lookup endpoints allow requests from any origin, since the API is meant to be called from other sites. The admin endpoints do not rely on CORS; their session cookie is never sent with cross-site requests.
-
Logging. Every API request is logged to stdout with its path and query string, which for GET lookups includes the coordinates.
- osm2pgsql stores geometry in Web Mercator (EPSG:3857), where one map unit is a true metre only at the equator. The fixed search radii of
/city(50 km),/place(200 m) and/road(200 m), and theroad_distance_mvalue, are in map units, so they shrink in true distance away from the equator; 200 units is about 155 m at latitude 39 degrees./buildingcorrects for this, and itsradiusanddistance_mare true metres. - Results are only as complete as the OSM data you import. Many residential buildings carry no tags at all;
/buildingfinds them by footprint and looks for an address point inside them. - Postal boundaries are sparse outside Europe, so postcodes come from address tags first.
server.js Entry point: middleware, routing and startup
lib/
config.js Settings from the environment and .env
db.js PostgreSQL pool and the shared query helper
routes.js API endpoints and input validation
geocode.js City, place, state, county, country, road and postcode lookups
building.js Building identification, footprints and address resolution
address.js Address parsing and formatting
osm-schema.js Detects where the osm2pgsql import stored address tags
lru-cache.js Result cache
stats.js Request counters and their persistence
admin-auth.js Admin token login and session cookies
stats-socket.js Live statistics WebSocket for the admin panel
console-stream.js Server log stream for the admin panel
public/index.html Single-file test page, served at / when there is no dashboard build
frontend/ React and Vite dashboard: live tester, endpoint list, admin panel
.env.example Configuration template
The code is released under the MIT License; see LICENSE.
Map data is copyright OpenStreetMap contributors and available under the Open Database License (ODbL). If you serve results publicly, attribute OpenStreetMap as described at https://www.openstreetmap.org/copyright. The bundled dashboard and test page already show this credit.