TechEarl

Build Service-Area Landing Page URLs in WordPress (/service/city/)

How to build /service/city/ landing page URLs in WordPress with add_rewrite_rule and a custom template, plus the honest E-E-A-T warning about when this crosses into doorway pages Google penalizes.

Ishan Karunaratne⏱️ 11 min readUpdated
Share thisCopied
Build service-area landing page URLs like /plumbing/chicago/ in WordPress using add_rewrite_rule, registered query vars, and a custom template, without crossing into the thin doorway pages Google penalizes.

To serve a URL like /plumbing/chicago/ from WordPress, you register a rewrite rule that captures the service and city slugs into query vars, register those vars so WordPress keeps them, then swap in a custom template that builds the page from your service data plus your city data. Here is the whole spine in a must-use plugin:

php
<?php
/**
 * Plugin Name: TE Service Area Pages
 * Plugin URI:  https://techearl.com/wordpress-service-area-landing-page-urls
 * Description: Routes /service/city/ URLs to a custom template built from service and city data.
 * Version:     1.0.0
 * Author:      Ishan Karunaratne
 * Author URI:  https://techearl.com
 * License:     GPL-2.0-or-later
 * Text Domain: te-service-area
 */

add_action( 'init', 'te_service_area_rewrite' );
function te_service_area_rewrite() {
    add_rewrite_rule(
        '^([^/]+)/([^/]+)/?$',
        'index.php?te_service=$matches[1]&te_city=$matches[2]',
        'top'
    );
}

add_filter( 'query_vars', 'te_service_area_query_vars' );
function te_service_area_query_vars( $vars ) {
    $vars[] = 'te_service';
    $vars[] = 'te_city';
    return $vars;
}

Two custom query vars (te_service, te_city), one rewrite rule that maps two path segments onto them, and query_vars so WordPress does not strip them on the way through WP::parse_request(). The te_ prefix keeps these from colliding with core query vars and with anything a plugin might register.

Route the matched request to a template

A rewrite rule only rewrites the URL into query vars. It does not decide what renders. To actually draw the page, hook template_include, confirm both vars are present and resolve to real data, and return your own template file:

php
add_filter( 'template_include', 'te_service_area_template' );
function te_service_area_template( $template ) {
    $service_slug = get_query_var( 'te_service' );
    $city_slug    = get_query_var( 'te_city' );

    if ( '' === $service_slug || '' === $city_slug ) {
        return $template;
    }

    $service = te_get_service( $service_slug );
    $city    = te_get_city( $city_slug );

    // Unknown service or city: fall through to a real 404, do not invent a page.
    if ( ! $service || ! $city ) {
        return $template;
    }

    $custom = locate_template( 'service-area.php' );
    return $custom ? $custom : $template;
}

The guard clauses matter more than they look. If either slug is missing or does not resolve to something you actually have content for, you return the original template and let WordPress 404. That single check is the difference between a finite set of pages you control and an infinite URL space where /anything/anywhere/ returns a soft 200. The latter is exactly the pattern crawlers flag as auto-generated.

Your service-area.php template composes the page from the two records:

php
<?php
get_header();

$service = te_get_service( get_query_var( 'te_service' ) );
$city    = te_get_city( get_query_var( 'te_city' ) );
?>
<main class="service-area">
    <h1><?php echo esc_html( sprintf(
        /* translators: 1: service name, 2: city name */
        __( '%1$s in %2$s', 'te-service-area' ),
        $service['name'],
        $city['name']
    ) ); ?></h1>

    <?php
    // Page-specific body. NOT a template with the city name find-replaced in.
    echo wp_kses_post( te_render_service_area_body( $service, $city ) );
    ?>
</main>
<?php
get_footer();

The data model

You need two lookups: services and cities. Keep them as real, editable records, not arrays hard-coded in the template, so a human can write genuine copy per page.

A clean shape is a service custom post type for the services (each with its own editable content), and a cities list as either a second CPT or a structured option. Resolve by slug:

php
function te_get_service( $slug ) {
    $query = new WP_Query( array(
        'post_type'      => 'service',
        'name'           => sanitize_title( $slug ),
        'post_status'    => 'publish',
        'posts_per_page' => 1,
        'no_found_rows'  => true,
    ) );

    if ( ! $query->have_posts() ) {
        return false;
    }

    $post = $query->posts[0];
    return array(
        'id'   => $post->ID,
        'name' => get_the_title( $post ),
        'body' => $post->post_content,
    );
}

function te_get_city( $slug ) {
    $cities = get_option( 'te_service_area_cities', array() );
    $slug   = sanitize_title( $slug );
    return isset( $cities[ $slug ] ) ? $cities[ $slug ] : false;
}

Both functions return false for an unknown slug, which is what the template_include guard relies on to 404. Storing cities in an option (a keyed array of slug => array( 'name' => ..., 'lat' => ..., ... )) is fine for a fixed service area of a few dozen towns. If editors need to write paragraphs of local copy per city, make cities a CPT too so each gets a real editor.

