In short

Programmatic location pages let you rank across every town you serve, not just the one or two you had time to write by hand. The trick is pairing a template with real per-location data and hard guards that refuse to publish any page that lacks a genuine local fact. Build it that way and you scale to hundreds of local searches without triggering the doorway-page penalty that buries the lazy version. This guide walks through the exact code, step by step.

Most local service businesses rank in exactly the one or two towns where someone bothered to write a page. Every other town they happily serve is invisible on Google. A homeowner three towns over searches for an electrician in their town, never sees you, and books the competitor who did have that page. That is worse than a lost lead: it is a lead you were never in the running for, repeated across every town you could serve but never named.

Writing a real, useful page for every town by hand does not scale. Fifty towns times three services is a hundred and fifty pages. Nobody writes those well. So people cheat: they spin up a hundred pages that are the same paragraph with the town name swapped in. Google has a name for that. It is called a doorway page, and it gets sites penalised or quietly buried. So the lazy version loses money too.

There is a middle path, and it is the one I built on a real site, ampy.se. You generate the pages programmatically from a template plus a data source, but you put hard guards in place so each page is genuinely different and genuinely useful. Do that, and you rank for hundreds of local searches you could never write by hand, you bring in qualified local customers, and you do not trip the penalty that kills the spam version. This is exactly how, step by step, in a way you can copy.

The idea in one picture

A programmatic page has two ingredients: a template and data. The template is the shape (“here is [service] in [town], here is what it costs here, here is who does it, here are the nearby towns”). The data is the rows that fill it in: a list of towns, a list of services, and real facts about each combination.

The whole game is in that word “real”. If the only thing that changes between two pages is the town name, you have made a hundred copies of one page. If real local facts change (prices, response times, a local landmark, a nearby town list, genuine local questions), you have made a hundred different pages. Google can tell the difference, and so can a human.

What you need before you write code

  • A list of locations you actually serve. Not every town in the country. The ones you will genuinely turn up to.
  • A list of services. Keep it short and honest.
  • Per-location data that varies. This is the part people skip. You need at least a few real facts per town: population or size, a couple of nearby towns, maybe a local price band, a landmark or district name.
  • A rule for which combinations get a page. Not every service times every town deserves one.

Modelling the data

I keep locations as a custom post type or a simple data table. A custom post type is the friendliest option in WordPress because your editors can open a town and add real notes to it. Each location carries the facts that make its pages unique.

<?php
// A single location, however you store it. The point is the fields.
$location = array(
    'slug'        => 'harnosand',
    'name'        => 'Härnösand',
    'region'      => 'Västernorrland',
    'population'  => 17348,
    'nearby'      => array( 'sundsvall', 'kramfors', 'timra' ),
    'local_note'  => 'Older housing stock near the town centre often needs the fuse board upgraded before a charger goes in.',
    'services'    => array( 'installation', 'repair', 'ev-charger' ),
);

The local_note field is the most important field on the whole site. It is the human sentence that no template can fake. On ampy.se this is where a real fact about the area lives. You will fill these in over time. A page with a real local note is a real page. A page without one is a candidate for “do not publish yet”.

A page with a real local note is a real page. A page without one is a candidate for not publishing yet.

Services as their own small dataset

Give each service its own key inside the row. You will need that key later to build URLs and to check whether a nearby town offers the same service, so store it once here rather than guessing it downstream.

<?php
$services = array(
    'installation' => array(
        'key'      => 'installation',
        'label'    => 'installation',
        'intro'    => 'New wiring, boards and safety checks done to standard.',
        'faqs'     => array(
            'How long does an installation take?',
            'Do you handle the paperwork and certification?',
        ),
    ),
    'ev-charger' => array(
        'key'      => 'ev-charger',
        'label'    => 'EV charger installation',
        'intro'    => 'Home charge points fitted and tested, load balancing included.',
        'faqs'     => array(
            'Can my board handle a charger?',
            'What speed of charger suits a normal home?',
        ),
    ),
);

Now a page is a pair: one location, one service. The content is built from both, plus the local note, plus a few questions. That is already far more varied than the spam version, and we have not written the generator yet.

Generating the pages

You do not create a hundred and fifty draft posts in the database on day one. That is hard to keep in sync and easy to break. The cleaner pattern is to route a URL to a template and build the page on request, then cache the result. WordPress lets you register a rewrite rule and answer it yourself.

Step one: own the URL

<?php
add_action( 'init', function () {
    add_rewrite_rule(
        '^services/([^/]+)/([^/]+)/?$',
        'index.php?bf_service=$matches[1]&bf_location=$matches[2]',
        'top'
    );
} );

add_filter( 'query_vars', function ( $vars ) {
    $vars[] = 'bf_service';
    $vars[] = 'bf_location';
    return $vars;
} );

That maps /services/ev-charger/harnosand/ to two query variables. Flush your permalinks once after adding this (visit Settings, Permalinks, and save) so the rule takes effect.

