This page shows how a theme (or a plugin that prints SEO tags) should work alongside Anansi SEO. The rule is simple: while Anansi is in charge, the theme stops printing its own title, description, robots, canonical, social and schema tags, and hands its data to Anansi through filters instead. While Anansi is in preview mode or not installed, the theme keeps printing everything as before. The four examples below are complete and can be copied into a theme’s functions.php; they are based on a real theme that made this switch.
The pattern
- Wrap Anansi’s check in one helper, so the theme still works without Anansi.
- In every place the theme prints SEO output, return early when the helper is
true. - Feed the theme’s own data (custom fields, extra schema, sitemap rules) to Anansi with
anansi/…filters. These filters only run when Anansi builds its output, so they are harmless when it is not in charge.
During a signed preview request, anansi_is_active() is false and the theme prints its own tags as usual, while Anansi prints its version between <!-- anansi-preview --> markers. Your anansi/… filters run for that preview block, so the admin page comparison shows the result before you go live.
Titles
Declare add_theme_support( 'title-tag' ) and do not print <title> in header.php. In live mode Anansi removes WordPress’s title printer and prints <title> in its own head block. A hard-coded <title> would give the page two.
Theme filters on document_title_parts or pre_get_document_title do not change Anansi’s <title>. Move that logic to anansi/title_raw (see example 2).
Breadcrumbs
If the theme already shows breadcrumbs with rank_math_the_breadcrumbs(), yoast_breadcrumb() or a similar function, keep the call and its function_exists() check: Anansi defines those functions when it is in charge. To make Anansi’s breadcrumb schema match a trail the theme builds itself, filter anansi/breadcrumb/items (example 2). See functions.md.
Example 1: step aside when Anansi prints the tags
/**
* Anansi SEO prints the page's SEO output (live mode, no other SEO plugin).
*
* @return bool
*/
function mytheme_anansi_active() {
return function_exists( 'anansi_is_active' ) && anansi_is_active();
}
add_action( 'after_setup_theme', function () {
add_theme_support( 'title-tag' );
} );
/**
* The theme's own description, Open Graph and canonical tags.
*/
function mytheme_seo_tags() {
if ( mytheme_anansi_active() ) {
return; // Anansi prints these.
}
$description = mytheme_description();
if ( '' !== $description ) {
printf( '<meta name="description" content="%s" />' . "\n", esc_attr( $description ) );
printf( '<meta property="og:description" content="%s" />' . "\n", esc_attr( $description ) );
}
if ( is_category() ) {
printf( '<link rel="canonical" href="%s" />' . "\n", esc_url( get_term_link( get_queried_object() ) ) );
}
}
add_action( 'wp_head', 'mytheme_seo_tags', 5 );
/**
* The theme's own JSON-LD.
*/
function mytheme_schema() {
if ( mytheme_anansi_active() ) {
return; // The same nodes go into Anansi's graph instead (example 3).
}
echo '<script type="application/ld+json">' . wp_json_encode( mytheme_schema_nodes() ) . '</script>' . "\n";
}
add_action( 'wp_head', 'mytheme_schema', 20 );
/**
* Tag archives: noindex, follow.
*/
function mytheme_robots( $robots ) {
if ( mytheme_anansi_active() ) {
return $robots; // Anansi prints robots; see mytheme_anansi_robots().
}
if ( is_tag() ) {
$robots['noindex'] = true;
$robots['follow'] = true;
}
return $robots;
}
add_filter( 'wp_robots', 'mytheme_robots' );
/**
* Same rule inside Anansi.
*/
function mytheme_anansi_robots( $robots ) {
if ( is_tag() ) {
$robots['index'] = 'noindex';
$robots['follow'] = 'follow';
}
return $robots;
}
add_filter( 'anansi/robots', 'mytheme_anansi_robots' );
mytheme_description() and mytheme_schema_nodes() stand for the theme’s existing functions.
Code written for Rank Math sometimes checks did_action( 'rank_math/head' ) instead. That keeps working: Anansi fires rank_math/head after its head block when Rank Math is not active. New code should use anansi_is_active().
Example 2: give Anansi your own SEO fields
A theme that stored its own per-post SEO title and description in custom fields can let Anansi read them, with no data migration. Anansi reads _anansi_title, then Rank Math’s rank_math_title, then the keys you add:
/**
* Anansi reads the theme's fields after its own and Rank Math's.
*
* @param string[] $keys Meta keys to try, in order.
* @param string $type post|term|user.
* @param string $key Anansi field (title, description, …).
* @return string[]
*/
function mytheme_anansi_meta_sources( $keys, $type, $key ) {
if ( 'post' !== $type ) {
return $keys;
}
if ( 'title' === $key ) {
$keys[] = '_mytheme_seo_title';
} elseif ( 'description' === $key ) {
$keys[] = '_mytheme_meta_description';
}
return $keys;
}
add_filter( 'anansi/meta/sources', 'mytheme_anansi_meta_sources', 10, 3 );
The stored value is used as a template, so it may contain variables such as %sep% and %sitename%. A value with no variables is used as is. Once someone edits the field in Anansi’s editor, the new value is saved to _anansi_title and wins from then on. The theme’s field is never changed.
If the theme built the description on the fly (from the excerpt or the first sentences), keep that logic as a fallback for pages where Anansi has nothing:
/**
* Use the theme's description when Anansi has none.
*
* @param string $description Anansi's description ('' when none).
* @return string
*/
function mytheme_anansi_description( $description ) {
if ( '' !== trim( (string) $description ) ) {
return $description;
}
if ( is_singular() ) {
return mytheme_description( get_queried_object() );
}
if ( is_category() || is_tag() ) {
return trim( wp_strip_all_tags( term_description() ) );
}
return $description;
}
add_filter( 'anansi/description', 'mytheme_anansi_description' );
/**
* Long post titles drop the site name, as the theme's document_title_parts filter did.
*
* @param string $title Plain-text title.
* @return string
*/
function mytheme_anansi_title( $title ) {
if ( ! is_singular( 'post' ) ) {
return $title;
}
if ( '' !== (string) Anansi_Meta::post( get_queried_object_id(), 'title' ) ) {
return $title; // A hand-set title stays as written.
}
return mb_strlen( $title ) > 60 ? get_the_title( get_queried_object_id() ) : $title;
}
add_filter( 'anansi/title_raw', 'mytheme_anansi_title' );
/**
* Anansi's BreadcrumbList follows the theme's visible trail.
*
* @param array $crumbs Each: array( 0 => name, 1 => url, 'hide_in_schema' => bool ).
* @return array
*/
function mytheme_anansi_breadcrumbs( $crumbs ) {
$trail = mytheme_breadcrumb_trail(); // array of array( 'name' => …, 'url' => … ).
if ( count( $trail ) < 2 ) {
return $crumbs;
}
$out = array();
foreach ( $trail as $item ) {
$out[] = array( 0 => $item['name'], 1 => $item['url'], 'hide_in_schema' => false );
}
return $out;
}
add_filter( 'anansi/breadcrumb/items', 'mytheme_anansi_breadcrumbs' );
Anansi_Meta is always loaded when these filters run, because only Anansi fires them.
Example 3: add a node to the schema graph
Anansi prints one JSON-LD graph. Add your nodes to it with anansi/json_ld instead of printing a second <script>. This example adds a SoftwareApplication for a plugin’s product page, linked to Anansi’s publisher and web page nodes.
/**
* SoftwareApplication node on the product page.
*
* @param array $data Graph nodes, keyed.
* @param mixed $jsonld Anansi's schema object.
* @return array
*/
function mytheme_anansi_software( $data, $jsonld = null ) {
if ( ! is_page( 'acme-forms' ) ) {
return $data;
}
$url = get_permalink();
$app = array(
'@type' => 'SoftwareApplication',
'@id' => $url . '#software',
'name' => 'Acme Forms',
'url' => $url,
'applicationCategory' => 'BusinessApplication',
'operatingSystem' => 'WordPress',
'softwareVersion' => '2.4.1',
'offers' => array(
'@type' => 'Offer',
'price' => '0',
'priceCurrency' => 'USD',
),
);
// Link to the nodes Anansi already built, when they are there.
if ( isset( $data['publisher']['@id'] ) ) {
$app['publisher'] = array( '@id' => $data['publisher']['@id'] );
}
if ( isset( $data['WebPage']['@id'] ) ) {
$app['mainEntityOfPage'] = array( '@id' => $data['WebPage']['@id'] );
}
$data['mythemeSoftware'] = $app;
return $data;
}
add_filter( 'anansi/json_ld', 'mytheme_anansi_software', 20, 2 );
Notes:
- Use priority 20 or later. Anansi adds
publisherat 9 andWebSite,WebPage,BreadcrumbListand the default Article at 10. - Use a unique key (
mythemeSoftware). Keys are dropped from the printed graph, so they only need to be unique inside the array. - Give the node its own
@id. Anansi’s linking step at priority 99 only adds@idand links to its own stored and default nodes. - A node with
'@type' => 'FAQPage'is folded into the WebPage node by that step, as Rank Math does. Add FAQ data that way rather than as a separate page node. - To change a node Anansi built, edit it in place, for example
$data['publisher']['sameAs'][] = 'https://profiles.wordpress.org/acme/';.
Example 4: add a sitemap
Anansi’s sitemap index lists post types, taxonomies and authors. To add URLs that are not posts or terms (pages from another system, for example), add a sitemap of your own. Anansi already routes any /{name}-sitemap.xml URL to the sitemap handler; you supply the index entry and the content.
/**
* URLs for the landing sitemap: url => last-modified date (Y-m-d or a full date).
*
* @return array
*/
function mytheme_landing_urls() {
return array(
home_url( '/go/spring-sale/' ) => '2026-03-01',
home_url( '/go/webinar/' ) => '2026-09-15',
);
}
/**
* List landing-sitemap.xml in the sitemap index.
*
* @param string $xml Extra index XML.
* @return string
*/
function mytheme_anansi_sitemap_index( $xml ) {
$dates = array_values( mytheme_landing_urls() );
rsort( $dates );
$xml .= "\t<sitemap>\n";
$xml .= "\t\t<loc>" . esc_url( home_url( '/landing-sitemap.xml' ) ) . "</loc>\n";
$xml .= "\t\t<lastmod>" . esc_html( gmdate( 'c', strtotime( $dates[0] ) ) ) . "</lastmod>\n";
$xml .= "\t</sitemap>\n";
return $xml;
}
add_filter( 'anansi/sitemap/index', 'mytheme_anansi_sitemap_index' );
/**
* Body of /landing-sitemap.xml. Anansi adds the XML declaration and stylesheet.
*
* @param string $xml '' by default.
* @return string
*/
function mytheme_anansi_landing_sitemap( $xml ) {
$xml = '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
foreach ( mytheme_landing_urls() as $url => $date ) {
$xml .= "\t<url>\n";
$xml .= "\t\t<loc>" . esc_url( $url ) . "</loc>\n";
$xml .= "\t\t<lastmod>" . esc_html( gmdate( 'c', strtotime( $date ) ) ) . "</lastmod>\n";
$xml .= "\t</url>\n";
}
return $xml . '</urlset>';
}
add_filter( 'anansi/sitemap/landing/content', 'mytheme_anansi_landing_sitemap' );
/**
* The list does not come from posts, so its changes do not reach the sitemap cache.
* Don't cache this sitemap or the index that lists it.
*
* @param bool $cache Cache it.
* @param string $type Sitemap type ('1' = index).
* @return bool
*/
function mytheme_anansi_sitemap_cache( $cache, $type ) {
return in_array( (string) $type, array( 'landing', '1' ), true ) ? false : $cache;
}
add_filter( 'anansi/sitemap/cache_this', 'mytheme_anansi_sitemap_cache', 10, 2 );
Notes:
- Do not name your sitemap after an existing post type or taxonomy: those types are handled by Anansi’s own providers and your
contentfilter would not run. - The cache filter only matters when “Cache sitemaps” is on. Without it,
wp anansi sitemap flushclears the cache by hand. - Check the result with
wp anansi sitemap urls indexand by opening/landing-sitemap.xml.
Removing sitemap entries
The other direction is more common: keep something out.
// No tag sitemap.
add_filter( 'anansi/sitemap/exclude_taxonomy', function ( $exclude, $taxonomy ) {
return 'post_tag' === $taxonomy ? true : $exclude;
}, 10, 2 );
// No author sitemap: the author query matches nobody, and the index does not list it.
add_filter( 'anansi/sitemap/author/query', function ( $args ) {
$args['include'] = array( 0 );
return $args;
} );
add_filter( 'anansi/sitemap/index/entry', function ( $entry, $kind ) {
return 'author' === $kind ? false : $entry;
}, 10, 2 );
Checklist
- [ ]
add_theme_support( 'title-tag' ), no hard-coded<title>. - [ ] Every SEO printer in the theme returns early when
anansi_is_active()istrue. - [ ] Theme SEO fields mapped with
anansi/meta/sources. - [ ] Theme title and description rules moved to
anansi/title_rawandanansi/description. - [ ] Theme schema moved into
anansi/json_ld. - [ ] Sitemap rules moved from
wp_sitemaps_*filters toanansi/sitemap/*(Anansi replaces WordPress’s sitemaps when it is in charge, sowp_sitemaps_*filters stop mattering). - [ ] Checked with
wp anansi preview <url>and the dashboard comparison before going live.