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 |
XML sitemaps (5.5) |
A sitemap index at |
Virtual |
Printed by |
Robots meta tag (5.7) |
Built by the Robots API ( |
Title tag (4.1) |
From |
Canonical URL (2.9) |
|
|
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) andusers(authors with published posts). Each sub-sitemap is split into pages of 2,000 URLs (wp-sitemap-posts-lp_book-2.xmland 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_argswith'has_password' => false. Core knows nothing about per-postnoindexchoices 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_publicis0),wp_sitemaps_enabledreturns false and every sitemap URL answers 404. -
URLs. With plain permalinks the index is
/?sitemap=index, which redirects to/wp-sitemap.xmlonce pretty permalinks are on. A request for/sitemap.xmlthat 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
Source: Code Reference: do_robots()
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
);
Source: Code Reference: robots_txt hook
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.txtin 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.txtreaches WordPress only if thetry_filesrule passes unknown files toindex.php; alocation = /robots.txt { access_log off; }block withouttry_filesreturns 404. See Web Servers: Apache and nginx. -
robots.txtis a request to well-behaved crawlers, not access control. It is public, so listing private paths in it advertises them. Google does not support anoindexrule inrobots.txt; use the meta tag or anX-Robots-Tagheader. -
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;
}
);
Source: Code Reference: wp_robots hook
$ 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.
Title tag, canonical URL and shortlink
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.
Links in comments: rel="nofollow ugc"
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_SLASHEStowp_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
Bookto that graph through the plugin’s own filter rather than printing a second, unconnected block. Two blocks are valid, but a single graph with@idreferences 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:
-
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. -
Submit the sitemap index under Indexing > Sitemaps:
https://lumenpress.example/wp-sitemap.xml(or the SEO plugin’s index). TheSitemap:line inrobots.txtalready announces it; submitting it shows its processing status and errors. -
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.
-
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 |
Set |
Google ignores both. It uses |
The sitemap is at |
Core’s index is |
Use the Google |
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 |
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 |
Core stopped doing this in 5.3 and uses |
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:
-
The administrator enters the username or email address. A request (a post of type
user_request, title = email,post_name=export_personal_dataorremove_personal_data) is created with status Pending. With the "Confirmation email" box ticked, WordPress emails a confirmation link (wp-login.php?action=confirmaction). -
When the person clicks it, the status becomes Confirmed. Unconfirmed requests expire after a day (
user_request_key_expirationfilter) and are marked Failed. Until 7.0 that happened only when one of the two screens was opened; since 7.1 the daily cron eventwp_privacy_personal_data_cleanup_requestsdoes 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). -
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/withindex.htmland, since 5.4,export.json. The file name contains a random string; the directory has an emptyindex.phpso it cannot be listed. The hourly cron eventwp_privacy_delete_old_export_filesdeletes files older than three days (wp_privacy_export_expirationfilter; older documentation says 48 hours). -
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).
-
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.
Cookie consent and consent signals
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.
References
-
Make WordPress Core: New XML Sitemaps Functionality in WordPress 5.5
-
Make WordPress Core: Robots API and max-image-preview directive in WordPress 5.7
-
Make WordPress Core: WP 5.3 introduces new functions to add UGC attribute to links
-
WordPress.org Documentation: Tools Export Personal Data Screen
-
Google Search Central Blog: Sitemaps ping endpoint is going away