Generating local service pages at scale with accurate geodata

Build accurate local landing pages using geocoding, boundary lookups, and static maps. Real service-area taxonomy from the API — no thin content.

| August 17, 2026
Generating local service pages at scale with accurate geodata

Every multi-location business eventually faces the same brief: "We need a landing page for every area we serve." A national plumbing franchise with 120 locations. A home-care chain covering 40 cities. A dental group spread across three states. The instinct is to write a script that loops through a list of town names, stamp each one into a template, and ship it. That instinct is how you end up with two hundred thin-content pages that rank for nothing, confuse customers about whether you actually serve them, and eventually attract a manual penalty.

The correct approach takes only a little more work and produces pages that are genuinely different from one another — because they are rooted in real geodata. Each page reflects a real business location, a real administrative area that location belongs to, and a real service boundary that a prospective customer can see on a map. The geodata is the content. The template is just the container.

This post walks through building that system end to end. The surfaces it uses are: forward geocoding for your business locations, Boundaries/Divisions endpoints for the administrative-area taxonomy, static maps with labelled pins for per-page visuals, and optionally isolines for drive-time-based service areas. The CMS and the templating layer are yours — this post does not prescribe them. What it does prescribe is how to get the data right, because accuracy is the only thing that separates a useful local page from spam.

Why thin-content local pages fail — and what replaces them

The thin-content pattern looks like this: someone exports a spreadsheet of 400 US towns within 50 miles of any location, runs a find-and-replace on a master template, and publishes 400 pages with identical body copy, a different town name in the H1, and nothing else differentiating them.

What is wrong with it, practically?

The service claim is often false. If your nearest technician is 65 miles from a town in the spreadsheet, you do not serve that town. A page claiming otherwise destroys conversion rate — visitors from that town bounce immediately because the page cannot tell them anything real about when someone will arrive or what it costs to get there.

There is nothing for a search engine to reward. Two hundred pages with the same structured data, the same body text, the same schema, and only the town name swapped produce no signal that differentiates one from another. Consolidation or de-indexing follows.

The customer journey is broken. A user who finds "HVAC repair in Millbrook" via search and lands on a page that has no map, no specific service radius, and no honest delivery time has no reason to convert over a competitor whose page answers those questions.

The replacement pattern: generate one page per administrative area where you have a real, geocoded business location (or a real, drive-time-verified service reach). Populate that page with the area's real name from your Boundaries/Divisions lookup, a static map that shows the actual location pinned inside the actual boundary, and service metadata that is true — hours, response radius, contact. The pages are shorter to write but longer in actual utility.

The data model behind a real local page

Before touching the API, nail down the data model. A local service page that is worth publishing has at minimum five fields that no template can invent:

  1. Business location lat/lng — the geocoded coordinates of the actual office, depot, or clinic that serves the area.
  2. Administrative area name and level — the real canonical name for the area (county, district, borough, postcode sector), derived from the Boundaries/Divisions lookup against that location's coordinates, not from a spreadsheet someone compiled by hand.
  3. Parent area name — the one step up the hierarchy (city → county → state), again from the API, so the breadcrumb and the page schema are accurate.
  4. Service boundary geometry — either the administrative area polygon from the Boundaries endpoint, or a drive-time isoline if your service area does not follow administrative lines.
  5. Static map image URL — a rendered image showing the pin and boundary, hosted as a stable URL, cached for the page's lifetime.

Everything else — the body copy, the CTAs, the testimonials, the pricing — is template content layered on top of these five data points. The five data points are what makes one page differ from another in a way that is meaningful rather than superficial.

The three API surfaces

Geocoding your locations

The first step is converting your location list — a spreadsheet of addresses — into verified lat/lng pairs. One GET /api/v1/geocode call per address returns coordinates plus a confidence score. The confidence score matters: a score below 0.7 means the geocoder was uncertain and you should flag that row for manual review before relying on the coordinates to position a boundary lookup or a static map pin.

curl -G "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=742 Evergreen Terrace, Springfield, IL" \
  --data-urlencode "api_key=$CSV2GEO_KEY"

The response gives you lat, lng, confidence, and structured address fields. Pull every row of your location list through this call first, persist the coordinates, and treat low-confidence rows as a separate queue that a human addresses before that location gets a page.