Step two: decide if the page is allowed to exist

This is the first guard, and it is the one that keeps you out of trouble. Before you render anything, you check that this service and this location are a real, approved pair. If they are not, you return a genuine 404. You never render an empty shell.

<?php
add_action( 'template_redirect', function () {
    $service_key  = get_query_var( 'bf_service' );
    $location_key = get_query_var( 'bf_location' );

    if ( ! $service_key || ! $location_key ) {
        return; // Not our URL, let WordPress carry on.
    }

    $location = bf_get_location( $location_key );
    $service  = bf_get_service( $service_key );

    // Guard: both must exist, and the location must actually offer it.
    $offered = $location && in_array( $service_key, $location['services'], true );

    if ( ! $service || ! $offered ) {
        global $wp_query;
        $wp_query->set_404();
        status_header( 404 );
        return;
    }

    echo bf_render_service_page( $service, $location );
    exit;
} );

Returning a real 404 for combinations you do not serve is not a small detail. It is the difference between a tidy set of useful pages and a sprawl of empty ones that make Google distrust the whole site. Only publish what is true.

The guards that keep Google happy

This is the part nobody warns you about. Generating pages is easy. Generating pages that do not look like spam is the actual work. Here are the guards I put on every programmatic page, and why each one matters.

Guard one: real unique content per page

Every page must carry at least one sentence that exists nowhere else on the site. That is the local_note. If a location has no note, the page is not ready. I enforce this in code, not in good intentions.

<?php
function bf_page_is_publishable( $location, $service ) {
    // Must have a genuine local sentence.
    if ( empty( $location['local_note'] ) ) {
        return false;
    }
    // Must have real local facts, not just a name.
    if ( empty( $location['population'] ) || empty( $location['nearby'] ) ) {
        return false;
    }
    return true;
}

Pages that fail this check get the 404 treatment until someone fills in the missing facts. It feels strict. That strictness is exactly what protects the pages that do pass.

Guard two: local data, not just a swapped name

When you build the body, weave the real facts in. Population, region, nearby towns, the local note. The town name should appear because the sentence is about the town, not because you find-and-replaced it.

<?php
function bf_render_service_page( $service, $location ) {
    $name   = esc_html( $location['name'] );
    $label  = esc_html( $service['label'] );
    $region = esc_html( $location['region'] );
    $note   = esc_html( $location['local_note'] );

    ob_start();
    ?>
    <article class="service-location">
        <h1><?php echo $label . ' in ' . $name; ?></h1>
        <p>We cover <?php echo $name; ?> and the wider <?php echo $region; ?> area.
           <?php echo esc_html( $service['intro'] ); ?></p>
        <p class="local-note"><?php echo $note; ?></p>
        <?php echo bf_render_faqs( $service, $location ); ?>
        <?php echo bf_render_nearby_links( $location, $service ); ?>
    </article>
    <?php
    return ob_get_clean();
}

Guard three: internal linking that helps people

Each page links to the same service in nearby towns. This does two jobs. It helps a visitor who is on the edge of two towns find the right page, and it gives Google a real map of your coverage so it can crawl and understand all your pages. Isolated pages with no links in look abandoned. Linked pages look like a network.

<?php
function bf_render_nearby_links( $location, $service ) {
    if ( empty( $location['nearby'] ) ) {
        return '';
    }
    $out = '<nav class="nearby"><h2>Nearby towns</h2><ul>';
    foreach ( $location['nearby'] as $near_key ) {
        $near = bf_get_location( $near_key );
        if ( ! $near || ! in_array( $service['key'], $near['services'], true ) ) {
            continue; // Never link to a page that will not exist.
        }
        $url  = home_url( "/services/{$service['key']}/{$near_key}/" );
        $out .= '<li><a href="' . esc_url( $url ) . '">'
              . esc_html( $service['label'] . ' in ' . $near['name'] )
              . '</a></li>';
    }
    return $out . '</ul></nav>';
}

Note the guard inside the loop. You only ever link to a page that will really render, which is why each service row carries its own key. A programmatic site full of internal links to 404s is a slow way to lose Google’s trust.

Pro tip

Run the duplicate fingerprinting job before you submit your sitemap, not after. Finding and unpublishing thin pages early keeps Google from indexing them in the first place, which is far easier than recovering from a crawl that already logged them.

Guard four: no near-duplicate boilerplate

The FAQ block is a trap. If every page shows the same two answers word for word, you have added duplicate content, not unique content. Blend the service questions with the location so the answer references the real place.

<?php
function bf_render_faqs( $service, $location ) {
    $out = '<section class="faqs">';
    foreach ( $service['faqs'] as $question ) {
        $answer = bf_answer_for( $question, $service, $location );
        $out .= '<h3>' . esc_html( $question ) . '</h3>';
        $out .= '<p>' . esc_html( $answer ) . '</p>';
    }
    return $out . '</section>';
}

Inside bf_answer_for, use the location facts. “In a town the size of [name], a home charger install is usually a half-day job.” The answer is templated, but it is anchored to a real number, so it reads as written for that place.

Making it fast, and keeping it consistent

Building a page on every request is fine until you have traffic. The fix is caching, and it needs two properties: it has to be bounded so it cannot eat all your memory, and it has to be safe when two requests hit the same page at once.

A bounded in-memory cache

I keep a small cache of recently built pages in memory, capped at a fixed size. When it is full, the least recently used page falls out. That is an LRU cache. It keeps the hot pages instant without letting the cache grow forever.

<?php
class BF_Page_Cache {
    private $items = array();
    private $limit;

    public function __construct( $limit = 200 ) {
        $this->limit = $limit;
    }

    public function get( $key ) {
        if ( ! isset( $this->items[ $key ] ) ) {
            return null;
        }
        // Touch: move to the end so it counts as recently used.
        $value = $this->items[ $key ];
        unset( $this->items[ $key ] );
        $this->items[ $key ] = $value;
        return $value;
    }

    public function set( $key, $value ) {
        if ( isset( $this->items[ $key ] ) ) {
            unset( $this->items[ $key ] );
        }
        $this->items[ $key ] = $value;
        if ( count( $this->items ) > $this->limit ) {
            array_shift( $this->items ); // Drop the oldest.
        }
    }
}

For pages that survive across requests, back this with a persistent store too, using the WordPress transients API or an object cache. The in-memory layer handles the burst; the persistent layer handles the day.

Atomic ownership so two processes never clobber a page

When you write a generated page to a persistent cache, two visitors can arrive at the same instant and both try to build and save it. Without a lock they can overwrite each other or write a half-built page. The fix is a short-lived lock so only one process owns the build.

<?php
function bf_build_with_lock( $key, callable $build ) {
    $lock = 'bf_lock_' . md5( $key );

    // add() only succeeds if the key does not already exist: an atomic claim.
    if ( ! wp_cache_add( $lock, 1, '', 30 ) ) {
        // Someone else is building it. Serve what we have, or build read-only.
        return get_transient( 'bf_page_' . md5( $key ) ) ?: $build();
    }

    $html = $build();
    set_transient( 'bf_page_' . md5( $key ), $html, HOUR_IN_SECONDS );
    wp_cache_delete( $lock );
    return $html;
}

The trick is wp_cache_add. It only writes if nothing is there, which makes claiming the lock atomic. The lock has a short expiry so a crashed process cannot block the page forever. On a multi-server setup, back this with a shared object cache like Redis so the lock is seen across the whole cluster, not just one machine.

Cleaning up duplicates

Over time, mistakes happen. A town gets added twice under two slugs. Two services drift into saying the same thing. You want a job that finds near-duplicate pages and flags or removes them, because duplicates are the exact thing that drags a programmatic site down.

<?php
function bf_find_duplicate_pages( array $rendered ) {
    $seen  = array();
    $dupes = array();
    foreach ( $rendered as $url => $html ) {
        // Fingerprint the meaningful text, ignoring the town name.
        $body  = wp_strip_all_tags( $html );
        $print = md5( preg_replace( '/s+/', ' ', strtolower( $body ) ) );
        if ( isset( $seen[ $print ] ) ) {
            $dupes[] = array( 'url' => $url, 'same_as' => $seen[ $print ] );
        } else {
            $seen[ $print ] = $url;
        }
    }
    return $dupes;
}

Run this on a schedule. If two pages fingerprint the same, one of them has no real unique content, which means a local note is missing or the template leaked identical text. Fix the data or unpublish the weaker page. This is guard one enforced after the fact, and it is your safety net.

Prove it works

Before you trust the system, check it end to end. Visit a served combination and confirm you get a full page with its own local note. Visit a combination you do not serve and confirm you get a real 404, not a thin shell. View source on two different town pages and confirm the body text genuinely differs beyond the name. Run the duplicate finder and confirm it comes back empty. Submit the set through your sitemap and watch, over the following weeks, which pages Google indexes. The ones that stay indexed are the ones carrying real local facts. That tells you the guards are doing their job.

The payoff, in business terms

Here is what this actually buys you, past the jargon:

  • You show up for local searches in every town you serve, not just the one or two you had time to write by hand. Each of those is a person ready to book, handed to you instead of a competitor.
  • The pages are genuinely useful, so they keep ranking. You avoid the doorway-page penalty that quietly buries lazy sites and wastes the whole effort.
  • The pages load instantly because they are cached, and they never clash or half-build because the ownership is atomic, so you do not lose a visitor to a slow or broken page.
  • The system stays clean on its own, because the duplicate cleaner catches the weak pages before Google does.

You do not need a magic tool or a black-hat trick. You need a template, a data source with real local facts, and a set of guards that refuse to publish anything thin. Build that, and you turn “we only rank in one town” into “we rank across the whole region”, with pages that earn their place instead of gaming for it.