SEO Standards — Rank Math Reference
This skill defines the SEO configuration standards, schema templates, and seeding commands for WordPress sites using Rank Math SEO as the primary SEO plugin.
1. Rank Math Plugin Detection
// Plugin slug: seo-by-rank-math
// Detection: defined('RANK_MATH_VERSION') or class_exists('RankMath')
// WP-CLI install: $WP plugin install seo-by-rank-math --activate
Before running any Rank Math commands, verify the plugin is active:
$WP eval "
if (defined('RANK_MATH_VERSION')) {
echo 'Rank Math v' . RANK_MATH_VERSION . ' is active.';
} else {
echo 'Rank Math is NOT active.';
}
"
2. Rank Math Modules Reference
| Module | Slug | Recommended | Notes |
|---|---|---|---|
| SEO Analysis | seo-analysis |
Yes | On-page content scoring |
| Sitemap | sitemap |
Yes | XML sitemap generation |
| Rich Snippets | rich-snippet |
Yes | JSON-LD structured data |
| Breadcrumbs | breadcrumbs |
Yes | Breadcrumb trail + schema |
| 404 Monitor | 404-monitor |
Yes | Track broken links |
| Redirections | redirections |
Yes | 301/302 redirect manager |
| Local SEO | local-seo |
Yes (business sites) | LocalBusiness schema |
| Image SEO | image-seo |
Yes | Auto alt/title attributes |
| Instant Indexing | instant-indexing |
Yes | IndexNow / Bing |
| Link Counter | link-counter |
Yes | Internal/external link audit |
Enable All Recommended Modules
$WP eval "
\$modules = get_option('rank_math_modules', []);
\$enable = ['seo-analysis','sitemap','rich-snippet','breadcrumbs','404-monitor','redirections','local-seo','image-seo','instant-indexing','link-counter'];
foreach (\$enable as \$mod) { if (!in_array(\$mod, \$modules)) { \$modules[] = \$mod; } }
update_option('rank_math_modules', \$modules);
echo 'Enabled modules: ' . implode(', ', \$modules);
"
3. Rank Math Option Keys
| Option | Purpose |
|---|---|
rank_math_modules |
Array of active module slugs |
rank-math-options-general |
Breadcrumbs, links, image SEO, strip category base |
rank-math-options-titles |
Title templates, schema type, social profiles, noindex rules |
rank-math-options-sitemap |
Sitemap inclusions, ping settings |
rank-math-options-instant-indexing |
IndexNow configuration |
Reading Options
# Get all general options
$WP eval "print_r(get_option('rank-math-options-general'));"
# Get all title options
$WP eval "print_r(get_option('rank-math-options-titles'));"
# Get active modules
$WP eval "print_r(get_option('rank_math_modules'));"
Writing Options
Rank Math stores options as serialized arrays. Always merge rather than overwrite:
$WP eval "
\$opts = (array) get_option('rank-math-options-general', []);
\$opts['breadcrumbs'] = 'on';
\$opts['strip_category_base'] = 'on';
\$opts['nofollow_external_links'] = 'on';
\$opts['new_window_external_links'] = 'on';
\$opts['add_img_alt'] = 'on';
\$opts['add_img_title'] = 'on';
update_option('rank-math-options-general', \$opts);
echo 'General options updated.';
"
4. Rank Math Post Meta Keys
| Meta Key | Purpose | Example Value |
|---|---|---|
rank_math_title |
SEO title template | %title% %sep% %sitename% |
rank_math_description |
Meta description | Free text, 150-160 chars |
rank_math_focus_keyword |
Focus keyword(s) | keyword1,keyword2 |
rank_math_robots |
Robots directives | ["index","follow"] |
rank_math_canonical_url |
Canonical URL override | https://... |
rank_math_facebook_title |
OG title | Free text |
rank_math_facebook_description |
OG description | Free text |
rank_math_facebook_image |
OG image URL | https://... |
rank_math_facebook_image_id |
OG image attachment ID | 42 |
rank_math_twitter_card_type |
Twitter card type | summary_large_image |
rank_math_twitter_title |
Twitter title | Free text |
rank_math_twitter_description |
Twitter description | Free text |
rank_math_schema_Article |
Article schema | Serialized array |
rank_math_schema_FAQPage |
FAQ schema | Serialized array |
rank_math_breadcrumb_title |
Breadcrumb label override | Free text |
rank_math_pillar_content |
Pillar content flag | on |
Seeding Post Meta via WP-CLI
# Set SEO meta for a single post
$WP eval "
\$post_id = 42;
update_post_meta(\$post_id, 'rank_math_title', '%title% %sep% %sitename%');
update_post_meta(\$post_id, 'rank_math_description', 'Your meta description here.');
update_post_meta(\$post_id, 'rank_math_focus_keyword', 'primary keyword');
update_post_meta(\$post_id, 'rank_math_robots', ['index','follow']);
update_post_meta(\$post_id, 'rank_math_twitter_card_type', 'summary_large_image');
echo 'SEO meta set for post ' . \$post_id;
"
Bulk-Seed Meta Descriptions from Content
$WP eval "
\$posts = get_posts(['post_type' => ['post','page'], 'posts_per_page' => -1, 'post_status' => 'publish']);
foreach (\$posts as \$p) {
\$existing = get_post_meta(\$p->ID, 'rank_math_description', true);
if (!empty(\$existing)) continue;
\$text = wp_strip_all_tags(\$p->post_excerpt ?: \$p->post_content);
\$desc = mb_substr(trim(preg_replace('/\s+/', ' ', \$text)), 0, 155);
if (mb_strlen(\$desc) > 10) {
update_post_meta(\$p->ID, 'rank_math_description', \$desc);
echo 'Set description for: ' . \$p->post_title . PHP_EOL;
}
}
"
5. Schema JSON-LD Templates
Organization
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "",
"url": "",
"logo": {
"@type": "ImageObject",
"url": ""
},
"sameAs": [
"https://www.facebook.com/PROFILE",
"https://www.instagram.com/PROFILE",
"https://x.com/PROFILE",
"https://www.linkedin.com/company/PROFILE"
]
}
LocalBusiness
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": "",
"url": "",
"image": "",
"telephone": "",
"email": "",
"address": {
"@type": "PostalAddress",
"streetAddress": "",
"addressLocality": "",
"addressRegion": "",
"postalCode": "",
"addressCountry": ""
},
"geo": {
"@type": "GeoCoordinates",
"latitude": "",
"longitude": ""
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday"],
"opens": "09:00",
"closes": "17:00"
}
],
"sameAs": []
}
FAQPage
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is the question?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The answer to the question."
}
},
{
"@type": "Question",
"name": "Another question?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Another answer."
}
}
]
}
BreadcrumbList
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://example.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Category",
"item": "https://example.com/category/"
},
{
"@type": "ListItem",
"position": 3,
"name": "Current Page"
}
]
}
Article
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "",
"author": {
"@type": "Person",
"name": "",
"url": ""
},
"datePublished": "2025-01-01T00:00:00+00:00",
"dateModified": "2025-01-01T00:00:00+00:00",
"image": {
"@type": "ImageObject",
"url": "",
"width": 1200,
"height": 630
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://example.com/post-slug/"
},
"publisher": {
"@type": "Organization",
"name": "",
"logo": {
"@type": "ImageObject",
"url": ""
}
}
}
6. Meta Title Formulas by Page Type
| Page Type | Formula |
|---|---|
| Homepage | %sitename% %sep% %sitedesc% |
| Page | %title% %sep% %sitename% |
| Blog Post | %title% %sep% %sitename% |
| Category | %term% %sep% %sitename% %page% |
| Search | Search: %searchphrase% %sep% %sitename% |
| 404 | Page Not Found %sep% %sitename% |
| Author | %name% %sep% %sitename% |
Available Variables
%title%— Post/page title%sitename%— Site name from Settings > General%sitedesc%— Site tagline%sep%— Separator character (default:-)%term%— Category/tag name%searchphrase%— Current search query%page%— Page number (blank on page 1)%name%— Author display name%date%— Post publication date%excerpt%— Post excerpt
Seed Title Templates via WP-CLI
$WP eval "
\$opts = (array) get_option('rank-math-options-titles', []);
\$opts['homepage_title'] = '%sitename% %sep% %sitedesc%';
\$opts['pt_post_title'] = '%title% %sep% %sitename%';
\$opts['pt_page_title'] = '%title% %sep% %sitename%';
\$opts['tax_category_title'] = '%term% %sep% %sitename% %page%';
\$opts['search_title'] = 'Search: %searchphrase% %sep% %sitename%';
\$opts['404_title'] = 'Page Not Found %sep% %sitename%';
\$opts['author_archive_title'] = '%name% %sep% %sitename%';
update_option('rank-math-options-titles', \$opts);
echo 'Title templates updated.';
"
7. Meta Description Generation
Priority Order
- Manual —
rank_math_descriptionpost meta (highest priority) - Excerpt — Post excerpt if set
- First paragraph — First
<p>block in post content - Trimmed content — First 155 characters of stripped content
Target length: 150–160 characters.
Auto-Generate Description Function
function prefix_auto_meta_description( $post_id ) {
$existing = get_post_meta( $post_id, 'rank_math_description', true );
if ( ! empty( $existing ) ) {
return $existing;
}
$post = get_post( $post_id );
if ( ! $post ) {
return '';
}
// Try excerpt first
if ( ! empty( $post->post_excerpt ) ) {
$text = $post->post_excerpt;
} else {
// Try first paragraph
$content = $post->post_content;
if ( preg_match( '/<p[^>]*>(.*?)<\/p>/is', $content, $matches ) ) {
$text = $matches[1];
} else {
$text = $content;
}
}
$text = wp_strip_all_tags( $text );
$text = trim( preg_replace( '/\s+/', ' ', $text ) );
$desc = mb_substr( $text, 0, 155 );
// Avoid cutting mid-word
if ( mb_strlen( $text ) > 155 ) {
$desc = mb_substr( $desc, 0, mb_strrpos( $desc, ' ' ) );
$desc .= '...';
}
return $desc;
}
Bulk Seed via WP-CLI
$WP eval "
\$posts = get_posts(['post_type' => ['post','page'], 'posts_per_page' => -1, 'post_status' => 'publish']);
\$count = 0;
foreach (\$posts as \$p) {
\$existing = get_post_meta(\$p->ID, 'rank_math_description', true);
if (!empty(\$existing)) continue;
\$text = wp_strip_all_tags(\$p->post_excerpt ?: \$p->post_content);
\$text = trim(preg_replace('/\s+/', ' ', \$text));
\$desc = mb_substr(\$text, 0, 155);
if (mb_strlen(\$text) > 155) {
\$desc = mb_substr(\$desc, 0, mb_strrpos(\$desc, ' '));
}
if (mb_strlen(\$desc) > 10) {
update_post_meta(\$p->ID, 'rank_math_description', \$desc);
\$count++;
}
}
echo \"Seeded \$count meta descriptions.\";
"
8. llms.txt Template
# {Site Name}
> {Site Description}
## Pages
- [Page Title](url): excerpt or first 20 words
## Blog Posts
- [Post Title](url): excerpt
## Contact
- Website: {home_url}
Generate llms.txt via WP-CLI
$WP eval "
\$name = get_bloginfo('name');
\$desc = get_bloginfo('description');
\$home = home_url('/');
\$out = \"# \$name\n\n> \$desc\n\n\";
// Pages
\$out .= \"## Pages\n\";
\$pages = get_posts(['post_type' => 'page', 'posts_per_page' => -1, 'post_status' => 'publish', 'orderby' => 'menu_order', 'order' => 'ASC']);
foreach (\$pages as \$p) {
\$url = get_permalink(\$p->ID);
\$excerpt = \$p->post_excerpt ?: wp_trim_words(wp_strip_all_tags(\$p->post_content), 20, '...');
\$out .= \"- [\$p->post_title](\$url): \$excerpt\n\";
}
// Blog posts
\$out .= \"\n## Blog Posts\n\";
\$posts = get_posts(['post_type' => 'post', 'posts_per_page' => -1, 'post_status' => 'publish', 'orderby' => 'date', 'order' => 'DESC']);
foreach (\$posts as \$p) {
\$url = get_permalink(\$p->ID);
\$excerpt = \$p->post_excerpt ?: wp_trim_words(wp_strip_all_tags(\$p->post_content), 20, '...');
\$out .= \"- [\$p->post_title](\$url): \$excerpt\n\";
}
// Contact
\$out .= \"\n## Contact\n\";
\$out .= \"- Website: \$home\n\";
file_put_contents(ABSPATH . 'llms.txt', \$out);
echo 'Generated llms.txt at ' . ABSPATH . 'llms.txt';
echo PHP_EOL . '---' . PHP_EOL . \$out;
"
9. robots.txt Template
# robots.txt for WordPress
User-agent: *
Allow: /
Disallow: /wp-admin/
Allow: /wp-admin/admin-ajax.php
Disallow: /wp-includes/
Disallow: /wp-content/plugins/
Disallow: /readme.html
Disallow: /license.txt
Disallow: /?s=
Disallow: /search/
Disallow: /cgi-bin/
Disallow: /trackback/
# AI Crawlers — Allow by default (confirm with user before changing)
User-agent: GPTBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: CCBot
Allow: /
User-agent: Bytespider
Disallow: /
# Sitemap
Sitemap: {home_url}/sitemap_index.xml
Note: AI crawler policy (Allow vs Disallow) should be confirmed with the site owner before writing. The defaults above allow all major AI crawlers except Bytespider.
Generate robots.txt via WP-CLI
$WP eval "
\$home = home_url('/');
\$robots = 'User-agent: *
Allow: /
Disallow: /wp-admin/
Allow: /wp-admin/admin-ajax.php
Disallow: /wp-includes/
Disallow: /wp-content/plugins/
Disallow: /readme.html
Disallow: /license.txt
Disallow: /?s=
Disallow: /search/
Disallow: /cgi-bin/
Disallow: /trackback/
# AI Crawlers
User-agent: GPTBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: CCBot
Allow: /
User-agent: Bytespider
Disallow: /
Sitemap: ' . \$home . 'sitemap_index.xml
';
file_put_contents(ABSPATH . 'robots.txt', \$robots);
echo 'Generated robots.txt at ' . ABSPATH . 'robots.txt';
"
10. Breadcrumb Integration Code
PHP Function
function prefix_breadcrumbs() {
if (is_front_page()) return;
echo '<nav class="breadcrumbs" aria-label="Breadcrumb">';
if (function_exists('rank_math_the_breadcrumbs')) {
rank_math_the_breadcrumbs();
} else {
echo '<a href="' . esc_url(home_url('/')) . '">Home</a>';
echo ' » ';
if (is_singular()) { the_title(); }
elseif (is_archive()) { the_archive_title(); }
elseif (is_search()) { echo 'Search results'; }
elseif (is_404()) { echo 'Page Not Found'; }
}
echo '</nav>';
}
Breadcrumb CSS
.breadcrumbs {
padding: 12px 0;
font-size: 0.875rem;
color: #6b7280;
}
.breadcrumbs a {
color: #3b82f6;
text-decoration: none;
}
.breadcrumbs a:hover {
text-decoration: underline;
}
.breadcrumbs .separator {
margin: 0 0.5rem;
color: #9ca3af;
}
Enable Breadcrumbs in Rank Math
$WP eval "
\$opts = (array) get_option('rank-math-options-general', []);
\$opts['breadcrumbs'] = 'on';
update_option('rank-math-options-general', \$opts);
echo 'Breadcrumbs enabled.';
"
11. FAQ Detection Patterns
FAQ content can appear in several forms. Detect and convert to JSON-LD schema automatically.
Detection Sources
- ACF repeater fields — field names:
faq_items,faqs,faq_cards,faq <details>/<summary>HTML — accordion-style FAQ in content- H2/H3 headings ending with
?— followed by<p>answer content
FAQ Auto-Detection and JSON-LD Generator
function prefix_detect_faq_jsonld( $post_id ) {
$faqs = [];
// 1. Check ACF repeater fields
$acf_names = ['faq_items', 'faqs', 'faq_cards', 'faq'];
foreach ( $acf_names as $field_name ) {
if ( function_exists('have_rows') && have_rows( $field_name, $post_id ) ) {
while ( have_rows( $field_name, $post_id ) ) {
the_row();
$q = get_sub_field('question') ?: get_sub_field('title');
$a = get_sub_field('answer') ?: get_sub_field('content') ?: get_sub_field('text');
if ( $q && $a ) {
$faqs[] = [ 'q' => wp_strip_all_tags($q), 'a' => wp_strip_all_tags($a) ];
}
}
}
}
// 2. Check <details>/<summary> in content
$content = get_post_field('post_content', $post_id);
if ( preg_match_all('/<summary[^>]*>(.*?)<\/summary>\s*(.*?)<\/details>/is', $content, $matches, PREG_SET_ORDER) ) {
foreach ( $matches as $m ) {
$q = wp_strip_all_tags( $m[1] );
$a = wp_strip_all_tags( $m[2] );
if ( $q && $a ) {
$faqs[] = [ 'q' => $q, 'a' => $a ];
}
}
}
// 3. Check H2/H3 headings ending with ?
if ( preg_match_all('/<h[23][^>]*>(.*?\?)<\/h[23]>\s*<p>(.*?)<\/p>/is', $content, $matches, PREG_SET_ORDER) ) {
foreach ( $matches as $m ) {
$q = wp_strip_all_tags( $m[1] );
$a = wp_strip_all_tags( $m[2] );
if ( $q && $a ) {
$faqs[] = [ 'q' => $q, 'a' => $a ];
}
}
}
if ( empty( $faqs ) ) {
return '';
}
// Build JSON-LD
$schema = [
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => [],
];
foreach ( $faqs as $faq ) {
$schema['mainEntity'][] = [
'@type' => 'Question',
'name' => $faq['q'],
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => $faq['a'],
],
];
}
return '<script type="application/ld+json">' . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) . '</script>';
}
Inject FAQ Schema into Head
add_action('wp_head', function() {
if ( is_singular() ) {
$jsonld = prefix_detect_faq_jsonld( get_the_ID() );
if ( $jsonld ) {
echo $jsonld;
}
}
});
12. SEO Seeding Sequence
Recommended order for bulk SEO setup on a new or existing site:
- Install + activate Rank Math —
$WP plugin install seo-by-rank-math --activate - Enable modules — activate all recommended modules (Section 2)
- Configure general settings — breadcrumbs, link behavior, image SEO (Section 3)
- Configure title templates — set formulas per page type (Section 6)
- Configure sitemap — set post types and taxonomies to include
- Set Organization / LocalBusiness schema — configure site-wide schema (Section 5)
- Configure OG / Social defaults — set default OG image, social profiles
- Enable IndexNow — activate instant indexing for Bing / Yandex
- Seed per-page SEO meta —
rank_math_title,rank_math_focus_keyword, robots (Section 4) - Seed meta descriptions — auto-generate from excerpt/content (Section 7)
- Set image alt texts — bulk update missing alt attributes
- Generate robots.txt — write optimized robots.txt (Section 9)
- Generate llms.txt — write AI-readable site summary (Section 8)
- Flush sitemap cache + rewrite rules — finalize:
$WP eval "
if (class_exists('RankMath')) {
// Trigger sitemap regeneration
delete_transient('rank_math_sitemap_cache');
\RankMath\Sitemap\Cache::invalidate_storage();
}
"
$WP rewrite flush
Verification Checklist
After seeding, verify:
# Check title templates are set
$WP eval "print_r(get_option('rank-math-options-titles'));"
# Count posts with meta descriptions
$WP eval "
global \$wpdb;
\$count = \$wpdb->get_var(\"SELECT COUNT(*) FROM \$wpdb->postmeta WHERE meta_key = 'rank_math_description' AND meta_value != ''\");
echo \"Posts with meta descriptions: \$count\";
"
# Verify sitemap exists
$WP eval "echo home_url('/sitemap_index.xml');"
# Verify robots.txt exists
$WP eval "echo file_exists(ABSPATH . 'robots.txt') ? 'robots.txt exists' : 'robots.txt missing';"
# Verify llms.txt exists
$WP eval "echo file_exists(ABSPATH . 'llms.txt') ? 'llms.txt exists' : 'llms.txt missing';"
13. Lighthouse SEO Audit Gotchas (hard-won)
Non-obvious traps that make a fix look applied while the audit keeps failing. Verify against these before reporting an SEO finding fixed.
link-text matches VISIBLE innerText — not aria-label
The Lighthouse link-text audit (core/audits/seo/link-text.js) lowercases/trims the anchor's link.text (rendered innerText) and checks it against a blocklist: click here, here, learn more, more, read more, this, start, … (per-language). aria-label, title, and nodeLabel are ignored — adding an aria-label fixes the a11y accessible-name but does not satisfy this SEO audit.
To keep a design's non-descriptive label (e.g. a "LEARN MORE" button) and pass, append a visually-hidden descriptive suffix inside the anchor so it becomes part of innerText:
<a class="btn" href="<?php echo esc_url( $url ); ?>">
<?php echo esc_html( $label ); // e.g. "LEARN MORE" — stays visible ?>
<?php if ( $context ) : ?><span class="screen-reader-text"><?php
echo esc_html( 'about ' . $context ); // e.g. "about Home Search"
?></span><?php endif; ?>
</a>
Now link.text = "LEARN MORE about Home Search" → not a blocklist match → audit passes; the button still visually reads "LEARN MORE".
- Critical: the hidden span must use the
.screen-reader-textclip pattern (position:absolute; clip: rect(...); width:1px) — NOTdisplay:noneorvisibility:hidden, which are excluded from innerText and would fail again. (A.screen-reader-textrule is required in the theme CSS; see a11y standards.) - The same non-descriptive label often repeats across a reusable button component — fix the component/template part once, then re-check every page that uses it (Lighthouse audits per-URL; a homepage pass doesn't clear inner pages).
identical-links-same-purpose(a11y, weight 0) can also flag two same-text links (e.g. two "VIEW ALL") pointing to different destinations — the same hidden-suffix technique resolves it.
Meta description: let the SEO plugin own it — don't emit a theme fallback
If Rank Math (or Yoast) is active and configured, do not also echo a hardcoded <meta name="description"> from the theme wp_head. Two tags = a duplicate-description warning, and the plugin's dynamic/templated value is the one you want. A common failure mode: a theme adds a front-page meta-description fallback "to be safe" while the SEO plugin's setup wizard was incomplete → once the wizard is finished, the tag is duplicated. Remove the theme fallback; verify only one <meta name="description"> renders (curl -s <url> | grep -c 'name="description"' → 1).