Full rationale on what confidence scores mean in production is at Geocoding Confidence Scores Explained.

Boundaries and Divisions for the area taxonomy

Once you have coordinates, a Boundaries/Divisions lookup tells you exactly which administrative areas contain that point — county, municipality, postcode, and the hierarchy above and below each. This is the call that replaces the hand-maintained spreadsheet of town names.

curl -G "https://csv2geo.com/api/v1/boundaries" \
  --data-urlencode "lat=39.7392" \
  --data-urlencode "lng=-104.9903" \
  --data-urlencode "api_key=$CSV2GEO_KEY"

The response returns a list of boundary objects. Each has a name, a level (country, state/province, county, municipality, postcode, etc.), an id, and optionally the parent/child relationships in the hierarchy. The fields you extract for your page data model are:

  • name at the level you are generating pages for — say, county or municipality.
  • parent.name one level up — for the breadcrumb and schema areaServed field.
  • geometry if you want to render the actual boundary on the static map (pass it to the static-map endpoint as a polygon overlay).

The ancestors endpoint variant lets you walk up the hierarchy from a given boundary ID, which is how you build a canonically correct breadcrumb trail (City → County → State → Country) rather than guessing. The children endpoint variant lets you enumerate sub-areas — if you want to generate pages at postcode-sector level inside a county, query children of the county boundary and iterate.

Static maps with labelled pins

Each page needs an image that shows the visitor where you are and what area you cover. The Static Maps endpoint produces one:

curl -s -o "map_denver_north.png" \
  "https://csv2geo.com/api/v1/staticmap?lat=39.7392&lng=-104.9903\
&width=800&height=400\
&pin_label=Denver+North+Branch\
&polygon_id=BOUNDARY_ID\
&api_key=$CSV2GEO_KEY"

Pass the lat/lng of the business location for the pin, the polygon_id from the Boundaries lookup for the area outline, and optionally a pin_label that matches the page's H1. The response is a raw image binary — pipe it to S3, your CDN, or a local file. The URL you serve in the page's <img> tag is your own CDN URL, not a call-time API URL.

One image per page. Static. Cached permanently (or until a location changes). The map is not decorative — it is the clearest answer to the visitor's question "do you actually cover where I live?"

Building the pipeline in Python

A complete working script that geocodes a location list, looks up boundaries, and writes a JSON data file per location — ready to feed a static site generator or a CMS import.

import csv
import json
import os
import time
import requests

API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_KEY"]

def geocode(address):
    r = requests.get(f"{API}/geocode",
                     params={"q": address, "api_key": KEY},
                     timeout=30)
    r.raise_for_status()
    results = r.json().get("results", [])
    if not results:
        return None
    return results[0]  # {lat, lng, confidence, ...}

def get_boundaries(lat, lng):
    r = requests.get(f"{API}/boundaries",
                     params={"lat": lat, "lng": lng, "api_key": KEY},
                     timeout=30)
    r.raise_for_status()
    return r.json().get("boundaries", [])

def fetch_static_map(lat, lng, boundary_id, label, out_path):
    params = {
        "lat": lat, "lng": lng,
        "width": 800, "height": 400,
        "pin_label": label,
        "polygon_id": boundary_id,
        "api_key": KEY,
    }
    r = requests.get(f"{API}/staticmap", params=params, timeout=30)
    if r.status_code == 400:
        return None
    r.raise_for_status()
    with open(out_path, "wb") as f:
        f.write(r.content)
    return out_path

def pick_boundary(boundaries, preferred_level="municipality"):
    for b in boundaries:
        if b.get("level") == preferred_level:
            return b
    # fall back to county if municipality not present
    for b in boundaries:
        if b.get("level") == "county":
            return b
    return boundaries[0] if boundaries else None

output = []

with open("locations.csv") as fh:
    reader = csv.DictReader(fh)
    for row in reader:
        address = row["address"]
        location_id = row["id"]

        geo = geocode(address)
        if not geo or geo.get("confidence", 0) < 0.7:
            print(f"SKIP (low confidence): {address}")
            continue

        lat, lng = geo["lat"], geo["lng"]
        boundaries = get_boundaries(lat, lng)
        boundary = pick_boundary(boundaries)

        if not boundary:
            print(f"SKIP (no boundary): {address}")
            continue

        map_path = f"maps/{location_id}.png"
        fetch_static_map(lat, lng, boundary["id"],
                         boundary["name"], map_path)

        output.append({
            "id": location_id,
            "address": address,
            "lat": lat,
            "lng": lng,
            "confidence": geo["confidence"],
            "area_name": boundary["name"],
            "area_level": boundary["level"],
            "area_id": boundary["id"],
            "parent_name": boundary.get("parent", {}).get("name"),
            "map_image": map_path,
        })

        time.sleep(0.05)  # polite pacing; remove if on a high-rate plan

with open("page_data.json", "w") as fh:
    json.dump(output, fh, indent=2)

print(f"Generated data for {len(output)} locations.")

The same pipeline in Node:

import fs from 'node:fs/promises';
import { createWriteStream } from 'node:fs';

const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_KEY;

async function geocode(address) {
  const url = `${API}/geocode?q=${encodeURIComponent(address)}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`geocode http ${r.status}`);
  const data = await r.json();
  return data.results?.[0] ?? null;
}

async function getBoundaries(lat, lng) {
  const url = `${API}/boundaries?lat=${lat}&lng=${lng}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`boundaries http ${r.status}`);
  const data = await r.json();
  return data.boundaries ?? [];
}

async function fetchStaticMap(lat, lng, boundaryId, label, outPath) {
  const params = new URLSearchParams({
    lat, lng, width: 800, height: 400,
    pin_label: label, polygon_id: boundaryId, api_key: KEY,
  });
  const r = await fetch(`${API}/staticmap?${params}`);
  if (r.status === 400) return null;
  if (!r.ok) throw new Error(`staticmap http ${r.status}`);
  const buf = Buffer.from(await r.arrayBuffer());
  await fs.writeFile(outPath, buf);
  return outPath;
}

Both scripts produce the same output shape. Feed it to your CMS import script, your static site generator's data layer, or your Next.js getStaticPaths call.

How to generate the pages

Step 1: Audit and deduplicate your location list

Before any API calls, clean the input. Franchises accumulate duplicate addresses (two CSV rows for the same depot), ambiguous addresses ("Main Street" with no postcode), and closed locations that were never removed. Run a simple normalisation pass — lowercase, strip punctuation, deduplicate on normalised string — before geocoding. Every row that passes through the geocoder costs one credit; rows that represent closed or duplicate locations are wasted spend and produce pages that confuse customers.

Output: a clean CSV with columns id, address, location_name, active. Only active = true rows proceed.

Step 2: Geocode each location and flag low-confidence results

Run every active row through /api/v1/geocode. Write back lat, lng, and confidence to the CSV. Anything below confidence 0.7 lands in a separate review queue — a spreadsheet or a Jira ticket, whatever your team uses. Do not generate a page for a location whose coordinates you are not confident in. A misplaced pin is worse than no pin.

For a list of 120 franchise locations, this step runs in under a minute and costs 120 credits — well within the free tier's 3,000 calls/day allowance for a first run.

Step 3: Derive the administrative area taxonomy for each location

For every geocoded location, call /api/v1/boundaries with the confirmed lat/lng. Extract the boundary at your chosen level (municipality, county, postcode — pick the one that matches how your business actually defines a service area). Persist the area_name, area_level, area_id, and parent.name.

Two practical decisions to make here:

What level do you generate pages at? For a plumbing franchise with 120 locations covering a single metro, municipality-level pages (one per borough or suburb) are usually right. For a national chain, county-level is often more honest — you may genuinely cover the whole county from one depot. Pick the level that matches the actual service truth, then stick to it for the whole site. Mixed levels (some pages are cities, some are counties) create a confusing URL structure and difficult internal linking.

What if two locations share the same administrative area? Decide now. Either produce one page for the area (referencing both locations) or produce two pages (one per location, distinguished by the sub-area name). Two pages for the same area with identical content is thin content; one page that honestly covers both locations is not.

Step 4: Fetch and store static map images

For each location, call the static-map endpoint, save the PNG or WebP to your asset store (S3, Cloudinary, or equivalent), and record the stable CDN URL in your page data. The map image is the hardest thing to fake in a template — it requires a real coordinate and a real boundary — and it is therefore the clearest signal to a visitor that your page is about a real place.

Image parameters worth tuning:

  • `size` / `width` / `height`: 800 × 400 is a reasonable default for a desktop-width inline map. Generate a 400 × 300 version for mobile if you are serving separate images.
  • `pin_label`: set this to the branch or location name, not the area name. The area name is the boundary polygon; the label is the pin. "North Glasgow Branch" on a pin inside the Glasgow North boundary is clear. "Glasgow" on a pin inside the Glasgow boundary is redundant.
  • `polygon_id`: the boundary ID from Step 3. The API renders the polygon outline on the map. If your service area is drive-time-based (see below) rather than boundary-based, pass the isoline geometry instead.

Store the image binary. Do not hotlink the API URL in your HTML — the API is a data source, not a CDN. The CDN URL you generate is stable; the API URL is rate-limited and not designed for direct browser calls.

Step 5: Optionally replace administrative boundaries with drive-time isolines

Some businesses do not follow administrative lines at all. A plumber in a rural area may cover a 45-minute drive radius that slices across four county lines. A clinic might limit bookings to a 30-minute isochrone regardless of postcodes.

For those cases, replace the Boundaries polygon with an isoline. Call /api/v1/isoline with the business location, a travel mode (driving), and a time or distance limit. The response is a GeoJSON polygon. Pass that polygon to the static-map endpoint as a geojson_overlay parameter instead of a polygon_id. The page's body copy says "we cover everywhere within a 45-minute drive" and the map shows exactly what that means for a customer standing at any point in the area.

The text on the page must match the isoline. If you use an administrative boundary on the map but write "we serve a 30-mile radius" in the copy, you have produced a contradiction that loses customer trust at exactly the moment you are trying to build it.

Step 6: Assemble and publish the page

With the five data-model fields populated — geocoded lat/lng, area name from Boundaries, parent name from the ancestor lookup, boundary or isoline geometry for the map, and the static map image URL — the template has real content to render. A minimum viable local page includes:

  • H1: [Service] in [area_name] — canonical, from the API.
  • An introductory sentence that names the specific branch serving the area — from your location_name field, not invented.
  • The static map image.
  • Structured data (LocalBusiness schema) with areaServed set to the area's canonical name, the branch address, and the branch's real telephone.
  • A breadcrumb (parent_namearea_name) that matches the URL structure.

What is absent from this list is also deliberate: there is no "area description" paragraph generated by interpolating the town name into a generic template string. That paragraph is either thin content (if it says nothing specific) or a liability (if it makes service claims that are not true). Write one real paragraph per area only when you can fill it with something true — the average response time from that specific branch to that specific area, a real landmark the branch is near, something.

Keeping the data fresh

Pages generated from live API data go stale in two situations: a location moves or closes, and an administrative boundary changes.

Location changes are your responsibility — build a webhook or a nightly reconciliation job that flags whenever your CRM marks a location as closed or moved. Those events trigger a re-geocode and, if the boundary changes, a new static map pull and a page update (or de-index if the location is closed).

Administrative boundary changes are less common but do happen — local government reorganisations, postcode reassignments, district boundary reviews. If you are generating a large number of pages and want protection, pull the Boundaries API data for each location on a quarterly schedule and compare area_id against the stored value. A changed area_id for the same coordinates means a boundary change; regenerate the affected pages.

Neither refresh cycle is expensive. A quarterly reconciliation of 500 locations costs 500 boundary API calls — within the free tier if spread across two days, or a few dollars at paid rates.

Observability: know when your pipeline breaks

A local-page pipeline that runs silently and produces bad data is worse than a pipeline that fails loudly. Instrument three things:

Geocoding confidence distribution. Track the histogram of confidence scores across your location list. A sudden drop (e.g. the median falls from 0.9 to 0.6) means something changed in your input data quality — a CRM export that started dropping postcodes, or a data import that scrambled address formats.

Missing boundary rate. If the Boundaries call returns empty for more than 2% of your geocoded locations, your coordinates are probably landing in the ocean or in gaps in the coverage data. Log the lat/lng of every empty-boundary response so you can eyeball them.

