The visibility command center for WordPress sites in the agentic web — a unified wp-admin screen that consolidates SEO, sitemaps, AI discoverability, and site verification settings across all site types (self-hosted, Atomic/WoW, Simple).
This package is built up across a stacked series of PRs (see #48154 for the split plan). It currently provides, on a wp-admin page registered at admin.php?page=jetpack-seo (gated on the seo-tools module being active):
- Overview — a dashboard with Site visibility, Site verification, AI access, and Content SEO cards. Visibility reports indexing directly and the sitemap by its effective state; verification lists Google, Bing and Facebook plus any other service the site has verified with. AI access summarizes the crawler groups and llms.txt, or the one reason per-crawler settings can't apply. Content SEO shows factual coverage rings (schema, SEO title, meta description, search visibility) with literal counts, each deep-linking to the content still missing that field. The visibility/verification cards deep-link into the matching Settings sections, AI access into the GEO tab.
- Settings — search-engine indexing, the XML sitemap (with a "View sitemap" link whenever it's on, and turning it off removes the site's sitemap entirely — including WordPress core's own — for a proper 404; the toggle is disabled while indexing is blocked, since a sitemap can't be served then), canonical URLs, the title structure for all page types (front page, posts, pages, tags, archives), the front-page description, site verification codes, and a single site-level Schema module (a BreadcrumbList toggle plus the site's Organization details, with LocalBusiness nested as a refinement of the Organization and one Save for both), plus the signed-in user's Author profile card, read-only search & social previews of the home page (Google / Facebook / X), and an Advanced module at the foot of the tab holding the turn-off-SEO-tools control — hidden on WordPress.com Simple, where
Modules::is_active()reports every module active so it couldn't take effect. - Content — a DataViews list of published content across every supported post type, showing each item's SEO state (schema type, meta description set, search visibility). Rows link to the Gutenberg editor and open a per-post SEO inspector (custom title, description, schema type, noindex, SERP preview) in the dashboard sidebar. Filters for post type, schema type, description state, and search visibility run client-side over the merged set. The Schema type control and SEO columns also appear in the block editor and
edit.phppost-list tables respectively. JSON-LD (Article / FAQ) is emitted onwp_head. - GEO — llms.txt generation, the plan-gated AI SEO Enhancer, and per-crawler allow/block controls for answer engines and training crawlers (written to the virtual robots.txt). (Generative Engine Optimization; internal route/id
ai.)
Built as a @wordpress/build (wp-build) dashboard, the pattern shared by recently-shipped Jetpack admin pages (Podcast, Scan, Forms, Newsletter):
- PHP:
Automattic\Jetpack\SEO\Initializerboots the package and gates the surface behind its feature flag and cohort;Admin_Pageregisters the admin menu viaAdmin_Menu::add_menu(), loads wp-build's generated bundle (build/build.php+WP_Build_Polyfills::register()), and bootstraps the app's initial state. Because the user-facing slug (jetpack-seo) differs from wp-build's page slug (jetpack-seo-dashboard), the screen id is aliased oncurrent_screenso wp-build's auto-generated enqueue callback fires. - React: an ES-module bundle. Each top-level view (Overview, Settings, Content, GEO) is its own
@wordpress/routeroute underroutes/, exporting astage— the per-route pattern shared with Forms. The Content route also exports aninspector(the per-post SEO editor), rendered in wp-build's native Stage/Inspector sidebar when a post is selected (?postId=). A shared shell (_inc/dashboard/dashboard-page+dashboard-nav) renders theAdminPagechrome from@automattic/jetpack-componentsand the route-based tab navigation, so every route gets the same header, tabs and footer. UI uses@wordpress/components,@wordpress/ui, and@wordpress/icons. - Data: the Overview, Settings, and GEO routes are backed by read-only REST routes
GET /jetpack/v4/seo/{overview,settings,ai}(Dashboard_Data::register_rest_reads(),manage_options-gated), whose responses are preloaded into the page server-side viarest_preload_api_request()(Admin_Page::inject_script_data(), on thejetpack_admin_js_script_datafilter — wp-build pages load as ES modules, sowp_localize_scriptcan't bootstrap them and the script-data layer is the supported channel). On a normal load each route reads its slice from the preload synchronously through@automattic/jetpack-script-data(_inc/data/get-preloaded.ts) — instant, no request. When a slice is missing or stale (e.g. a mismatched bundle after an update), the route's stage fetches it from its REST route instead of dead-ending:_inc/data/use-ensure-tab-data.tsshows a loading skeleton, retries silently, and surfaces a recoverable "Try again" state only on genuine failure.google_verifyandsitestay synchronous onwindow.JetpackScriptData.seo. The Content route fetches posts and pages live from core/wp/v2/postsand/wp/v2/pages(no custom endpoint), with SEO meta returned via registeredshow_in_restpost meta; the inspector writes through the same core post endpoint. Most Settings writes reuse/jetpack/v4/settings(and core/wp/v2/settingsforblog_public); nested site-level schema settings use the package-ownedGET/POST /jetpack/v4/seo/schema-settingsroute so each schema section can be preserved across partial updates. - Plan gating (WordPress.com): on WordPress.com sites below Premium (lacking the
advanced-seoentitlement after the March 2026 rebundling) the dashboard reduces to a free subset and surfaces an upsell. The gate isHost::is_wpcom_platform() && ! Current_Plan::supports( 'advanced-seo' )(Initializer::is_gated()):advanced-seois in the free plan's supports list, sosupports()returns true on self-hosted (never gated) and hijacks towpcom_site_has_feature()on WordPress.com, false below Premium. It's surfaced to the client as theseo.gatingscript-data slice (is_gated,upsell_url), read via_inc/data/is-gated.ts, which defaults to ungated when the slice is absent so a stale snapshot never hides a paid feature. When gated: the Content and GEO tabs are removed and their route stages redirect to Overview; Overview and Settings drop their paid cards/sections and pin the upsell banner; and — importantly for anyone debugging a missing/llms.txtor absent AI-crawler robots.txt rules —init()skipsLlms_Txt::init()andAi_Crawlers::init(), so those services stop emitting, not just their UI. Gating is evaluated live per request (no cached option), so an upgrade restores everything on the next load and a downgrade takes effect immediately.
@wordpress/ui's Text variants are easy to pick wrong, because the heading and body ramps don't share a scale — heading-sm is smaller than body-md. Resolved from the WPDS tokens (@wordpress/theme):
| Variant | Size | Weight | Transform | Use it for |
|---|---|---|---|---|
heading-lg |
15px | medium | — | card title |
heading-md |
13px | medium | — | sub-heading inside a module |
heading-sm |
11px | medium | UPPERCASE | field label |
body-md |
13px | regular | — | body copy |
body-sm |
12px | regular | — | field explainer |
"Small heading" is a field label, not a small sub-heading. Reach for heading-md when you want a sub-heading.
The rule for prose, which the modules drifted away from once already:
body-md— a module's or section's own prose. Things the reader is meant to read.body-sm+.muted— explainer text attached to a field: hints under an input, help under a group label. Not general copy.body-md+.muted— the third case, and deliberately narrow: chrome inside the SERP and social preview mockups (social-previews-card,serp-preview). Those imitate how Google and Facebook render a link, so their type follows someone else's design, not ours. Don't extend this case to real UI.
Field labels should be uppercase across the board. Where a TextControl renders the label, that's automatic; where a label is drawn by hand, use heading-sm so it matches (same size and casing — the control label is weight 600 against heading-sm's medium, so they read alike without being pixel-identical).
Text renders a <span> by default, which is rarely what you want:
- Prose →
render={ <p /> }. - A real sub-heading →
render={ <h3 /> }. Card headers areh2(the page<h1>is inAdmin_Page), so a module's own sub-headings areh3. Without this a screen-reader user navigating by heading finds the card title and nothing beneath it. - A hand-drawn field label heading a group of controls rather than one input → keep it a
span, but give the wrapperrole="group"andaria-labelledbypointing at it (seegoogle-verification-field), or it announces as a label attached to nothing.
Rendering as <p> or a heading is safe: Text always applies both of @wordpress/ui's global-CSS defenses, and every variant defines --_gcd-heading-* alongside --_gcd-p-*, so the element change doesn't alter the type.
# Build once (from the repo root)
jetpack build packages/seo
# Watch mode
pnpm --filter='@automattic/jetpack-seo' run watch
# Typecheck
pnpm --filter='@automattic/jetpack-seo' run typecheck
# Tests
pnpm --filter='@automattic/jetpack-seo' run testThe built JS/CSS lives in build/ and is included in the vendored Jetpack plugin distribution via .gitattributes (/build/** production-include).