SEO for Solos
6.xcms

WordPress SEO

Still the largest share of the web. The plugin ecosystem covers the metadata layer well and the programmatic and AEO layers not at all, which is where hand-written code earns its place.

What you start with

  • +Canonical tags, sitemaps and Open Graph from any of the major SEO plugins
  • +A built-in sitemap since 5.5, which most plugins replace
  • +Template hierarchy that makes per-type markup straightforward

What it makes harder

  • !Two SEO plugins active at once emit two canonicals, which is the most common WordPress SEO defect
  • !The default sitemap has no lastmod at all, and several plugins stamp it with the post modified date on every save regardless of content change
  • !Any output added with the wrong hook priority can land outside the head

Recipes

Each one is real WordPress code, not the same snippet with the imports swapped.

llms.txt as a rewrite rule

The negative statements block is the most useful part: it is how you stop an engine conflating you with a similarly named product. The file costs one route. Check aeo-llms-txt

functions.php
<?php
// No plugin ships this, so it is a rewrite rule plus a template redirect.
add_action('init', function () {
    add_rewrite_rule('^llms\.txt$', 'index.php?llms_txt=1', 'top');
});

add_filter('query_vars', function ($vars) {
    $vars[] = 'llms_txt';
    return $vars;
});

add_action('template_redirect', function () {
    if (!get_query_var('llms_txt')) {
        return;
    }
    header('Content-Type: text/plain; charset=utf-8');
    header('Cache-Control: public, max-age=3600, s-maxage=3600');

    $lines = [
        '# ' . get_bloginfo('name'),
        '',
        '> ' . get_bloginfo('description'),
        '',
        '## Key facts',
        '',
        '- Canonical site: ' . home_url(),
        '',
        '## What this is not',
        '',
        '- ' . get_bloginfo('name') . ' is not a marketplace.',
    ];
    echo implode("\n", $lines) . "\n";
    exit;
});

One graph, after disabling the plugin's own output

Multiple disconnected script tags describe several unrelated things. One graph describes one page, and lets nodes reference each other instead of repeating themselves. Check schema-single-script

functions.php
<?php
// Emitting your own graph while a plugin emits its own produces two scripts
// and two Organization nodes with different identifiers.
add_filter('wpseo_json_ld_output', '__return_false');

add_action('wp_head', function () {
    $graph = [
        '@context' => 'https://schema.org',
        '@graph' => [
            [
                '@type' => 'Organization',
                '@id' => home_url('/#organization'),
                'name' => get_bloginfo('name'),
                'url' => home_url('/'),
            ],
            [
                '@type' => 'WebSite',
                '@id' => home_url('/#website'),
                'url' => home_url('/'),
                'publisher' => ['@id' => home_url('/#organization')],
            ],
        ],
    ];
    printf(
        '<script type="application/ld+json">%s</script>',
        wp_json_encode($graph, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG)
    );
}, 20);

Sitemap dates that reflect real content changes

A sitemap claiming 2,100 pages changed at 03:14 this morning teaches the crawler the field is noise. It then stops reading it, and you lose the signal permanently. Check crawl-lastmodified-differentiated

functions.php
<?php
// post_modified changes on every save, including a typo fix in a draft.
// Tracking a deliberate content-change timestamp keeps lastmod meaningful.
add_action('post_updated', function ($post_id, $post_after, $post_before) {
    if ($post_after->post_content !== $post_before->post_content) {
        update_post_meta($post_id, '_content_changed_at', current_time('mysql', true));
    }
}, 10, 3);

add_filter('wp_sitemaps_posts_entry', function ($entry, $post) {
    $changed = get_post_meta($post->ID, '_content_changed_at', true);
    $entry['lastmod'] = wp_date('c', strtotime($changed ?: $post->post_modified_gmt));
    return $entry;
}, 10, 2);

A deliberate crawler policy

The useful split is not AI versus search. It is whether a crawler can send a visit or a citation in exchange for the bandwidth. Blocking a training crawler does not remove you from that company's answer engine when the citation crawler is a separate user agent. Check crawl-bot-economics

functions.php
<?php
add_filter('robots_txt', function ($output, $public) {
    if (!$public) {
        return $output; // Site is set to discourage indexing. Respect it.
    }

    $deny = ['GPTBot', 'Google-Extended', 'CCBot', 'anthropic-ai', 'Bytespider'];
    $allow = ['OAI-SearchBot', 'ChatGPT-User', 'PerplexityBot', 'ClaudeBot'];

    foreach ($allow as $agent) {
        $output .= "\n# Citation crawler: sends referral traffic.\n";
        $output .= "User-agent: {$agent}\nAllow: /\nDisallow: /wp-admin/\n";
    }
    foreach ($deny as $agent) {
        $output .= "\n# Training crawler: no referral traffic.\n";
        $output .= "User-agent: {$agent}\nDisallow: /\n";
    }
    return $output;
}, 10, 2);

The same checks in other frameworks

Questions

What does WordPress give you before you write any SEO code?

WordPress starts with: Canonical tags, sitemaps and Open Graph from any of the major SEO plugins; A built-in sitemap since 5.5, which most plugins replace; Template hierarchy that makes per-type markup straightforward.

What does WordPress make harder?

Two SEO plugins active at once emit two canonicals, which is the most common WordPress SEO defect This is specific to WordPress rather than a general SEO problem.

How many SEO recipes does SEO for Solos publish for WordPress?

SEO for Solos publishes 4 WordPress recipes, each with real WordPress code and a note on what differs from the same check in other frameworks.