Static map error rate. A spike in 400 responses from the static-map endpoint usually means boundary IDs from a stale lookup no longer match the live data. Your reconciliation job should pre-empt this; the error rate is the signal that it is not running.

For a broader treatment of instrumenting geocoding pipelines, Observability for Geocoding Pipelines covers the metrics that matter in production.

Cost model for a real franchise rollout

A concrete example. A home-services franchise with 200 US locations generating municipality-level pages — roughly 400 pages, assuming an average of two municipalities per location's service area.

One-time setup:

  • 200 geocoding calls: 200 credits
  • 400 boundary lookups: 400 credits
  • 400 static-map images: 400 credits
  • Total: 1,000 credits

The free tier allows 3,000 calls/day. The entire initial dataset generation runs free. For organisations on the entry paid tier ($54/month for 100,000 calls), the ongoing quarterly reconciliation of 200 locations — 200 geocodes plus 200 boundary checks — costs 400 credits, well within any monthly allowance.

The static-map images, once stored on your CDN, cost nothing to serve from that point forward. There are no per-display image licensing fees, no resizing royalties, no "you served this image 10,000 times this month" surprise invoice.

See the live pricing at csv2geo.com/pricing/api.

The ethical line

There is a pattern this post does not endorse: generating pages for areas you do not actually serve, on the theory that a page might capture traffic even if no one can be dispatched to answer the enquiry. That pattern destroys conversion rate, poisons your brand's local reputation, and wastes both your money and your customer's time.

The correct rule is simple: generate one page per area where a real person can be dispatched within your advertised response time. If the Boundaries lookup returns a municipality 80 miles from your nearest location, do not generate that page. If drive-time isoline analysis shows that a particular postcode is technically within a 45-minute radius but only because of a motorway that a van cannot actually reach at peak hours, do not generate that page.

Every piece of geodata this post describes is a mechanism for enforcing accuracy, not for inflating coverage. Use it that way.

Frequently Asked Questions

How many locations do I need before this pipeline is worth building? Somewhere around 20 to 30 locations with distinct service areas, the manual alternative (hand-writing or individually editing each page) becomes slower than building the pipeline once. Below that, a hand-maintained page per location is often fine. Above 50, the pipeline is clearly the right investment — the data consistency alone (every page using the canonical area name from the same source) is worth it.

Can I generate pages at postcode level rather than municipality level? Yes. The Boundaries/Divisions endpoint returns whichever administrative levels exist for a given coordinate, including postcodes and postcode sectors. The same pipeline works — just change the preferred_level filter to match the postcode field. Be honest about whether you actually serve at that granularity; postcode-level pages are appropriate if you can make real per-postcode service promises.

What if two of my locations cover the same administrative area? Decide on one of two strategies: a single page per area (referencing both locations, with combined service details), or two pages differentiated by a sub-area level (e.g., "North District" vs "South District" within the same municipality). Two separate pages for the same area with nearly identical content is thin content regardless of how the geodata is labelled.

Do the static maps work for areas outside the United States? The static-maps endpoint is not restricted to the US — it renders map tiles for any coordinate globally. The US restriction applies to the aerial-imagery endpoint (which is a separate product). Boundary data covers 63 countries. Check csv2geo.com/pricing/api for the current country list.

How should I handle locations that serve a drive-time radius rather than an administrative area? Use the isolines endpoint to generate a drive-time polygon, store the GeoJSON, and pass it to the static-map endpoint as a geometry overlay rather than a polygon_id. Your page copy should explicitly describe the service as a drive-time radius — "we cover everywhere within 40 minutes of our Northgate depot" — and the map should match.

Will generating hundreds of these pages improve rankings? That is not a promise this post makes, and you should be suspicious of any vendor who makes it. Pages built on accurate geodata and honest service claims are pages that do real work for the visitor. That is the precondition for any ranking outcome. Pages that pass the accuracy test but have no unique value for the visitor still rank for nothing. Start with accuracy; add real content; measure traffic against conversion, not just impressions.

Can I use the same API key for geocoding, boundaries, and static maps? Yes. One API key covers all 56 endpoints. There is no separate key, plan, or approval process per endpoint type.

Related Articles

---

*I.A. / CSV2GEO Creator*

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →