DEV Community

Cover image for Building a UK-wide fuel price tracker with Next.js and the CMA open data scheme
Wayne Austin
Wayne Austin

Posted on

Building a UK-wide fuel price tracker with Next.js and the CMA open data scheme

Most UK drivers don't know this: since June 2024, every major fuel retailer in the country is legally required to report their pump prices to the government in near-real-time. The Competition and Markets Authority (CMA) built a public API for it. Almost nobody is using it.

I built PetrolFinder.uk to change that — a free tool that pulls live prices from 7,500+ stations and lets anyone find the cheapest fuel near them. This post walks through how it works, the architecture decisions I made, and the gotchas I hit along the way.

The CMA Fuel Finder API

In 2023, the CMA concluded its road fuel market study and found that UK drivers were overpaying by roughly 6p per litre because price information was fragmented and hard to compare. Their solution: mandate that all retailers with 250+ employees report prices to a central government database, and expose that data through a public API.

The API lives at fuel-finder.service.gov.uk and provides two main endpoints:

  • /api/v1/pfs — Station information: names, brands, locations, amenities, opening hours
  • /api/v1/pfs/fuel-prices — Current prices per station per fuel type

Both endpoints are paginated and require an OAuth2 access token. The rate limit is 100 requests per minute — generous enough for a background sync, tight enough that you can't just hammer it from the client side.

// Simplified token + fetch flow
const API_BASE = "https://www.fuel-finder.service.gov.uk";