If you want each combination to behave like a normal post for WP_Query, sitemaps, and SEO plugins instead of a hand-rolled template_include route, use pre_get_posts to point the main query at the right service post and inject the city as context. For a small set of static landing pages the template_include approach is simpler; reach for pre_get_posts only when you genuinely need the main query to be the real thing.

Read this before you generate a single page: doorway pages

This is the part most "programmatic local SEO" tutorials skip, and it is the part that decides whether the technique helps you or sinks the whole site.

Google's spam policies name two things you can trip here. Doorway pages are pages "created to rank for similar search queries" where the differences are cosmetic, funneling users to the same destination. Scaled content abuse is generating many pages "primarily to manipulate search rankings and not help users," which now explicitly covers programmatic generation. A folder of /plumbing/{city}/ pages where every page is the same 300 words with the city name swapped in is the textbook example of both. Google can and does demote or deindex sites for it, and the March 2024 core and spam updates sharpened the scaled-content rule specifically to catch this pattern at scale.

The mechanism above makes it trivial to mint a thousand pages from twelve services and eighty cities. That is precisely why the honest answer to "should I build these?" is usually "only if you can make each one genuinely worth landing on." If you cannot, do not build them. A handful of strong pages beats a thousand thin ones, and the thousand thin ones can drag the strong pages down with them.

What makes a service-area page legitimate

The test is simple: would this page still be useful if you deleted the templated scaffolding and the matched keyword? If the only thing distinguishing /plumbing/chicago/ from /plumbing/denver/ is the two place names, it is a doorway. Make each page carry real, page-specific value:

  • Genuine local specifics. The actual neighborhoods or suburbs you cover, local permit or code quirks (Chicago's plumbing code is famously its own thing), response times and travel for that area, real pricing differences. Facts a competitor in the next town cannot copy.
  • Distinct copy, written or meaningfully reviewed by a human. Not the same paragraph with a token swapped. The CPT-per-service, option-or-CPT-per-city model exists so a person can actually write this.
  • Real proof for that location. Reviews, completed jobs, photos, and case studies from that city, not stock imagery and a generic testimonial slider.
  • A real reason the page exists. You actually serve that city. Inventing pages for cities you do not operate in is the clearest doorway signal there is.
  • Indexability that matches reality. Only let pages you have filled out get indexed. A barely-started /service/city/ should noindex until it has real content, so half-built pages do not bloat the index.

If you have eight services and you genuinely operate in fifteen towns, that is 120 pages, and 120 substantial, locally-specific pages is a perfectly legitimate site. The technique is fine. The abuse is using it to fake coverage and depth you do not have.

When not to do this at all

Skip it outright if any of these are true: you serve one metro and the "cities" are just neighborhoods that would each get a near-identical page; you cannot commit to writing real per-page content; you would be listing cities you do not actually service; or you are tempted because it is fast to generate a lot of URLs. "Fast to generate a lot of URLs" is the warning sign, not the feature. For a single-location business, one strong location page plus a clear service list will out-rank a pile of thin permutations and carries none of the penalty risk.

Slug collisions and flushing rewrite rules

The catch-all rule ^([^/]+)/([^/]+)/?$ is greedy: it matches any two-segment path, so it can swallow URLs that were meant for real pages, categories, or other post types. Two ways to keep it in its lane:

  • Make it more specific. If services live under a known prefix, write the rule as ^services/([^/]+)/([^/]+)/?$ so only /services/plumbing/chicago/ matches and the rest of the site is untouched.
  • Validate before claiming the request. The template_include guard already does this: if te_get_service() or te_get_city() returns false, you hand the template back and WordPress routes normally. That keeps the rule from breaking unrelated two-segment URLs even with the broad pattern.

Either way, rewrite rules are cached. A new rule does nothing until the rules are flushed. Do not call flush_rewrite_rules() on init: it is expensive and running it on every request is a real performance problem. Flush once on activation:

php
register_activation_hook( __FILE__, 'te_service_area_activate' );
function te_service_area_activate() {
    te_service_area_rewrite();   // register the rule first
    flush_rewrite_rules();       // then flush, once
}

During development, the no-code equivalent is visiting Settings > Permalinks and clicking Save, which flushes the rules without changing anything. If a brand-new /service/city/ URL 404s right after you add the rule, an unflushed rewrite cache is almost always why.

Verify it works

With the plugin active and the rules flushed, test the routing from the command line before you trust the browser:

bash
curl -sI https://example.com/plumbing/chicago/ | head -n 1
# expect: HTTP/2 200  (a service + city you actually have data for)

curl -sI https://example.com/plumbing/atlantis/ | head -n 1
# expect: HTTP/2 404  (unknown city must NOT return 200)

The second check is the important one. A real 404 on a slug you have no content for is what proves your guard clauses work and your URL space is finite. If /plumbing/atlantis/ returns 200, your rule is matching everything and you are one crawl away from an index full of empty permutations. Fix the guard or tighten the rule before you let Google anywhere near it.

See also

Sources

Authoritative references this article was fact-checked against.

TagsWordPressPHPLocal SEORewrite RulesProgrammatic SEO

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts