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.
Yoast builds its sitemaps by querying the database for posts, pages, custom post types, and terms. A URL created with add_rewrite_rule() and rendered from a custom table or an API has no wp_posts row, so there is nothing for Yoast to query and the page never appears in any default sitemap.
The fix is to enumerate those virtual URLs yourself and register them as a custom sitemap with register_sitemap(), then add that sitemap to the index with the wpseo_sitemap_index filter.
register_sitemap( 'name', 'callback' ) creates the child sitemap itself: it makes a URL like name-sitemap.xml resolve and runs your callback to emit the <url> entries.
The wpseo_sitemap_index filter is separate: it adds a <sitemap> line to the top-level index so crawlers discover the child. You generally want both. Register-only and the file resolves but is never linked from the index; filter-only points the index at a file that does not exist.
No. Because you added it to the index via wpseo_sitemap_index, submitting sitemap_index.xml in Search Console is enough. Google reads the index, finds your child sitemap's <loc>, and crawls it like any other child.
You can optionally paste the child URL into the Sitemaps screen too, which lets you watch its discovered-URL count directly while you debug.
The sitemap internals are not a stable public API and have changed across Yoast versions. The global object name, and methods like sitemap_url() and set_sitemap(), have shifted between releases, and Yoast's indexables architecture changed some of the internal generation.
Guard every call with isset( $wpseo_sitemaps ) so a moved global fails soft instead of fatal, and confirm the method names against the official Yoast XML Sitemaps API docs for the exact version you run. If the internals do not match, generate an external sitemap file yourself and use only the index filter to reference it.
Yoast caches generated sitemaps, so a fresh curl may show stale output while you iterate. Flush it from the Yoast SEO tools, or on a staging box deactivate and reactivate the plugin to force a rebuild.
For production, drive cache invalidation from your data layer: when a location row changes, bump its updated_at so the mod timestamp moves, and let Yoast's own cache TTL refresh the file. Set <lastmod> from real per-row data, never a hardcoded value, or crawlers stop re-fetching.
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





