Geo SEO Locator
Wordpress plugin to add geo seo variables in URLS
by Axel SAGLIER · github.com/axsag/geo-seo-locator · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/axsag/geo-seo-locator/archive/refs/heads/main.zipA WordPress plugin that injects a {zip-city} segment into any URL and exposes it through shortcodes and Yoast SEO variables — letting you serve thousands of geo-targeted page variants without duplicating content.
https://your.site/13100-aix-en-provence/contact/
https://your.site/06000-nice/contact/
https://your.site/75001-paris-1er/contact/
All three URLs serve the same page. The geo segment is captured as a query variable and made available via shortcodes, Yoast %% tokens, and nav menu rewriting.
Features
- Clean geo URLs — rewrite rules for pages, paginated pages, and category archives
- 10 shortcodes — city name, ZIP, description, neighbourhood list, location links, conditionals, SEO title builder
- Yoast SEO variables —
%%geo_city%%,%%geo_zip%%,%%geo_zip_city%%,%%geo_description%% - Geo-sticky nav menus — menu links automatically carry the geo prefix (PHP only, no JS)
- Default location — fallback city when no geo segment is present or an unknown slug is visited
- Per-city content — display name, free-text description, neighbourhood list per location
- Admin UI — dedicated top-level menu with list view, add/edit form, and shortcode reference
Installation
- Download the latest release zip.
- In WordPress: Plugins → Add New → Upload Plugin → select the zip → Install → Activate.
- Go to Geo SEO → All Locations and add your first city.
- Add shortcodes to your page content.
After activation, if geo URLs return 404 go to Settings → Permalinks and click Save Changes to flush rewrite rules.
URL Structure
| URL | Resolves to |
|---|---|
/{zip-city}/ |
Front page / homepage |
/{zip-city}/{page-slug}/ |
Any static page |
/{zip-city}/{page-slug}/page/{n}/ |
Paginated static page |
/{zip-city}/{category-base}/{slug}/ |
Category archive |
The {zip-city} segment must match: 4–5 digits + dash + lowercase slug
e.g. 13100-aix-en-provence, 06000-nice, 75001-paris-1er
Shortcodes
[geo_city]
City name extracted from the URL, title-cased.
Plombier à [geo_city fallback="votre ville"]
→ Plombier à Aix-En-Provence
| Attribute | Default | Description |
|---|---|---|
fallback |
(empty) | Text shown when no geo context |
format |
title |
title | upper | lower |
[geo_zip]
Postal / ZIP code.
Intervention en [geo_zip] et ses alentours
→ Intervention en 13100 et ses alentours
| Attribute | Default | Description |
|---|---|---|
fallback |
(empty) | Text shown when no geo context |
[geo_zip_city]
ZIP + separator + City in one shot.
Zone couverte : [geo_zip_city sep=" · "]
→ Zone couverte : 13100 · Aix-En-Provence
| Attribute | Default | Description |
|---|---|---|
sep |
– |
Separator between ZIP and city |
fallback |
(empty) | Text shown when no geo context |
format |
title |
City name format: title | upper | lower |
[geo_description]
Free-text block entered per city in Geo SEO → Edit Location. Basic HTML allowed.
[geo_description tag="p" class="city-intro"]
→ <p class="city-intro">Nice est une ville sur la Côte d'Azur…</p>
| Attribute | Default | Description |
|---|---|---|
tag |
div |
Wrapper element: div | p | span | section | none |
class |
gsl-description |
CSS class on the wrapper |
fallback |
(empty) | Text shown when no description is set |
Use tag="none" to output the raw HTML without any wrapper element.
[geo_city_link]
Anchor tag linking to the current geo URL.
[geo_city_link class="btn-zone"]
→ <a href="/13100-aix-en-provence/" class="btn-zone">Aix-En-Provence</a>
| Attribute | Default | Description |
|---|---|---|
text |
(city name) | Link label |
class |
(empty) | CSS class on the <a> |
fallback |
(empty) | Output when no geo context |
[geo_quartiers]
Neighbourhood list configured per city in the admin.
[geo_quartiers tag="ul" class="quartiers-list"]
→ <ul class="quartiers-list"><li>Mazarin</li><li>Sextius</li>…</ul>
[geo_quartiers tag="p" separator=" · "]
→ <p>Mazarin · Sextius · Encagnane</p>
[geo_quartiers tag="div" separator="\n"]
→ <div>Mazarin<br>Sextius<br>Encagnane</div>
| Attribute | Default | Description |
|---|---|---|
tag |
ul |
ul | ol | div | p |
class |
gsl-quartiers |
CSS class on the wrapper |
separator |
(empty) | Separator between items. Empty = <li> items for ul/ol, , for div/p. Use \n for <br>. |
fallback |
(empty) | Text shown when no quartiers defined |
[geo_locations_list]
One link per registered location, pointing to the geo variant of the current (or any named) page. The current city is excluded by default.
[geo_locations_list page="contact" link_class="location-link"]
→ <ul class="gsl-locations-list">
<li><a href="/06000-nice/contact/" class="location-link">Nice</a></li>
<li><a href="/06270-villeneuve-loubet/contact/" class="location-link">Villeneuve-Loubet</a></li>
</ul>
[geo_locations_list include_self="yes"]
→ includes current city with active_class applied
| Attribute | Default | Description |
|---|---|---|
page |
(current page slug) | Target page slug |
tag |
ul |
ul | ol | div | nav |
class |
gsl-locations-list |
CSS class on the wrapper |
link_class |
location-link |
CSS class on every <a> |
active_class |
location-link--active |
Extra class on the current city's link (only with include_self="yes") |
include_self |
no |
Set to yes to include the current city in the list |
fallback |
(empty) | Text shown when no locations are configured |
[geo_if]…[/geo_if]
Renders content only when a geo segment is active.
[geo_if]
<p>Disponible à [geo_city] — appelez le 04 XX XX XX XX.</p>
[/geo_if]
[geo_if zip_city="13100-aix-en-provence"]
<p>Nous sommes basés à Aix — intervention en 5 min.</p>
[/geo_if]
| Attribute | Default | Description |
|---|---|---|
zip_city |
(empty) | If set, only renders for this exact slug |
[geo_unless]…[/geo_unless]
Opposite of [geo_if] — renders only when no geo segment is active.
[geo_unless]
<p>Contactez-nous pour connaître nos zones d'intervention.</p>
[/geo_unless]
[geo_meta_title]
Formatted SEO string using {city}, {zip}, {zip_city} as placeholders.
[geo_meta_title template="Plombier à {city} – Devis gratuit {zip}"]
→ Plombier à Aix-En-Provence – Devis gratuit 13100
| Attribute | Default | Description |
|---|---|---|
template |
{city} ({zip}) |
Template string with {city}, {zip}, {zip_city} |
fallback |
(empty) | Output when no geo context |
Yoast SEO Variables
Four %% tokens are available directly in the Yoast SEO Title and Meta Description fields:
| Token | Output |
|---|---|
%%geo_city%% |
Aix-En-Provence |
%%geo_zip%% |
13100 |
%%geo_zip_city%% |
13100 – Aix-En-Provence |
%%geo_description%% |
City description, HTML stripped (safe for meta) |
They appear in the Yoast autocomplete dropdown under the + button, labelled Geo SEO: ….
Example Yoast setup:
SEO Title:
Plombier à %%geo_city%% (%%geo_zip%%) - %%sitename%%
Meta Description:
%%geo_description%% Devis gratuit, intervention rapide à %%geo_city%%.
Note: The Yoast live preview in the editor will show these tokens as empty — because they only resolve at request time when an actual geo URL is served. The rendered
<title>and<meta name="description">in the HTML source will be correct.
Default Location
In Geo SEO → All Locations, use the Default location selector to designate a fallback city. When set:
- Pages served without a geo segment (plain
/contact/) will use the default city's data in all shortcodes and Yoast tokens. - Visitors who land on an unrecognised geo slug (e.g. a mis-typed URL) will see the default city's content instead of a 404.
The default location is marked with a ★ star in the locations table.
Geo-Sticky Navigation
When a visitor is on a geo URL, nav menu links are automatically rewritten server-side to carry the geo prefix. No JavaScript is involved.
| Original link | Rewritten to (on /06000-nice/…) |
|---|---|
/contact/ |
/06000-nice/contact/ |
/services/urgence/ |
/06000-nice/services/urgence/ |
https://other.com |
unchanged |
For links outside the nav menu (breadcrumbs, content buttons, footers), use [geo_city_link] or [geo_locations_list] to generate correctly prefixed URLs in your templates.
SEO Best Practices
Canonical tags
The plugin automatically outputs <link rel="canonical"> pointing to the geo URL itself, signalling to Google that each geo URL is intentional — not a duplicate.
If you use Yoast SEO or Rank Math, their canonical output fires after priority 1. The plugin fires at priority 1 to ensure its canonical takes precedence. Alternatively, disable the SEO plugin's canonical for geo pages.
Geo meta tags
Automatically output on every geo page:
<meta name="geo.placename" content="Aix-En-Provence" />
<meta name="geo.region" content="FR-13100" />
Sitemap
Geo URLs are not added to the default sitemap automatically. Options:
Yoast SEO:
add_filter( 'wpseo_sitemap_entry', function( $url, $type, $object ) {
// append your geo URL variants here
return $url;
}, 10, 3 );
Manual: create a dedicated geo-sitemap.xml and submit it in Google Search Console.
Internal linking
Create a "Zones d'intervention" page that links to every /{zip-city}/ variant — this is the primary signal for Google to discover and crawl them.
Avoiding thin content
Use [geo_if], [geo_description], and [geo_quartiers] to add meaningful, unique content to each geo variant. Pages that are 100% identical except for the URL will not rank well.
FAQ
Do I need a separate page for each city?
No. One page → unlimited geo variants. The geo segment is a query variable; shortcodes render city-specific content dynamically.
What if someone visits an unknown slug?
If a default location is configured, it's used. Otherwise, if you have any locations registered, unknown slugs return 404. If no locations are registered, any well-formed slug is accepted.
Does it work with Elementor / Divi / Bricks?
Yes — any builder that supports WordPress shortcodes can render these.
Does it work with WooCommerce?
The rewrite rules work for standard pages and category archives. WooCommerce shop/product URLs have their own rewrite stack and may need additional custom rules.
Will geo URLs be indexed by Google?
Yes, provided you: (1) have internal links pointing to each geo URL, (2) have submitted a sitemap containing them, and (3) are not blocking them in robots.txt.
Requirements
- WordPress 5.8+
- PHP 7.4+
- Yoast SEO (optional, for
%%geo_*%%variables)
Changelog
1.3.0
- Fixed Yoast SEO variable replacement: added
wpseo_replacementsfilter (layer 2) so%%geo_city%%and%%geo_zip%%actually resolve on the front-end.wpseo_register_extra_replacementsnow handles UI registration only, using__return_empty_stringas a placeholder callback. - Fixed version mismatch between plugin header and
GSL_VERSIONconstant. - Added GitHub-flavored
README.md.
1.2.0
- New shortcode
[geo_description]— per-city free-text block with HTML support. - New
include_selfattribute on[geo_locations_list](default: excluded). [geo_quartiers]separator now works on all tag types;separator="\n"renders<br>.- Yoast SEO
%%geo_city%%,%%geo_zip%%,%%geo_zip_city%%,%%geo_description%%variables. - Default location setting — fallback city for pages without a geo segment.
1.1.0
- Dedicated top-level admin menu (Geo SEO) with All Locations, Add/Edit, and Shortcode Reference sub-pages.
- New shortcode
[geo_locations_list]. - Geo-sticky navigation is PHP-only (
wp_nav_menu_objects); JS layer removed. - Geo meta tags (
geo.placename,geo.region) on all geo pages. - Fixed location list showing 0 entries (removed conflicting
register_settingsanitize callback).
1.0.0
- Initial release.
- Rewrite rules for pages, paginated pages, and category archives.
- Shortcodes:
geo_city,geo_zip,geo_zip_city,geo_city_link,geo_quartiers,geo_if,geo_unless,geo_meta_title. - Admin settings page.
- Canonical tag output.
- Allow-list validation (404 for unknown slugs when list non-empty).
License
GPL v2 or later — see LICENSE.