Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 5 min read

I Built a Cheapest Fuel Station Map from Spanish Open Data

Spain publishes the price of every public fuel station as open data. The feed is refreshed every 30 minutes, contains roughly 11,500 stations and needs no API key. That sounded like a good small project: download the dat

Spain publishes the price of every public fuel station as open data. The feed is refreshed every 30 minutes, contains roughly 11,500 stations and needs no API key. That sounded like a good small project: download the data, find the cheapest station near a location, and put the result on a map that works on a phone.

The result is FuelSpot, a mobile site for comparing stations by fuel and distance. I built it in one day on top of my own small data-web engine. The interesting part was not drawing markers. It was making a public government feed predictable enough to use.

The source is simple, with a few sharp edges

The source is the Spanish Ministry REST API:

GET https://sedeaplicaciones.minetur.gob.es/ServiciosRESTCarburantes/PreciosCarburantes/EstacionesTerrestres/

There is no API key. The response is one JSON document of about 12 MB. When I tested it, it contained 11,486 public stations. Each record includes a station id, brand (the RΓ³tulo field), address, postal code, municipality, province, latitude and longitude, opening-hours text, sale type and around 25 fuel-price fields.

The first trap was content negotiation. Without Accept: application/json, the same URL returns XML. curl happened to give me JSON during an initial test, but my HTTP client received XML and failed with Unexpected token '<'. The request now states what it accepts explicitly:

const response = await fetch(url, {
  headers: { Accept: "application/json" }
});
const stations = await response.json();

That small header saved more debugging time than any map library feature.

The second trap was the shape of the values. Coordinates are strings using decimal commas, such as "39,211417", rather than decimal points. Prices are strings too, and an empty string means that the station does not sell that fuel. Some field names contain spaces, dots or parentheses, including C.P. and Longitud (WGS84). I normalise those fields at the ingestion boundary instead of allowing their original spelling to leak throughout the application.

function numberFromApi(value) {
  if (value === "" || value == null) return null;
  return Number(String(value).replace(",", "."));
}

I also had to decide how names should appear to people. Province values include CORUΓ‘A (A), PALMAS (LAS), VALENCIA / VALÈNCIA and ARABA/ÁLAVA. I map those to human labels and clean slugs such as a-coruna, las-palmas, valencia and alava. Municipalities have a similar issue: Realejos (Los) is better displayed as Los Realejos.

These are not cosmetic details when the site has URLs, breadcrumbs, filters and titles. A name that is technically copied from the source can still be a poor label or an unstable slug.

Finding the cheapest station near a point

The β€œnear me” query runs on the server. The client sends a centre, a fuel and a radius of 5, 10 or 25 kilometres. I first use a latitude/longitude bounding box to remove stations that cannot be close. Then I calculate the haversine distance for the remaining candidates, discard anything outside the selected radius, sort by the selected fuel price and return only the top 20 rows.

The bounding box is only a prefilter. It is fast, but it is not a distance calculation, especially as longitude degrees cover different distances at different latitudes. The haversine result is what controls the radius and the displayed distance.

For an example query centred on LeΓ³n, using unleaded 95 and a 10-kilometre radius, the feed produced 48 stations. The cheapest was 1.469 €/l at 1.8 km, while the average was 1.617 €/l. The JSON response for the result was about 5.6 KB and took about 0.24 seconds.

Returning a small ranked list rather than sending the complete national dataset to the browser matters on a phone. It also keeps the map focused: the user asked for nearby options, not every station in Spain.

Adding a year of history

The API has a second endpoint for a past day:

.../EstacionesTerrestresHist/{dd-mm-yyyy}

It returns the same kind of payload. The endpoint rejects today’s date with HTTP 400 and a message saying that the requested date cannot be later than yesterday. I used that rule when building the backfill rather than treating the error as a transient failure.

I downloaded one file per day for 364 days, cached the responses and waited one second between requests. Each file was about 12 MB. I kept only daily averages per fuel, both nationally and by province. The raw cache filled my /tmp quota halfway, which was a useful reminder that β€œa small file” multiplied by hundreds of days is not small storage.

The historical result is deliberately modest. It is a set of daily aggregates, not a claim that every station reported a price every day. For unleaded 95, the average across public stations was 1.479 €/l on 13 November 2025 and 1.647 €/l on 11 November 2026, about 11% higher in a year. On 12 November 2026 it was 1.636 €/l.

The important design decision was storage: keep the aggregates needed for charts and comparisons, and discard the bulky raw responses after processing. If I need a raw payload again, I can fetch it from the source instead of making the application carry a year of copies.

Making the map readable

The map uses MapLibre GL JS with OpenFreeMap vector tiles, so it does not need an API key. My first implementation created one HTML price pill per marker. At country zoom, the province-level pills simply piled on top of one another.

I changed the data to a GeoJSON source and rendered labels with a symbol layer. MapLibre’s collision detection hides labels that cannot fit, and symbol-sort-key is set to price so the cheaper labels get priority. A small circle layer sits underneath, which means a hidden label does not make the station disappear completely. For areas with hundreds of points, I use clustering.

map.addSource("stations", {
  type: "geojson",
  data: stationGeojson,
  cluster: true
});

map.addLayer({
  id: "prices",
  type: "symbol",
  source: "stations",
  layout: {
    "text-field": ["get", "price"],
    "symbol-sort-key": ["get", "price"]
  }
});

I also fixed the initial map bounds. Including the Canary Islands in the first extent can make the peninsula look tiny, so the initial view uses fixed bounds. Base-map labels follow the page language, and attribution stays compact but visible.

Pages generated from the same data

The site has one page per province, roughly 1,070 municipality pages and roughly 11,400 station pages. Station pages carry JSON-LD for GasStation, province and municipality pages an ItemList, all of them a BreadcrumbList, a real lastmod, and an IndexNow ping when its content changes. Pages without enough useful content are noindexed rather than published just to increase the URL count.

That last rule matters for programmatic sites. A template filled with a name and an empty price is not automatically useful. The page needs a reason to exist: a station’s current details, a municipality’s available options, or a province-level view that can be maintained from the data.

What I’d do differently

I would check content negotiation before inspecting the parser. The XML response looked like a broken JSON feed, but the request was simply underspecified.

I would also design the storage around aggregates from the beginning. Keeping every historical 12 MB response was convenient during development and wasteful in the cache.

The project made me curious about the equivalent feeds elsewhere. Does your country publish fuel prices as open data, and is the feed usable enough to build something on top of it?

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.