To add a custom sitemap to Yoast SEO, you do two things: append a <sitemap> entry to Yoast's sitemap index with the wpseo_sitemap_index filter, and register a named sitemap whose callback emits the <url> entries. Drop this into a must-use plugin:
add_action( 'init', function () {
global $wpseo_sitemaps;
if ( isset( $wpseo_sitemaps ) && ! empty( $wpseo_sitemaps ) ) {
$wpseo_sitemaps->register_sitemap( 'locations', 'te_locations_sitemap' );
}
} );
add_filter( 'wpseo_sitemap_index', 'te_add_locations_to_index' );That registers a sitemap reachable at /locations-sitemap.xml and tells Yoast's index to list it. The two callbacks (te_add_locations_to_index and te_locations_sitemap) are below. The whole point: the URLs you list there can be anything, including pages WordPress does not know exist.
Why rewrite-driven URLs are missing from the default sitemap
Yoast builds its sitemaps from what the database knows about: posts, pages, custom post types, taxonomy terms, author archives. It queries those tables, chunks them into post-sitemap.xml, page-sitemap.xml, category-sitemap.xml, and so on, and stitches them together in sitemap_index.xml.
That model breaks the moment a URL has no row behind it. If you run a directory of /california/los-angeles/ location pages off a rewrite rule, those URLs are entirely virtual: a add_rewrite_rule() maps the path to a query var, a template renders the page from a custom table or an API, and there is no wp_posts row anywhere. (I walk through that setup in building rewrite-driven state and city URLs in WordPress.) Yoast has nothing to query, so those pages never appear in any default sitemap, and Google has no list of them to crawl.
This is the exact gap the custom-sitemap API exists to close. You enumerate the virtual URLs yourself, hand them to Yoast as a named sitemap, and add that sitemap to the index so Search Console discovers it alongside the post and page sitemaps.
Step 1: append your sitemap to the index
The wpseo_sitemap_index filter passes you the string of extra <sitemap> entries (initially empty) and expects you to return it with yours concatenated on. You are not editing Yoast's built-in entries, only adding to them:
function te_add_locations_to_index( $sitemap_custom_items ) {
$base = trailingslashit( home_url() );
$last = gmdate( 'c' ); // ISO 8601, e.g. 2026-05-31T12:00:00+00:00
$sitemap_custom_items .= '<sitemap>'
. '<loc>' . esc_url( $base . 'locations-sitemap.xml' ) . '</loc>'
. '<lastmod>' . esc_html( $last ) . '</lastmod>'
. '</sitemap>';
return $sitemap_custom_items;
}The <loc> here is the URL of the child sitemap, not a page URL. Yoast serves the registered locations sitemap at locations-sitemap.xml, so that is the value to point at. Compute <lastmod> from your data (the newest location's updated timestamp) rather than hardcoding it; a static lastmod tells crawlers the file never changes and they will stop re-fetching it.
Step 2: generate the <url> entries
Now the registered callback. Yoast hands rendering off to your function when it serves locations-sitemap.xml. You build each entry with $wpseo_sitemaps->sitemap_url() (it formats a single <url> block from an array), wrap the lot in a <urlset>, and pass the finished XML to $wpseo_sitemaps->set_sitemap():
function te_locations_sitemap() {
global $wpseo_sitemaps;
$links = te_get_location_urls(); // your data layer
$output = '';
foreach ( $links as $link ) {
$output .= $wpseo_sitemaps->sitemap_url( array(
'loc' => $link['url'],
'mod' => $link['modified'], // ISO 8601 timestamp
) );
}
$urlset = '<urlset';
$urlset .= ' xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
$urlset .= ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"';
$urlset .= ' xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9';
$urlset .= ' http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">';
$urlset .= "\n" . $output . '</urlset>';
$wpseo_sitemaps->set_sitemap( $urlset );
}te_get_location_urls() is the part only you can write: it is whatever query returns your virtual URLs. For the state/city directory it might be a SELECT slug, state, city, updated_at FROM directory_locations, turned into absolute URLs and ISO 8601 timestamps:
function te_get_location_urls() {
global $wpdb;
$rows = $wpdb->get_results(
"SELECT state_slug, city_slug, updated_at
FROM {$wpdb->prefix}directory_locations
WHERE is_published = 1"
);
$links = array();
foreach ( $rows as $row ) {
$links[] = array(
'url' => home_url( "/{$row->state_slug}/{$row->city_slug}/" ),
'modified' => gmdate( 'c', strtotime( $row->updated_at ) ),
);
}
return $links;
}The mod value matters for crawl efficiency. Feed it a real per-row timestamp so Google re-crawls only the city pages that actually changed, instead of re-reading the whole sitemap on a guess.
The version caveat: confirm the API for your Yoast version
This is the part to read before you ship. The sitemap internals are not part of Yoast's stable public API, and they have changed across major versions. The wpseo_sitemap_index filter and the $wpseo_sitemaps->register_sitemap() method shown here are the long-standing pattern, but the exact method surface (sitemap_url(), set_sitemap(), the global object name) has shifted between releases, and Yoast's move to an indexables-based architecture changed how some of the generation runs internally.
So: check the official Yoast XML Sitemaps API documentation against the version you are running before relying on any one method name. If $wpseo_sitemaps is unset on your install, the isset() guard in step 1 is what stops a fatal, and that is a signal the global has moved. Treat the snippets here as the shape of the solution, not a copy-paste contract across every Yoast version.
A safer fallback that does not depend on Yoast's internals at all: generate a fully external sitemap file yourself (or with WP-CLI), then use only the wpseo_sitemap_index filter to add its <loc> to the index. You skip register_sitemap() entirely and Yoast just references a file you control. For very large directories that is also the more scalable route; see generating a static XML sitemap with WP-CLI for that approach, which works with or without Yoast.
Point Search Console at the index
You do not submit the custom sitemap on its own. Because you added it to the index, submitting sitemap_index.xml is enough: Google reads the index, finds your locations-sitemap.xml <loc>, and crawls it like any other child sitemap.
In Google Search Console, go to Sitemaps, and confirm https://example.com/sitemap_index.xml is submitted (it usually already is if Yoast is active). After your code is live, Google will pick up the new child sitemap on its next index fetch. You can also paste the child URL (locations-sitemap.xml) into the same Sitemaps screen to watch its discovered-URL count directly, which is handy while debugging.
Verify the sitemap actually renders
Before trusting Search Console, hit the files yourself. Yoast caches sitemaps, so if you are iterating, flush the cache first (Yoast SEO, Tools, or just deactivate/reactivate on a staging box), then curl the index and the child:
# The index should now list your custom sitemap
curl -s https://example.com/sitemap_index.xml | grep locations-sitemap
# The child sitemap should return your virtual URLs
curl -s https://example.com/locations-sitemap.xml | head -n 20The first command should print the <sitemap> line you added. The second should return a well-formed <urlset> with your /state/city/ <loc> entries. If the child returns Yoast's 404 page or an empty urlset, the usual causes are: the register_sitemap() call did not run (the init-time isset( $wpseo_sitemaps ) guard failed), a stale Yoast cache (flush it), or your data callback returned nothing. Validate the output is real XML, not HTML with an XML doctype glued on:
curl -s https://example.com/locations-sitemap.xml | xmllint --noout - && echo "valid XML"Put it in a must-use plugin
Keep all of this out of functions.php. A sitemap that silently breaks when someone switches themes is a slow, miserable bug to track down months later. The full file, with the attribution header so it is traceable if it gets copied:
<?php
/**
* Plugin Name: TE Yoast Sitemap
* Plugin URI: https://techearl.com/wordpress-add-custom-sitemap-yoast
* Description: Adds a custom sitemap of rewrite-driven location URLs to the Yoast SEO sitemap index.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-yoast-sitemap
*/
add_action( 'init', function () {
global $wpseo_sitemaps;
if ( isset( $wpseo_sitemaps ) && ! empty( $wpseo_sitemaps ) ) {
$wpseo_sitemaps->register_sitemap( 'locations', 'te_locations_sitemap' );
}
} );
add_filter( 'wpseo_sitemap_index', 'te_add_locations_to_index' );
function te_add_locations_to_index( $sitemap_custom_items ) {
$base = trailingslashit( home_url() );
$last = gmdate( 'c' );
$sitemap_custom_items .= '<sitemap>'
. '<loc>' . esc_url( $base . 'locations-sitemap.xml' ) . '</loc>'
. '<lastmod>' . esc_html( $last ) . '</lastmod>'
. '</sitemap>';
return $sitemap_custom_items;
}
function te_locations_sitemap() {
global $wpseo_sitemaps;
$output = '';
foreach ( te_get_location_urls() as $link ) {
$output .= $wpseo_sitemaps->sitemap_url( array(
'loc' => $link['url'],
'mod' => $link['modified'],
) );
}
$urlset = '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
$urlset .= "\n" . $output . '</urlset>';
$wpseo_sitemaps->set_sitemap( $urlset );
}
function te_get_location_urls() {
global $wpdb;
$rows = $wpdb->get_results(
"SELECT state_slug, city_slug, updated_at
FROM {$wpdb->prefix}directory_locations
WHERE is_published = 1"
);
$links = array();
foreach ( $rows as $row ) {
$links[] = array(
'url' => home_url( "/{$row->state_slug}/{$row->city_slug}/" ),
'modified' => gmdate( 'c', strtotime( $row->updated_at ) ),
);
}
return $links;
}Files in wp-content/mu-plugins/ load automatically, before regular plugins, and survive theme switches. The functions the example defines are prefixed so a copied snippet stays unique and does not collide with anything else in the install.
See also
- Build Rewrite-Driven State and City URLs in WordPress: the directory setup that produces the virtual
/state/city/pages this sitemap exists to expose, with nowp_postsrow behind any of them - Generate a Static XML Sitemap with WP-CLI: the no-Yoast alternative for very large directories, where a pre-rendered file you reference from the index beats generating entries on every request
- Clean Up wp_head in WordPress: the broader pattern of one must-use plugin shaping WordPress output, the same place this sitemap code belongs
Sources
Authoritative references this article was fact-checked against.
- Yoast SEO XML Sitemaps: API documentation (Yoast developer portal)developer.yoast.com
- How to add an external sitemap to the index (Yoast)yoast.com
- add_filter(): WordPress Developer Referencedeveloper.wordpress.org





