TechEarl

Add a Custom Sitemap to Yoast SEO (wpseo_sitemap_index)

How to add a custom sitemap to Yoast SEO so rewrite-driven pages with no WordPress post still get indexed: append a <sitemap> to the index with wpseo_sitemap_index, register a named sitemap, and generate its <url> entries.

Ishan Karunaratne⏱️ 9 min readUpdated
Share thisCopied
How to add a custom sitemap to Yoast SEO: use the wpseo_sitemap_index filter to add a <sitemap> entry to Yoast's index and register a named sitemap so rewrite-driven URLs that have no WordPress post still get crawled and indexed.

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:

php
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:

php
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():

php
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:

php
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:

bash
# 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 20

The 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:

bash
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
<?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

Sources

Authoritative references this article was fact-checked against.

TagsWordPressYoast SEOXML SitemapPHPSEORewrite Rules

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

Add a Custom WP-CLI Command in WordPress

How to register a custom WP-CLI command: guard it with defined('WP_CLI'), wire it up with WP_CLI::add_command(), turn class methods into subcommands, document args with @synopsis, and show progress with make_progress_bar().