MapJSON: Building an API for Map Data

July 7, 2026

Map built with MapJSON

I have built RESTful APIs in the past, and while building some recent maps with D3.js I realized that it would be useful to have a simple API for geographic data. A single endpoint that returns map data in standard formats, with only the details needed — including the properties to visualize data on the map. This is how mapjson.com was born, and I think there are a few interesting things worth mentioning in a blog post.

API Design

The core question was how to express the full range of what a map might need in a URL. You might want all world countries at low resolution, or just European countries at medium resolution with names attached, or US state boundaries at high resolution. Those are very different requests, but they should feel like variations of one thing — not separate endpoints. I landed on a single endpoint with composable query parameters:

GET /v1/geo
  ?layer=countries|regions|rivers|lakes
  &filter=europe|north-america|US|FR|...
  &detail=low|medium|high
  &properties=name,iso2,capital,...
  &format=topojson|geojson

Layers answer what type of geographical features you want and at what granularity (countries, regions, districts, etc.). Filter reduces the output to only the data you need (countries in Europe, states in the USA, etc.). Detail refers to feature detail and roughly maps to Natural Earth's 110m, 50m, and 10m datasets, but I also leveraged DIVA-GIS for the higher level of detail needed for very small regions. Properties attaches attributes to the geometry, like country names and a unique identifier for each region. The format is TopoJSON by default but also available as GeoJSON.

All the geometric work is done offline in a pipeline that downloads shapefiles and converts them to TopoJSON and GeoJSON using mapshaper. World topology does not change often, so there's no reason to recompute geometry on every request.

What the API Worker Does

The Worker's job is deliberately narrow:

  1. Parse and validate the query parameters
  2. Fetch the right pre-built TopoJSON from R2 (e.g. countries/medium.topojson)
  3. Filter features by region if requested
  4. If properties were requested, look up the metadata for each feature from a cached properties table and merge it onto the response
  5. Normalize the output (the TopoJSON object key is always geo, regardless of what mapshaper named it internally)
  6. Return with CORS headers and appropriate caching

The Worker is just parse, fetch, filter, merge, normalize, and serve.

More on Properties

Country metadata (names, ISO codes, capitals, coordinates) lives in a separate properties.json file in R2. The Worker loads this once per instance and keeps it in memory — a simple object keyed by gid. When a request asks for properties=name,iso2, the Worker looks up each feature's gid in the table and attaches the requested fields.

This separation between geometry and attributes is what makes the API composable. The same pre-built topology file serves every request; only the metadata merge changes.

The Data

Natural Earth is the obvious source for geographic data at this scale — it's well-maintained, freely licensed, and covers the world at three resolutions. But it makes some choices that don't work well for an API that's supposed to give each politically recognized territory its own polygon. Its most detailed resolution also doesn't cover about 60 regions, which I generated from DIVA-GIS instead.

County and district data was also pulled from Natural Earth, but it only contains districts for the United States. That level of data for other countries can be tricky due to licensing — often commercial usage is prohibited and attribution is required. I need to think about this a bit more before expanding. But since we have counties for the USA, I wanted to also include postal codes, and that was easy via the Census Bureau's ZCTA (ZIP Code Tabulation Areas), which are public domain — free, no license, no attribution required.

The sovereignty bundling problem

Natural Earth's admin_0_countries dataset is organized around sovereignty. France is one feature — a single MultiPolygon that includes metropolitan France, French Guiana, Martinique, Guadeloupe, Réunion, Mayotte, and all other French overseas departments. This is technically correct: French overseas departments are legally French territory. But it means that hovering over the coast of South America near French Guiana returns "France," which is confusing at best.

The principle I wanted to uphold: if a territory has its own ISO 3166-1 alpha-2 code, it should be its own polygon. French Guiana has ISO code GF. Aruba has AW. The Caribbean Netherlands (Bonaire and its sister islands) has BQ. These should all be separate, hoverable, filterable features.

The admin_0_map_units dataset

Natural Earth provides an alternative dataset — admin_0_map_units — which splits countries into their constituent geographic units. France becomes six separate features. The Netherlands splits out Aruba, Curaçao, Sint Maarten, and the Caribbean Netherlands. This is closer to what I needed, but it has two problems.

First, it doesn't just split overseas territories — it also splits the UK into England, Scotland, Wales, and Northern Ireland (no ISO codes), and Belgium into its three regions. Those are regressions I didn't want. Second, the ISO codes for French territories in the map_units dataset are ISO 3166-2 subdivision codes (FR-973 for French Guiana) rather than ISO 3166-1 alpha-2 codes (GF). Those needed patching.

Surgical replacement

The solution was to stay with admin_0_countries as the base dataset but surgically replace specific parent countries with their map_units sub-features. The pipeline excludes France from the main countries pass and instead pulls its six map_units features — France proper, French Guiana, Guadeloupe, Martinique, Réunion, Mayotte — patching the ISO codes in the process. The same approach handles Norway (adding Svalbard and Jan Mayen as a separate feature), the Netherlands (splitting out the Caribbean Netherlands), New Zealand (splitting out Tokelau), and the Indian Ocean Territories grouping (splitting it into Christmas Island and the Cocos Islands).

Other Data Fixes

A few other things needed patching:

ISO codes for France and Norway. Both countries have ISO_A2 = '-99' in Natural Earth's main countries file (because the file represents the whole French Republic including overseas territories, which has a different ISO numeric code than metropolitan France). The pipeline patches these explicitly.

Country names. Natural Earth uses abbreviated names for display — "W. Sahara" instead of "Western Sahara," "Dem. Rep. Congo" instead of "Democratic Republic of Congo." I maintain a small name patch table to normalize these to the names Google Maps uses, which are familiar to most people.

Disputed territories. Natural Earth marks some features with TYPE = 'Disputed' or TYPE = 'Indeterminate' — Kosovo, Western Sahara, Palestine, and others. An earlier version of the API excluded these by default, which created literal holes in the map. The current version always includes them; a disputed: true property on the feature lets clients style them differently if they want. Two calls I had to make: Natural Earth assigns Crimea to Russia, which reflects the current situation on the ground, and I've kept the source data as-is for now — though I'm open to revisiting that. And the Gaza Strip is currently part of Israel's polygon rather than split out on its own; I'm still deciding whether to keep it as is, cut it into its own feature, or merge it with the West Bank.

The Kazakhstan and Cuba problem. Natural Earth classifies Kazakhstan and Cuba with TYPE = 'Sovereignty' in some versions of the dataset. An early exclusion rule for TYPE = 'Sovereignty' accidentally removed both countries from the output. Removing that rule entirely fixed it — the only TYPE value we exclude now is 'Lease' (a tiny number of leased territories that don't need their own polygons).

Morocco vs Western Sahara. Natural Earth uses the Western Sahara Berm (the Moroccan Wall) to separate Morocco from Western Sahara. According to the UN (and Google Maps), the border runs east to west. I've kept the Berm but revised the border to match the UN.

ISO codes for disputed territories. Changed Taiwan from CN-TW to TW, as this is the primary ISO 3166-1 code. Kosovo got XK — the user-assigned code adopted by the European Commission and SWIFT — with XKX as the three-letter analog. I used the same X-prefix convention for other disputed regions like Somaliland and Northern Cyprus.

Examples

The best way to get a feel for the API is the examples page — a collection of D3.js maps, each built against the live API, each with its source shown.

The starting point is the minimal call: GET /v1/geo with no parameters returns 110m world countries as geometry-only TopoJSON. From there, the homepage world map adds properties=name for hover tooltips, an Equal Earth projection, and zoom. Same endpoint, one parameter more.

A few examples exist specifically to show off the data work described above. The Caribbean map is the sovereignty-splitting payoff: Aruba, Martinique, Guadeloupe, and Bonaire all render as their own polygons with their own ISO codes, and hover correctly. Aruba on its own demonstrates the small-territory problem from the other side — the island is 180 km² and invisible at standard resolutions, so when the filter is a single country code and detail=high, the API automatically serves a high-resolution file for small territories. And the disputed territories map shows Kosovo, Western Sahara, Palestine, and others rendered and styled separately via the disputed flag — no holes in the map.

Others show composition. The physical features map layers three independent API calls — countries, rivers, lakes — into one richer map. The Africa map colors countries by subregion and plots capitals from capitalLat and capitalLng properties, with a force simulation keeping labels from overlapping.

The examples I find most practically useful are the data joins. Massachusetts counties fetches just 14 features with filter=US-MA and joins population data via the 5-digit FIPS gid — the same code used by Census, BLS, and CDC datasets. US counties takes that further, joining CDC PLACES depression-prevalence data onto every continental county through a client-side API call. And the Massachusetts ZIP areas map does the same with the postal layer, where each feature's gid is simply the bare ZIP code — joining your ZIP-keyed data is a dictionary lookup.

One last example worth calling out: world capitals rendered in Node.js. Because the API returns plain TopoJSON, nothing about it requires a browser — the example shows a complete server-side script producing the same SVG with no DOM and no headless Chrome.

What's Next

There's a whole layer of this project I haven't covered here: how features get their stable, unique identifiers, and how the API resolves the messy geographic keys found in real-world datasets — country names in a dozen spellings, mixed code systems, ambiguous abbreviations. That ontology work deserves its own post. The same goes for the interactive tools growing around the API — Clean, Explore, and Build.

For now, the API is live at mapjson.com. If you build something with it, I'd love to see it.