SEO, Analytics and Privacy

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), which requires PHP 7.4 at minimum and recommends PHP 8.3 or newer, and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2 is in beta and planned for 2026-12-08; nothing on this page is expected to change with it. Every snippet was run against WordPress 7.1.3 on the official wordpress:7.1.3-php8.4-apache image with MariaDB 11.8 and the wordpress:cli-php8.4 image. The output blocks are real output, with the host shown as https://lumenpress.test/.

The page is for the people who run the Lumen Press site: what core already does for search engines, where an SEO plugin helps, how to check the result in Google’s tools, which common SEO advice has expired, how to add analytics without setting cookies before consent, and how to answer a reader who asks for a copy of their data or for its deletion. The code lives in the lumen-catalog plugin (the book catalogue with the custom post type lp_book) and in one must-use plugin. Writing exporters and erasers for a plugin’s own data is covered in Privacy and Personal Data; this page covers the site owner’s side.

What core does for search engines

A fresh WordPress install already produces most of the technical signals that search engines read. None of them needs a plugin:

Feature (since) What it does

Pretty permalinks (1.5)

Settings > Permalinks turns /?p=5 into paths such as /books/the-lighthouse-ledger/; old ?p= links get a 301 from redirect_canonical(). A new install picks /%year%/%monthnum%/%day%/%postname%/ when the server can rewrite URLs; Lumen Press uses /%postname%/

XML sitemaps (5.5)

A sitemap index at /wp-sitemap.xml, one sub-sitemap per public post type, public taxonomy, and for users

Virtual robots.txt (2.1)

Printed by do_robots() when no physical file exists; a Sitemap: line on public sites since 5.5

Robots meta tag (5.7)

Built by the Robots API (wp_robots filter); max-image-preview:large on public sites

Title tag (4.1)

From wp_get_document_title(); automatic in block themes (5.8), title-tag support in classic themes

Canonical URL (2.9)

<link rel="canonical"> on single posts, pages and custom post type entries

rel="nofollow ugc" (5.3)

On links in comments and on comment author URLs

The default permalink structure and the screens are described in Settings Screens; the rewrite rules behind them in Rewrite API and Permalinks. Changing the structure on a live site breaks inbound links only when the old URLs cannot be resolved: WordPress resolves a changed post slug through the _wp_old_slug meta and redirects, but a structure change from one custom pattern to another relies on redirect_canonical() guessing, so redirects for the old pattern belong in the web server or a redirect plugin.

XML sitemaps

The sitemap index lists one file per provider and sub-type. On the Lumen Press site, after activating lumen-catalog (which registers lp_book, lp_genre and lp_series as public and queryable), core lists them without any extra code:

$ curl -s https://lumenpress.test/wp-sitemap.xml
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://lumenpress.test/wp-sitemap-index.xsl" ?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://lumenpress.test/wp-sitemap-posts-post-1.xml</loc></sitemap>
<sitemap><loc>https://lumenpress.test/wp-sitemap-posts-page-1.xml</loc></sitemap>
<sitemap><loc>https://lumenpress.test/wp-sitemap-posts-lp_book-1.xml</loc></sitemap>
<sitemap><loc>https://lumenpress.test/wp-sitemap-taxonomies-category-1.xml</loc></sitemap>
<sitemap><loc>https://lumenpress.test/wp-sitemap-taxonomies-post_tag-1.xml</loc></sitemap>
<sitemap><loc>https://lumenpress.test/wp-sitemap-taxonomies-lp_genre-1.xml</loc></sitemap>
<sitemap><loc>https://lumenpress.test/wp-sitemap-taxonomies-lp_series-1.xml</loc></sitemap>
<sitemap><loc>https://lumenpress.test/wp-sitemap-users-1.xml</loc></sitemap>
</sitemapindex>

The output is shown one entry per line; core prints the index on a single line. The XSL stylesheet only makes the file readable in a browser; crawlers ignore it. How the sitemaps behave:

  • Providers. Three are built in: posts (each public post type except attachments), taxonomies (each public taxonomy with at least one term in use) and users (authors with published posts). Each sub-sitemap is split into pages of 2,000 URLs (wp-sitemap-posts-lp_book-2.xml and so on); the sitemaps protocol allows up to 50,000.

  • Entries. Each entry has only <loc>. Core writes no <lastmod>, <changefreq> or <priority>; the first can be added with a filter, and the other two are ignored by Google.

  • Exclusions. Attachments, non-public post types and taxonomies, empty terms and posts that are not published are left out. Password-protected posts are listed (their URL exists, only the content is hidden); a site that wants them out uses wp_sitemaps_posts_query_args with 'has_password' => false. Core knows nothing about per-post noindex choices made in an SEO plugin; plugins that offer them remove those posts from the sitemap themselves.

  • Visibility. When Settings > Reading > "Discourage search engines from indexing this site" is ticked (blog_public is 0), wp_sitemaps_enabled returns false and every sitemap URL answers 404.

  • URLs. With plain permalinks the index is /?sitemap=index, which redirects to /wp-sitemap.xml once pretty permalinks are on. A request for /sitemap.xml that no page or plugin claims is redirected (301) to /wp-sitemap.xml.

  • SEO plugins. Yoast SEO, Rank Math and similar plugins switch the core sitemaps off and serve their own (usually /sitemap_index.xml); the core index then answers with a redirect or 404, depending on the plugin.

The wp_sitemaps_* filters change what core lists. The Lumen Press catalogue drops the users sub-sitemap (author archives are thin pages on this site), leaves tags out, and adds <lastmod> to every post, page and book:

<?php
/**
 * Lumen Catalog: sitemap, robots and title adjustments.
 * File: wp-content/plugins/lumen-catalog/includes/seo.php, loaded from lumen-catalog.php with
 * require_once __DIR__ . '/includes/seo.php';
 *
 * @package lumen-catalog
 */

defined( 'ABSPATH' ) || exit;

// 1. Drop the users sub-sitemap: author archives are not useful landing pages.
add_filter(
	'wp_sitemaps_add_provider',
	function ( $provider, $name ) {
		return ( 'users' === $name ) ? false : $provider;
	},
	10,
	2
);

// 2. List categories, genres and series, but not tags.
add_filter(
	'wp_sitemaps_taxonomies',
	function ( $taxonomies ) {
		unset( $taxonomies['post_tag'] );
		return $taxonomies;
	}
);

// 3. Add <lastmod> to every post, page and book URL.
add_filter(
	'wp_sitemaps_posts_entry',
	function ( $entry, $post ) {
		$entry['lastmod'] = get_post_modified_time( DATE_W3C, true, $post );
		return $entry;
	},
	10,
	2
);

After activating the plugin, the book sub-sitemap carries the modification date:

$ curl -s https://lumenpress.test/wp-sitemap-posts-lp_book-1.xml
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="https://lumenpress.test/wp-sitemap.xsl" ?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://lumenpress.test/books/the-lighthouse-ledger/</loc><lastmod>2026-10-28T10:27:30+00:00</lastmod></url>
</urlset>

The other filters, all since 5.5: wp_sitemaps_enabled (return false, for example with __return_false, to switch sitemaps off; the rewrite rules stay, so the URLs answer 404 instead of falling through to a page), wp_sitemaps_post_types (remove post types), wp_sitemaps_posts_query_args, wp_sitemaps_taxonomies_query_args and wp_sitemaps_users_query_args (change the query, for example to exclude IDs), wp_sitemaps_taxonomies_entry, wp_sitemaps_users_entry and wp_sitemaps_index_entry (add fields to one entry), wp_sitemaps_max_urls (URLs per page, per object type), wp_sitemaps_*_pre_max_num_pages (short-circuit the page count) and wp_sitemaps_stylesheet_url with its relatives (change or remove the XSL presentation).

A new kind of content (for example events kept in a custom table) gets its own provider: a class extending WP_Sitemaps_Provider that implements get_url_list() and get_max_num_pages(), registered with wp_register_sitemap_provider( 'lumen-events', $provider ) on init. Content in custom post types needs no provider, as lp_book shows.

The virtual robots.txt

When no physical robots.txt exists in the document root, the web server passes the request to index.php, and do_robots() prints the file. On a public site, and then with "Discourage search engines" ticked (the Sitemap: line disappears and the sitemaps answer 404):

$ curl -s https://lumenpress.test/robots.txt
User-agent: *
Disallow: /wp-admin/
Allow: /wp-admin/admin-ajax.php

Sitemap: https://lumenpress.test/wp-sitemap.xml
$ wp option update blog_public 0
Success: Updated 'blog_public' option.
$ curl -s https://lumenpress.test/robots.txt
User-agent: *
Disallow: /wp-admin/
Allow: /wp-admin/admin-ajax.php
$ curl -s -o /dev/null -w '%{http_code}\n' https://lumenpress.test/wp-sitemap.xml
404

Before WordPress 5.3, a private site’s robots.txt contained Disallow: /. That rule stops crawling but not indexing: a blocked URL can still be indexed from links elsewhere, and the crawler never sees the page’s noindex. Since 5.3 core relies on the robots meta tag instead (next section), and the robots.txt stays permissive.

