WordPress powers over 40% of the web. That means most technical SEO work you encounter professionally will touch WordPress in some form. The goal of this track is to move you beyond plugin dashboards and into the engine room — understanding how WordPress actually renders, where it breaks, and how to fix it at the code level when plugins cannot reach.
Before you can audit or fix a WordPress site, you need to understand how a request becomes a rendered HTML page.
WordPress is a server-side PHP application. When a browser (or Googlebot) requests a URL, the following sequence occurs:
This means WordPress, by default, produces server-rendered HTML — crawlers receive real content in the initial response. However, themes, page builders, and plugins can layer JavaScript on top of that HTML, introducing all the rendering risks covered in Phase 0, Module 0.3.
WordPress uses a specific lookup order to find the right template. A simplified version:
single-{post-type}-{slug}.php → single-{post-type}.php → single.php → singular.php → index.phpcategory-{slug}.php → category-{id}.php → category.php → archive.php → index.phpfront-page.php → page.php → index.phpUnderstanding this hierarchy is essential because custom SEO logic (custom meta, custom JSON-LD) must be attached to the right template or hooked into the right WordPress action.
A raw WordPress theme can produce clean, semantic HTML quickly. A page builder (Elementor, Divi, WPBakery) wraps every element in multiple <div> layers, injects proprietary CSS and JS on every page regardless of whether those assets are needed, and frequently generates markup that breaks heading hierarchy and semantic structure.
The SEO costs:
<h1> tags, broken heading outlines, non-semantic wrappers that confuse crawlers about content hierarchy.<head> without defer or async.The major plugins — Yoast SEO, Rank Math, and SEOPress — manage a consistent set of SEO features:
noindex, nofollow) per post, term, or archive typeSEO plugins are designed for general-purpose use. You must move to custom code when:
At that point, you write code. The next section shows you how.
Never modify a parent theme directly — updates will overwrite your changes. Always create a child theme. Your customizations live in the child theme's functions.php, which WordPress loads after the parent theme.
<?php
// wp-content/themes/your-child-theme/functions.php
// Enqueue parent theme styles
add_action( 'wp_enqueue_scripts', function() {
wp_enqueue_style(
'parent-style',
get_template_directory_uri() . '/style.css'
);
});
WordPress is event-driven. Almost everything that happens during a page request fires an action (a named event you can attach to) or passes data through a filter (a named pipeline you can intercept and modify).
add_action( 'hook_name', 'your_function', $priority, $args ) — attach your function to run when an event fires.add_filter( 'hook_name', 'your_function', $priority, $args ) — intercept and return modified data as it passes through a pipeline.Key SEO-relevant hooks:
wp_head — fires inside <head>; use it to inject canonical tags, JSON-LD, and custom meta.wp_footer — fires just before </body>.document_title_parts (filter) — modify the array that builds the <title> tag.the_content (filter) — modify post content before output.robots_txt (filter) — modify the dynamically generated robots.txt content.wp_sitemaps_posts_query_args (filter) — modify queries that populate WordPress's built-in sitemap.This example outputs a canonical tag based on custom logic — useful when your SEO plugin's canonical is wrong for a specific post type.
<?php
add_action( 'wp_head', 'custom_canonical_tag', 1 );
function custom_canonical_tag() {
// Only run on singular product pages
if ( ! is_singular( 'product' ) ) {
return;
}
$post_id = get_the_ID();
// Retrieve a custom field that stores the canonical override
$override = get_post_meta( $post_id, '_canonical_override', true );
if ( $override ) {
$canonical = esc_url( $override );
} else {
$canonical = esc_url( get_permalink( $post_id ) );
}
echo '<link rel="canonical" href="' . $canonical . '" />' . "\n";
}
Important: If you are also using an SEO plugin, disable its canonical output for this post type to avoid duplicate, potentially conflicting canonical tags. Most plugins provide a filter for this.
Below is an example that outputs a Product JSON-LD block on WooCommerce product pages, pulling data directly from the database. This produces richer, more accurate schema than most plugin defaults.
<?php
add_action( 'wp_head', 'custom_product_jsonld', 5 );
function custom_product_jsonld() {
if ( ! is_singular( 'product' ) ) {
return;
}
global $post;
$product = wc_get_product( $post->ID );
if ( ! $product ) {
return;
}
$name = esc_js( $product->get_name() );
$description = esc_js( wp_strip_all_tags( $product->get_description() ) );
$sku = esc_js( $product->get_sku() );
$price = $product->get_price();
$currency = get_woocommerce_currency();
$image_id = $product->get_image_id();
$image_url = $image_id ? esc_url( wp_get_attachment_url( $image_id ) ) : '';
$url = esc_url( get_permalink() );
// Availability
$availability = $product->is_in_stock()
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock';
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => $product->get_name(),
'url' => get_permalink(),
'sku' => $product->get_sku(),
'description' => wp_strip_all_tags( $product->get_description() ),
'image' => $image_url,
'offers' => [
'@type' => 'Offer',
'price' => $price,
'priceCurrency' => $currency,
'availability' => $availability,
'url' => get_permalink(),
],
];
echo '<script type="application/ld+json">'
. wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT )
. '</script>' . "\n";
}
WordPress generates a virtual robots.txt at the root of the site. You can modify it without editing a physical file:
<?php
add_filter( 'robots_txt', 'custom_robots_txt_rules', 10, 2 );
function custom_robots_txt_rules( $output, $public ) {
// Add a Crawl-delay directive for all bots
$output .= "\n# Custom rules\n";
$output .= "Crawl-delay: 2\n";
// Block a specific path
$output .= "Disallow: /wp-json/\n";
return $output;
}
Warning: Never disallow /wp-content/themes/ or /wp-content/plugins/. Blocking these resources prevents Googlebot from rendering your pages correctly, causing all the rendering failures described in Phase 0, Module 0.3f.
Performance in WordPress is a layered problem. Each layer addresses a different bottleneck. You must understand every layer and how they interact — installing a caching plugin without understanding what it does is how you create bugs.
WordPress generates pages dynamically via PHP + MySQL on every request. For most pages, this content does not change between requests. Page caching stores the generated HTML output and serves it directly, bypassing PHP and the database entirely.
Key concerns: ensure logged-in users, cart pages (WooCommerce), and search result pages are excluded from cache. Cached pages with user-specific or dynamic content produce incorrect results.
WordPress makes database queries on every request even when a page cache exists (because not every request hits the page cache — e.g., admin users, logged-in users, dynamic fragments). Object caching stores the results of expensive database queries in memory.
PHP compiles your PHP files to bytecode on every request by default. OPcache stores that compiled bytecode in memory, eliminating the recompilation step. This is a server-level PHP configuration — a significant TTFB improvement at zero application cost. Verify it is enabled on your hosting environment.
wp_options table, bloating it and slowing option-loading queries. Use a plugin like WP-Optimize or a custom WP-CLI command to clean them.define( 'WP_POST_REVISIONS', 5 ); to wp-config.php to limit revisions.wp_script_add_data() to add defer or async attributes to registered scripts. Critically, never defer scripts that are depended on by inline scripts — it will break execution order.wp_dequeue_script() and wp_dequeue_style() to remove assets on pages where they are not needed (e.g., remove the contact form plugin's CSS/JS on posts that have no form).width and height attributes to prevent CLS. Use loading="lazy" on below-the-fold images; never use it on the LCP image (typically the hero).A CDN (Content Delivery Network) serves static assets (images, CSS, JS) from edge nodes geographically close to the user, reducing latency. It also offloads bandwidth from your origin server. Cloudflare is the most common choice; it also provides additional performance features (HTTP/2, HTTP/3, image optimization, edge caching).
Ensure your CDN is configured to:
Cache-Control headers from origin for dynamic pagesThese are the issues you will find on almost every established WordPress site. Know each one, its cause, and its remedy.
Problem: WordPress creates a public URL for every uploaded media file. These pages contain almost no content — usually just the image and its filename — yet they consume crawl budget and frequently get indexed, creating thousands of thin-content pages.
Fix: Redirect all attachment URLs to their parent post, or to the attachment file itself. Most SEO plugins have a one-click option to enable this. The underlying mechanism is a redirect from the attachment page URL to the attachment file URL (or parent post), so there is no indexed thin-content page.
Problem: WordPress generates archive pages for every category, every tag, every author, and every date combination. A post tagged with 10 tags creates 10 additional archive pages containing that post's excerpt, often duplicating content that appears on the main category page.
Diagnostic approach:
/tag/, /author/, and /?m= (date archives) URLsFixes:
noindex all tag archives. In Rank Math or Yoast, this is a single toggle. Alternatively, consolidate your taxonomy strategy — categories for site architecture, tags for granular topic grouping.noindex author archives — they duplicate the blog listing. On multi-author sites with genuine editorial identity, keep them and ensure they have unique content (author bio, curated post list).noindex or noindex, nofollow. Date-based archives have no topical coherence and are pure index bloat./page/2/, /page/3/) use self-referencing canonical tags (not a canonical pointing to page 1) and that the pagination links use real <a href> elements so Googlebot can follow them.Problem: E-commerce sites built on WooCommerce commonly use filtering plugins (e.g., WooCommerce Product Filters, FacetWP) that generate URL parameters for every filter combination (?color=red&size=large&sort=price). This can generate millions of unique URLs from a catalog of hundreds of products, overwhelming crawl budget and creating massive thin-content problems.
Fixes (in order of preference):
noindex robots meta to filtered URLs. Combine with removing internal links to filtered URLs in navigation elements where possible.WordPress's built-in sitemap (introduced in WordPress 5.5) and many SEO plugin sitemaps include URLs that should not be in a sitemap: attachment pages, author archives, date archives, tag archives, noindexed pages, low-value utility pages.
Audit your sitemap and remove any URL that:
noindex directive (a noindex URL in a sitemap is a direct contradiction)Use the wp_sitemaps_posts_query_args, wp_sitemaps_taxonomies, and wp_sitemaps_add_provider filters to control what WordPress's core sitemap includes. SEO plugins expose their own filters and admin toggles.
Problem: Many themes wrap the site name or tagline in an <h1> on every page. The post title also outputs as an <h1>. Result: every page has two <h1> tags, and neither correctly represents the page's primary topic.
Fix: In the child theme, override the header template to change the site title to a <p> or <span> on non-homepage templates, or to an <h1> only on the homepage (where it is the primary heading). Use browser DevTools to verify the heading outline of every template type.
Audit with Chrome DevTools Coverage tab and the Network tab waterfall. For every render-blocking resource, determine:
<head> and the rest loaded asynchronously?WordPress can function purely as a content management backend with a completely decoupled frontend. This architecture is increasingly common in enterprise and performance-critical deployments.
In a headless setup:
/wp-json/wp/v2/) or WPGraphQL (a plugin that exposes WordPress data as a GraphQL API) serves as the data layer.This is the critical architectural implication. When WordPress is headless, it no longer controls what gets rendered in the browser. SEO responsibilities move to the front-end framework:
redirects config) or at the CDN/edge, not within WordPress.This is the direct bridge to Track B (Payload / Headless). The patterns you implement in a Payload + Next.js project are architecturally identical to what you would build with WordPress + Next.js.
The REST API returns data in a fixed schema; every request fetches a full object even if you need only a few fields. WPGraphQL allows the front end to request exactly the fields it needs, reducing over-fetching. For SEO-specific data, a query fetching meta title, description, canonical, and JSON-LD fields becomes tightly scoped:
query GetPostSEO($slug: String!) {
postBy(slug: $slug) {
title
excerpt
slug
featuredImage {
node {
sourceUrl
altText
}
}
seo {
metaDesc
canonical
opengraphTitle
opengraphDescription
}
}
}
The seo fields in the example above are provided by the Yoast SEO WPGraphQL integration (wp-graphql-yoast-seo plugin). Rank Math has an equivalent.
A WordPress migration that loses rankings is a common and avoidable failure. Migrations include domain changes, hosting moves, URL structure changes, and platform changes from or to WordPress.
noindex (staging sites often have a blanket noindex that must be removed on launch).Every URL that existed on the old site must either:
Do not redirect all old URLs to the homepage. This is a soft 404 and Google ignores the redirect, treating the original URL as a 404 anyway.
Implement redirects at the server level (Nginx, Apache) or CDN level — not via a WordPress plugin, which adds PHP execution overhead to every redirect request.
noindex removed from site settings (Settings → Reading → "Discourage search engines")robots.txt is permissive for GooglebotCreate a child theme for any WordPress installation. In functions.php:
wp_head to inject a custom JSON-LD block for a single custom post type. Pull at least three data points from post meta fields using get_post_meta(). Validate the output in the Schema Markup Validator.document_title_parts filter to modify the title format on that post type — append a suffix or change the separator.wp_dequeue_style() to remove an unnecessary plugin stylesheet on all pages except the one page type that actually uses it.Choose a live WordPress site (your own, a client's, or a publicly accessible one with permission). Produce a written audit report covering:
You have completed Track A when you can do all of the following without looking things up:
add_action, add_filter) to programmatically inject a custom canonical tag and a custom JSON-LD block on a specific post type — with no SEO plugin performing that function.noindex tags, broken redirects, or missing canonicals on the live environment at launch.When all of the above are second nature, move to Track B — Payload (Headless / Code-First) Technical SEO.