async function getAccessToken(): Promise<string> {
  const res = await fetch(`${API_BASE}/api/v1/oauth/generate_access_token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      grant_type: "client_credentials",
      client_id: process.env.CMA_CLIENT_ID,
      client_secret: process.env.CMA_CLIENT_SECRET,
    }),
  });
  const data = await res.json();
  return data.access_token;
}

async function fetchStations(token: string, page = 1) {
  const res = await fetch(`${API_BASE}/api/v1/pfs?page=${page}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

The key insight is that the data is complete — this isn't crowdsourced or scraped. Every Shell, BP, Esso, Tesco, Asda, Sainsbury's, and Morrisons forecourt is in there by law. That's a fundamentally different data quality story compared to existing fuel price apps that rely on user submissions.

Architecture: sync everything, serve from your own database

My first instinct was to proxy the API — user searches, we fetch from the government, return results. Bad idea for three reasons:

  1. Rate limits. 100 RPM means a few hundred concurrent users would exhaust your budget instantly.
  2. Latency. The API isn't slow, but it's not optimised for consumer-facing sub-100ms queries.
  3. No geospatial queries. The API returns flat lists. There's no "find stations within 5 miles of this postcode" endpoint.

So I went with a sync-and-serve pattern instead:

CMA API  →  Background sync (every 5 min)  →  Supabase PostgreSQL  →  Next.js app
Enter fullscreen mode Exit fullscreen mode

Every 5 minutes, a background process pulls the full dataset from the government API, merges station info with current prices, and upserts everything into Supabase. User requests never touch the upstream API — they hit Postgres directly.

Why Supabase + PostgreSQL

I needed PostGIS for geospatial queries (finding stations near a lat/lng), and I needed it cheap. Supabase gives you a full Postgres instance with PostGIS enabled out of the box, plus auth and row-level security for the user-facing features I added later.

The core table looks roughly like this:

CREATE TABLE stations (
  id TEXT PRIMARY KEY,           -- CMA node_id
  name TEXT NOT NULL,
  brand TEXT,
  address TEXT,
  postcode TEXT,
  city TEXT,
  location GEOGRAPHY(POINT, 4326),
  amenities TEXT[],
  fuel_types TEXT[],
  is_motorway BOOLEAN DEFAULT FALSE,
  is_supermarket BOOLEAN DEFAULT FALSE,
  updated_at TIMESTAMPTZ
);

CREATE TABLE fuel_prices (
  station_id TEXT REFERENCES stations(id),
  fuel_type TEXT NOT NULL,
  price NUMERIC(5,1) NOT NULL,   -- pence per litre
  last_updated TIMESTAMPTZ,
  PRIMARY KEY (station_id, fuel_type)
);

-- The magic line: a spatial index
CREATE INDEX idx_stations_location ON stations USING GIST (location);
Enter fullscreen mode Exit fullscreen mode

With that index in place, "find the 20 cheapest E10 stations within 10 miles of this point" is a single query that runs in under 50ms:

SELECT s.*, fp.price, fp.fuel_type,
  ST_Distance(s.location, ST_MakePoint($1, $2)::geography) / 1609.34 AS distance_miles
FROM stations s
JOIN fuel_prices fp ON fp.station_id = s.id
WHERE fp.fuel_type = 'E10'
  AND ST_DWithin(s.location, ST_MakePoint($1, $2)::geography, $3 * 1609.34)
ORDER BY fp.price ASC
LIMIT 20;
Enter fullscreen mode Exit fullscreen mode

Handling the sync without blowing the rate limit

The CMA API paginates at ~200 stations per page, so pulling 7,500+ stations takes about 40 pages. Prices are separate — another 40-ish pages. That's 80 requests per sync cycle.

At 100 RPM, you can't fire those in parallel. I added a simple sequential fetcher with a minimum interval between requests:

const MIN_INTERVAL_MS = 700; // ~85 RPM, safely under the 100 RPM limit

async function fetchAllPages(endpoint: string, token: string) {
  const results = [];
  let page = 1;
  let lastRequest = 0;

  while (true) {
    const elapsed = Date.now() - lastRequest;
    if (elapsed < MIN_INTERVAL_MS) {
      await sleep(MIN_INTERVAL_MS - elapsed);
    }

    lastRequest = Date.now();
    const data = await fetchPage(endpoint, token, page);
    results.push(...data.items);

    if (!data.next_page) break;
    page++;
  }

  return results;
}
Enter fullscreen mode Exit fullscreen mode

Boring? Yes. But it hasn't failed once in months of production use. The entire sync — fetch all stations, fetch all prices, merge, upsert — takes about 90 seconds.

The Next.js frontend

The app is built on Next.js 16 with the App Router, deployed on Vercel. A few decisions worth calling out:

Server Components for SEO, client components for interactivity

The homepage renders server-side with live price averages baked into the initial HTML — good for SEO, good for LCP. The interactive map and search are client components that hydrate on top.

page.tsx (Server Component)
├── <h1> + price stats (SSR, visible instantly)
├── <HomeClient /> (Client Component — search, map, results)
└── SEO sections (FAQ, structured data)
Enter fullscreen mode Exit fullscreen mode

revalidate is set to 1800 seconds (30 minutes) for the homepage, so Vercel's edge cache serves most requests without hitting the origin.

Leaflet for mapping

I went with React Leaflet over Google Maps or Mapbox. Free, no API key required for basic tiles, and OpenStreetMap data is excellent for UK coverage. The heatmap layer uses leaflet.heat to visualise price density across regions.

Recharts for price trends

The /trends page shows historical price movements using Recharts. I store daily averages in a daily_averages table that the sync process maintains, so charting a year of price history is just a single indexed query.

Things I'd do differently

Geocoding. I use Postcodes.io (free, UK-specific) to convert postcodes to lat/lng. It's great, but I should have built a local postcode lookup table from the ONS dataset instead of relying on an external service. One more dependency I don't control.

Cache warming. The first request after a Vercel cold start is slow because it has to populate the in-memory cache from Postgres. I should add a cron-triggered cache warmer that hits key routes every few minutes.

Mobile UX. The initial desktop-first layout was wrong for a fuel price app — most people check fuel prices on their phone, in the car. I eventually rebuilt the mobile experience as a full-screen map interface that hides the header and footer, closer to a native app feel. Should have started there.

The CMA scheme is underused

Here's what surprises me most: this data has been publicly available since mid-2024, and almost nobody is building with it. The government maintains the API, the data quality is enforced by law, and it's free to use.

If you're a developer in the UK, this is one of the most interesting open data sources available. Fuel prices touch every driver in the country, the data refreshes constantly, and there are a dozen useful products you could build on top of it — fleet management tools, price alert bots, cost-of-living dashboards, route optimisers.

The code for PetrolFinder isn't open source (yet), but the API is open and everything I've described here is reproducible. If you build something with it, I'd love to see it.


PetrolFinder.uk is live at petrolfinder.uk. It tracks prices from 7,500+ UK fuel stations, updated every 5 minutes, using official government data. Free, no account required.

If you found this useful, the best thing you can do is share it with someone who drives in the UK — the more people comparing prices, the harder it is for retailers to quietly overcharge.

Top comments (0)