The robots_txt filter changes the output. The Lumen Press plugin asks crawlers to skip its REST routes. Core appends the Sitemap: line from a callback at priority 0, so the plugin’s callback runs at -1 to keep its rule inside the User-agent: * group:

// 4. Ask crawlers to skip the plugin's REST routes (crawl control, not access control).
// Priority -1 runs before core appends the "Sitemap:" line at priority 0, so the rule stays in the "*" group.
add_filter(
	'robots_txt',
	function ( $output, $is_public ) {
		if ( $is_public ) {
			$output .= "Disallow: /wp-json/lumen/v1/\n";
		}
		return $output;
	},
	-1,
	2
);
User-agent: *
Disallow: /wp-admin/
Allow: /wp-admin/admin-ajax.php
Disallow: /wp-json/lumen/v1/

Sitemap: https://lumenpress.test/wp-sitemap.xml

Pitfalls:

  • A physical robots.txt in the document root (left by a host, an old plugin or a migration) wins: the web server serves it and WordPress never runs, so neither the setting nor the filter has any effect. Delete the file to get the virtual one back.

  • On nginx, /robots.txt reaches WordPress only if the try_files rule passes unknown files to index.php; a location = /robots.txt { access_log off; } block without try_files returns 404. See Web Servers: Apache and nginx.

  • robots.txt is a request to well-behaved crawlers, not access control. It is public, so listing private paths in it advertises them. Google does not support a noindex rule in robots.txt; use the meta tag or an X-Robots-Tag header.

  • WordPress in a subdirectory (https://example.com/blog/) cannot serve /robots.txt, because crawlers only read it from the host root; the file must be placed at the root by other means.

The robots meta tag and the Reading setting

Since 5.7 the robots meta tag is assembled by the Robots API: wp_robots() runs on wp_head and prints the directives returned by the wp_robots filter. Core’s callbacks: wp_robots_noindex (noindex, nofollow on every front-end page when blog_public is 0), wp_robots_noindex_search (noindex on search results), wp_robots_noindex_embeds (noindex on the /embed/ view of a post), wp_robots_max_image_preview_large (max-image-preview:large on public sites, allowing large image previews) and, on the login screen, wp_robots_sensitive_page (noindex, noarchive; it replaces wp_sensitive_page_meta(), deprecated in 5.7). The helper wp_robots_no_robots returns noindex plus follow or nofollow depending on blog_public; core also adds it for comment reply links (?replytocom=) and unapproved comment previews.

The output on the Lumen Press site, first public and then with the Reading setting ticked:

$ curl -s https://lumenpress.test/books/the-lighthouse-ledger/ | grep "name='robots'"
<meta name='robots' content='max-image-preview:large' />
$ curl -s 'https://lumenpress.test/?s=lighthouse' | grep "name='robots'"
<meta name='robots' content='noindex, follow, max-image-preview:large' />
$ wp option update blog_public 0
$ curl -s https://lumenpress.test/ | grep "name='robots'"
<meta name='robots' content='noindex, nofollow' />

The setting is labelled "Search engine visibility" on Settings > Reading, and the screen itself warns that it is up to search engines to honour the request. While it is ticked, the dashboard’s At a Glance box shows "Search engines discouraged". It is the right switch for staging copies and a site under construction; on staging it is often safer to add HTTP authentication as well, because a page indexed before the switch was ticked can stay in results until it is recrawled. Forgetting to untick it at launch is one of the most common reasons a new WordPress site does not appear in search results; a deployment check of wp option get blog_public catches it.

A plugin adds directives through the same filter. Lumen Press keeps date archives and attachment pages out of the index, while still letting crawlers follow their links:

// 5. Keep date archives and attachment pages out of search results, but let crawlers follow their links.
add_filter(
	'wp_robots',
	function ( array $robots ) {
		if ( is_date() || is_attachment() ) {
			$robots['noindex'] = true;
			$robots['follow']  = true;
		}
		return $robots;
	}
);
$ curl -s https://lumenpress.test/2026/10/ | grep "name='robots'"
<meta name='robots' content='max-image-preview:large, noindex, follow' />

A date archive such as /2026/10/ then prints max-image-preview:large, noindex, follow. Each array key is a directive; a value of true prints the bare name, a string prints name:value. A directive set to false is dropped, which is how a callback removes one that an earlier callback added. Attachment pages themselves can be switched off: since 6.4 new installs set the wp_attachment_pages_enabled option to 0, which redirects attachment URLs to the file, and the noindex above matters only for older sites that keep them.

Block themes always print <title> (core hooks _block_template_render_title_tag() to wp_head when it renders a block template); classic themes declare add_theme_support( 'title-tag' ) and must not hard-code a <title> in header.php. In both cases the text comes from wp_get_document_title(): the post or archive title, the page number for paginated views, and the site name (with the tagline on the front page), joined by a separator.

Three filters change it: pre_get_document_title (return a non-empty string to replace the whole title; SEO plugins use it), document_title_parts (the title, page, tagline and site parts before joining) and document_title_separator (default -, printed as an en dash).

Lumen Press adds the content type to book titles and uses a vertical bar:

// 6. "Book title | Books | Lumen Press" for single books.
add_filter(
	'document_title_parts',
	function ( $parts ) {
		if ( is_singular( 'lp_book' ) ) {
			$parts = array(
				'title' => $parts['title'],
				'type'  => __( 'Books', 'lumen-catalog' ),
				'site'  => get_bloginfo( 'name', 'display' ),
			);
		}
		return $parts;
	}
);

add_filter(
	'document_title_separator',
	function () {
		return '|';
	}
);

The <head> of a single book, with the default theme Twenty Twenty-Five 1.5 active:

<meta name='robots' content='max-image-preview:large' />
<title>The Lighthouse Ledger | Books | Lumen Press</title>
<link rel="canonical" href="https://lumenpress.test/books/the-lighthouse-ledger/" />
<link rel='shortlink' href='https://lumenpress.test/?p=5' />

rel_canonical() prints the canonical link on singular views only (posts, pages, lp_book entries), using wp_get_canonical_url(); for paginated posts and comment pages it adds the page number. Archives, the blog index and search results get no canonical link from core, and the get_canonical_url filter changes the URL for singular views. Google treats the canonical link as a strong hint, not a directive, and combines it with redirects, sitemaps and internal links. The shortlink (?p=5) is a convenience for sharing; it redirects to the permalink and has no SEO role.

The search engine shows its own title link, which may differ from the <title>; a title that describes the page and is not stuffed with keywords is the one most likely to be used.

Since 5.3, wp_rel_ugc() runs on pre_comment_content and adds rel="nofollow ugc" to every link a visitor writes in a comment, and the comment author’s website link is printed with rel="external nofollow ugc". ugc tells search engines that the link is user-generated content; nofollow is kept for crawlers that do not know ugc. A comment saved through wp_new_comment() with the body see <a href="https://reviews.example.net/ledger">my review</a> is stored as see <a href="https://reviews.example.net/ledger" rel="nofollow ugc">my review</a>. Source: Make WordPress Core: WP 5.3 introduces new functions to add UGC attribute to links

The attribute is added when the comment is saved through wp_new_comment() (the comment form, the REST API, the XML-RPC API). Comments inserted directly with wp_insert_comment(), including wp comment create, skip the filter and keep their links unchanged.

What SEO plugins add

Core covers the technical basics; it has no per-post SEO title or meta description field, no social preview tags (Open Graph, X/Twitter cards), no structured data beyond what the theme prints, no redirect manager, and no content analysis. SEO plugins add these. The best known, all in the WordPress.org Plugin Directory, are Yoast SEO, Rank Math SEO, All in One SEO and The SEO Framework.

Their feature sets overlap: an editor panel or sidebar for the SEO title, meta description and social image of each post; per-post and per-archive noindex with matching sitemap exclusion; their own XML sitemaps replacing the core ones; a schema.org graph (Organization, WebSite, Article, BreadcrumbList) in JSON-LD; breadcrumbs; redirects and 404 logs (often in paid editions); and readability or keyword feedback. Choosing one is a matter of interface, which features are free, and how the plugin stores data (custom tables, post meta), which matters when switching later; most offer an importer from the others. The vetting checklist in Choosing and Managing Plugins and Themes applies.

Run only one SEO plugin at a time. Two of them print duplicate title, canonical, robots and JSON-LD tags, and both try to own the sitemap URL. After activating one, check the page source for a single <title>, a single canonical link and a single robots meta tag, and check which sitemap URL it announces in robots.txt. Keyword-density scores and "traffic lights" are a writing aid, not a ranking factor; Google’s documentation describes ranking in terms of helpful, people-first content and does not use a keyword percentage.

Structured data and the Rich Results Test

Structured data describes a page in a vocabulary that search engines understand (schema.org), most often as a JSON-LD <script> in the page. It makes a page eligible for rich results, such as review stars, breadcrumbs or book actions, but eligibility does not guarantee display, and markup must describe content that is visible on the page.

The Lumen Press catalogue prints a schema.org Book for each lp_book from its meta (lp_isbn, lp_pages, lp_published_on) and its genre:

<?php
/**
 * Lumen Catalog: schema.org Book data for single book pages.
 * File: wp-content/plugins/lumen-catalog/includes/schema.php, loaded from lumen-catalog.php.
 *
 * @package lumen-catalog
 */

defined( 'ABSPATH' ) || exit;

add_action(
	'wp_head',
	function () {
		if ( ! is_singular( 'lp_book' ) ) {
			return;
		}

		$book = get_queried_object();
		$data = array(
			'@context'  => 'https://schema.org',
			'@type'     => 'Book',
			'name'      => get_the_title( $book ),
			'url'       => get_permalink( $book ),
			'publisher' => array(
				'@type' => 'Organization',
				'name'  => get_bloginfo( 'name' ),
			),
		);

		$genre = get_the_terms( $book, 'lp_genre' );
		$extra = array(
			'isbn'          => get_post_meta( $book->ID, 'lp_isbn', true ),
			'numberOfPages' => (int) get_post_meta( $book->ID, 'lp_pages', true ),
			'datePublished' => get_post_meta( $book->ID, 'lp_published_on', true ),
			'genre'         => ( $genre && ! is_wp_error( $genre ) ) ? $genre[0]->name : '',
			'description'   => has_excerpt( $book ) ? wp_strip_all_tags( get_the_excerpt( $book ) ) : '',
		);
		$data += array_filter( $extra ); // Leave out empty values.

		// wp_json_encode() escapes "/" by default, so "</script>" in a title cannot close the tag.
		wp_print_inline_script_tag(
			wp_json_encode( $data ),
			array( 'type' => 'application/ld+json' )
		);
	}
);

The printed tag, for a book with ISBN, 312 pages and the genre "Literary Fiction":

<script type="application/ld+json">
{"@context":"https:\/\/schema.org","@type":"Book","name":"The Lighthouse Ledger","url":"https:\/\/lumenpress.test\/books\/the-lighthouse-ledger\/","publisher":{"@type":"Organization","name":"Lumen Press"},"isbn":"978-1-23456-789-7","numberOfPages":312,"datePublished":"2026-09-15","genre":"Literary Fiction","description":"A keeper's logbook turns into a family mystery."}
</script>

Points to watch:

  • Do not pass JSON_UNESCAPED_SLASHES to wp_json_encode() here: the escaped \/ is what stops a title containing </script> from ending the script element early.

  • If an SEO plugin already prints a schema graph, add the Book to that graph through the plugin’s own filter rather than printing a second, unconnected block. Two blocks are valid, but a single graph with @id references is easier for search engines to relate.

  • Google’s Book rich result (book actions: read or buy links) is fed by a data feed that publishers submit to Google, not by markup on a page; the markup above still describes the book to any other consumer of schema.org, and the Schema Markup Validator checks it.

Two official validators replace the older Structured Data Testing Tool, which Google retired: the Rich Results Test checks which Google rich results a page or code snippet is eligible for, and the Schema Markup Validator checks any schema.org markup against the vocabulary, independent of Google features. A local site such as lumenpress.test is not reachable by the Rich Results Test; paste the page source into its code tab instead, or test the staging URL.

Search Console

Google Search Console is where a site owner sees how Google crawls and indexes the site. Setting it up for Lumen Press:

  1. Add a property. A Domain property (lumenpress.example) covers every protocol and subdomain and is verified with a DNS TXT record; a URL-prefix property (https://lumenpress.example/) can also be verified with an HTML file, a meta tag (most SEO plugins have a field for it), Google Analytics or Google Tag Manager.

  2. Submit the sitemap index under Indexing > Sitemaps: https://lumenpress.example/wp-sitemap.xml (or the SEO plugin’s index). The Sitemap: line in robots.txt already announces it; submitting it shows its processing status and errors.

  3. Use URL Inspection to see how Google last crawled a page, the canonical it chose, and to request indexing of a new or changed page.

  4. Watch the Page indexing report ("Crawled - currently not indexed", "Excluded by noindex tag", "Duplicate without user-selected canonical"), the Core Web Vitals and HTTPS reports, and the Links report for inbound links.

Bing Webmaster Tools offers the same functions for Bing and can import a verified Search Console property. Neither needs a ping to a sitemap URL.

SEO advice that has expired

Several SEO practices that older books and tutorials still recommend no longer work or never worked with today’s search engines:

Older advice Current state

"Ping" Google or Bing with the sitemap URL after publishing, or install a plugin that does

Google deprecated its sitemap ping endpoint in June 2023 and it stopped working six months later; most ping submissions were spam. Announce the sitemap in robots.txt and submit it in Search Console. The Update Services list on Settings > Writing pings blog update services such as Ping-o-Matic, not search engines' sitemap endpoints.

Set <priority> and <changefreq> for each URL in the sitemap

Google ignores both. It uses <lastmod> when the value is consistently accurate, so set lastmod only from real content changes (as the filter above does from post_modified_gmt).

The sitemap is at /sitemap.xml

Core’s index is /wp-sitemap.xml since 5.5; /sitemap.xml only redirects to it when nothing else uses that path, and SEO plugins use their own names. Read the Sitemap: line in robots.txt to find the real one.

Use the Google link: search operator to see who links to the site

The operator is no longer supported. Use the Links report in Search Console.

Test structured data in the Structured Data Testing Tool

Retired; use the Rich Results Test and the Schema Markup Validator.

Build AMP versions of posts to rank or to appear in Top Stories

AMP is not required for any Google Search feature, and pages are evaluated by the same standards whatever technology builds them. Core has no AMP output; the AMP plugin still exists for sites that want it, but it is an extra template layer to maintain.

Submit the site to web directories and link exchanges to build links

Paid or exchanged links meant to manipulate ranking are a spam policy violation, and generic directories pass no value. Earn links with content; mark paid or sponsored links with rel="sponsored".

Turn on IP anonymization in Google Analytics

A Universal Analytics setting. Google Analytics 4 does not log or store IP addresses, so there is no setting to turn on; consent and data retention settings are what matter now.

Hide the site from search by putting Disallow: / in robots.txt, or rely on core doing so

Core stopped doing this in 5.3 and uses noindex instead; a disallowed URL can still be indexed from external links.

Analytics

Core has no analytics. Three common ways to add it:

  • Site Kit by Google (wordpress.org/plugins/google-site-kit): an official Google plugin that connects Search Console, Google Analytics 4, PageSpeed Insights and, optionally, AdSense and Tag Manager, places the tag, and shows their reports in the dashboard. It also verifies the Search Console property. The WP Consent API listing (below) names Site Kit among the plugins that read its consent signals.

  • A tag manager or analytics plugin from another vendor, or a self-hosted, cookieless analytics tool; the choice depends on what the site needs to measure and on the jurisdictions of its readers.

  • A few lines of code, when the site needs only the measurement tag and has its own consent banner.

The third option, as a must-use plugin, loads the Google tag asynchronously, only on production, not for logged-in editors, and with Google’s consent mode defaults set to denied before the tag runs. The consent banner (a plugin or the site’s own script) later calls gtag( 'consent', 'update', ... ) when the visitor accepts:

<?php
/**
 * Plugin Name: Lumen Analytics
 * Description: Loads the Google tag with every consent type denied until the consent banner updates it.
 *
 * File: wp-content/mu-plugins/lumen-analytics.php
 * The measurement ID comes from wp-config.php: define( 'LUMEN_GA_ID', 'G-XXXXXXXXXX' );
 */

defined( 'ABSPATH' ) || exit;

add_action(
	'wp_enqueue_scripts',
	function () {
		if ( ! defined( 'LUMEN_GA_ID' ) || 'production' !== wp_get_environment_type() || is_user_logged_in() ) {
			return;
		}

		$id = LUMEN_GA_ID;

		wp_enqueue_script(
			'lumen-gtag',
			'https://www.googletagmanager.com/gtag/js?id=' . rawurlencode( $id ),
			array(),
			null, // No ?ver= on a third-party URL.
			array( 'strategy' => 'async' )
		);

		// Consent defaults must run before the tag; the banner later calls gtag( 'consent', 'update', ... ).
		$before = "window.dataLayer = window.dataLayer || [];\n"
			. "function gtag(){dataLayer.push(arguments);}\n"
			. "gtag('consent', 'default', {\n"
			. "  'ad_storage': 'denied',\n"
			. "  'ad_user_data': 'denied',\n"
			. "  'ad_personalization': 'denied',\n"
			. "  'analytics_storage': 'denied'\n"
			. "});\n"
			. "gtag('js', new Date());\n"
			. "gtag('config', " . wp_json_encode( $id ) . ');';

		wp_add_inline_script( 'lumen-gtag', $before, 'before' );
	}
);

With WP_ENVIRONMENT_TYPE set to production and LUMEN_GA_ID defined, an anonymous visitor’s page contains an inline <script id="lumen-gtag-js-before"> with the consent defaults, followed by <script async data-wp-strategy="async" id="lumen-gtag-js" src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX">.

The strategy argument of wp_enqueue_script() (since 6.3) prints async and keeps a before inline script in place; an after inline script would make WordPress fall back to a blocking script. With consent denied, the tag sets no analytics cookies and sends only cookieless pings, which Google may use for modelling. Whether even that is allowed without prior consent depends on the law that applies to the site’s readers; the code does not settle that question. The constant keeps the measurement ID out of the code and lets staging run without analytics; see Local Development Environments for WP_ENVIRONMENT_TYPE.

Privacy

WordPress 4.9.6 (May 2018, timed for the EU General Data Protection Regulation) added the privacy tools that every site has: a privacy policy page, a policy guide, and request-based export and erasure of personal data. They help with compliance; they do not make a site compliant by themselves.

The privacy policy page

A new install creates a draft page titled "Privacy Policy" and stores its ID in the wp_page_for_privacy_policy option. Settings > Privacy (options-privacy.php) chooses or creates the page, and its Policy Guide tab (since 5.3) collects suggested text from core and from active plugins that call wp_add_privacy_policy_content(); the guide also flags text from plugins that have been deactivated since. The suggested text is a starting point that the site owner edits, not legal wording. Once the page is published:

$ wp post update 3 --post_status=publish
Success: Updated post 3.
$ wp option get wp_page_for_privacy_policy
3
$ wp eval 'echo get_the_privacy_policy_link(), PHP_EOL;'
<a class="privacy-policy-link" href="https://lumenpress.test/privacy-policy/" rel="privacy-policy">Privacy Policy</a>

Core then links the page from the login and registration screens, and themes link it with the_privacy_policy_link() or, in block themes, by adding the page to a footer Navigation block. While the selected page is not published, get_privacy_policy_url() returns an empty string and the link functions print nothing.

Core’s own cookies are few: the authentication and wordpress_test_cookie cookies for logged-in users, and the comment author cookies (comment_author_*), which since 4.9.6 are set only if the visitor ticks the "Save my name, email, and website" checkbox on the comment form (Settings > Discussion, option show_comments_cookies_opt_in). Embeds, analytics, fonts loaded from third-party CDNs and plugins add the rest; a block theme that registers its fonts through theme.json or the Font Library serves them locally (see Typography and Fonts).

Exporting and erasing personal data

Tools > Export Personal Data and Tools > Erase Personal Data handle a person’s request by email address:

  1. The administrator enters the username or email address. A request (a post of type user_request, title = email, post_name = export_personal_data or remove_personal_data) is created with status Pending. With the "Confirmation email" box ticked, WordPress emails a confirmation link (wp-login.php?action=confirmaction).

  2. When the person clicks it, the status becomes Confirmed. Unconfirmed requests expire after a day (user_request_key_expiration filter) and are marked Failed. Until 7.0 that happened only when one of the two screens was opened; since 7.1 the daily cron event wp_privacy_personal_data_cleanup_requests does it as well. A data removal request could be crafted to cause stored XSS on these screens until WordPress 7.1.4 (2026-10-28) fixed it (WordPress News: WordPress 7.1.4).

  3. For an export, Send Export Link or Download personal data runs every registered exporter page by page over Ajax and builds a ZIP file in wp-content/uploads/wp-personal-data-exports/ with index.html and, since 5.4, export.json. The file name contains a random string; the directory has an empty index.php so it cannot be listed. The hourly cron event wp_privacy_delete_old_export_files deletes files older than three days (wp_privacy_export_expiration filter; older documentation says 48 hours).

  4. For an erasure, Erase personal data runs every eraser. Core anonymizes the person’s comments (author name becomes "Anonymous", email, URL, IP and user agent are cleared) but does not delete the user account; deleting it is a separate step under Users. Plugins report items they removed and items they had to retain, with a reason (for example, orders kept for tax law).

  5. The request becomes Completed, and can be removed from the list.

Core registers exporters for the user’s profile (including the community events location and session tokens), comments and media uploaded by the person, and an eraser for comments. Plugins add their own through the wp_privacy_personal_data_exporters and wp_privacy_personal_data_erasers filters; data that a plugin stores without registering one is not in the export and is not erased.

The screens need export_others_personal_data or erase_others_personal_data; these meta capabilities map to manage_options on a single site (administrators only) and to manage_network on Multisite (super admins only). Erasure additionally requires delete_users.

The same flow from WP-CLI

WP-CLI 2.12.0 has no bundled privacy command, but wp eval-file can run the same code path as the screens. This script creates a request that is already confirmed (for example, after verifying the person’s identity by other means), runs each exporter page by page through the filter the admin screen uses, and lets that filter write the ZIP file after the last page:

<?php
/**
 * Exports one person's data the way Tools > Export Personal Data does.
 * Usage: wp eval-file privacy-export.php <email> --user=<admin>
 */

require_once ABSPATH . 'wp-admin/includes/admin.php';

$email = sanitize_email( $args[0] ?? '' );
if ( ! is_email( $email ) ) {
	WP_CLI::error( 'Pass an email address.' );
}

// A request that is already confirmed (for instance, verified by phone or in person).
$request_id = wp_create_user_request( $email, 'export_personal_data', array(), 'confirmed' );
if ( is_wp_error( $request_id ) ) {
	WP_CLI::error( $request_id );
}

$exporters = apply_filters( 'wp_privacy_personal_data_exporters', array() );
$index     = 0;

foreach ( $exporters as $key => $exporter ) {
	++$index;
	$page = 1;
	do {
		$response = call_user_func( $exporter['callback'], $email, $page );
		// The same filter the admin screen uses; after the last page it writes the ZIP file.
		$response = apply_filters( 'wp_privacy_personal_data_export_page', $response, $index, $email, $page, $request_id, false, $key );
		WP_CLI::log( sprintf( '%s, page %d: %d item(s)', $exporter['exporter_friendly_name'], $page, count( $response['data'] ) ) );
		++$page;
	} while ( empty( $response['done'] ) );
}

_wp_privacy_completed_request( $request_id );
WP_CLI::success( 'Export file: ' . wp_privacy_exports_url() . get_post_meta( $request_id, '_export_file_name', true ) );
$ wp eval-file privacy-export.php rosa@example.com --user=lp_admin
WordPress User, page 1: 0 item(s)
WordPress Comments, page 1: 2 item(s)
WordPress Media, page 1: 0 item(s)
Success: Export file: https://lumenpress.test/wp-content/uploads/wp-personal-data-exports/wp-personal-data-file-<random>.zip
$ wp post list --post_type=user_request --post_status=any --fields=ID,post_title,post_name,post_status
ID	post_title	post_name	post_status
6	rosa@example.com	export_personal_data	request-completed

The ZIP file contains index.html and export.json. The status argument of wp_create_user_request() exists since 5.7; without it, the request starts as pending and needs the emailed confirmation. A second request for the same email and action is refused ("An incomplete personal data request for this email address already exists") while an earlier one is not yet completed.

Erasure follows the same pattern: wp_create_user_request( $email, 'remove_personal_data', array(), 'confirmed' ), then each callback from wp_privacy_personal_data_erasers, passed through the wp_privacy_personal_data_erasure_page filter with the arguments $response, $index, $email, $page, $request_id; after the last eraser the filter marks the request completed. Each response reports items_removed, items_retained, messages and done. Run against the same reader, the core comment eraser reported "removed yes, retained no", and wp comment list then showed both of the reader’s comments with the author "Anonymous" and empty email and URL fields.

Both scripts call internal functions (_wp_privacy_completed_request(), the admin includes) and filters whose contract belongs to the admin screens, so they should be re-run against each new major version before being relied on. On completion, the erasure filter also fires wp_privacy_personal_data_erased, which emails the person a confirmation; a host without working mail (see Email Delivery) silently drops it.

Core does not ask for cookie consent: it sets no tracking cookies of its own, and the comment cookies already require the opt-in checkbox. Consent banners come from plugins or the site’s own code, and the requirements (prior consent for non-essential cookies, a reject option as easy as accept, a record of consent) come from the law that applies to the site’s readers, such as the EU ePrivacy rules and GDPR.

The WP Consent API (wordpress.org/plugins/wp-consent-api), a community-maintained plugin that is not part of core, gives banners and other plugins a shared vocabulary: a consent management plugin records the visitor’s choice per category (functional, preferences, statistics, statistics-anonymous, marketing), and plugins that set cookies ask wp_has_consent( 'statistics' ) before doing so. The function exists only while the plugin is active, so code that uses it checks function_exists( 'wp_has_consent' ) first. The plugin’s listing names the plugins known to read these signals, Site Kit among them.

For Lumen Press, the order of work is: list every cookie and third-party request the site makes (browser developer tools, with a clean profile), remove what is not needed, self-host fonts, describe the rest on the privacy policy page, and only then choose a banner that blocks or defers the remaining scripts until consent and passes the choice on through consent mode or the WP Consent API.

Older books

The SEO chapters of the older books predate several of the changes above. One book still recommends sitemap pings and the link: operator; another points readers to /sitemap.xml, to the Structured Data Testing Tool and to concatenating assets for speed; a third recommends sitemap priorities, AMP and GA4 IP anonymization. The table in "SEO advice that has expired" gives the current state of each. Their advice on writing descriptive titles, meaningful permalinks, alt text and internal links still holds. The broader list of corrections is in Where Older Books and Tutorials Are Outdated. WooCommerce product SEO and structured data for shops are covered in Themes: Block and Classic, not here.