automattic / jetpack-seo
Jetpack SEO — the visibility command center for WordPress sites in the agentic web.
Package info
github.com/Automattic/jetpack-seo
Type:jetpack-library
pkg:composer/automattic/jetpack-seo
Requires
- php: >=7.4
- automattic/jetpack-admin-ui: ^0.14.10
- automattic/jetpack-connection: ^9.15.0
- automattic/jetpack-plans: ^0.16.0
- automattic/jetpack-status: ^7.1.1
- automattic/jetpack-wp-build-polyfills: ^0.7.1
Requires (Dev)
- automattic/jetpack-changelogger: ^7.0.5
- automattic/phpunit-select-config: ^1.0.12
- yoast/phpunit-polyfills: ^4.0.0
Suggests
- automattic/jetpack-autoloader: Allow for better interoperability with other plugins that use this package.
Provides
None
Conflicts
None
Replaces
None
README
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; the post-list columns are hidden by default and opted into per user via Screen Options. 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.)
Architecture
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 the legacyrsm_jetpack_seofilter or per-siteseo-admin-uifeature, then its discoverability 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, Content, GEO, Settings) 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,geo}(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. Every Settings and GEO write that is a plain option goes through core/wp/v2/settings— each is registered withshow_in_restinDashboard_Data::register_rest_settings(), so the dashboard needs no Jetpack-plugin route and behaves identically on WordPress.com and self-hosted. Two kinds of write can't live there and get package-owned routes: the settings whose write switches a Jetpack module (sitemap, canonical URLs, site verification —POST /jetpack/v4/seo/modules), because switching a module can fail and core's settings endpoint has no way to refuse a value, and the nested site-level schema settings (GET/POST /jetpack/v4/seo/schema-settings), so each schema section can be preserved across partial updates. The signed-in user's Author profile also has its own route (GET/POST /jetpack/v4/seo/author-profile), because WordPress.com Simple's API drops user fields from core's/wp/v2/users; for WordPress.com accounts it leaves name, bio and website to the account and saves only the job title and social profiles. The Settings form writes the settings and module endpoints sequentially, never in parallel, and re-reads/jetpack/v4/seo/settingsafter every save so the form shows what the server actually stored rather than what it was asked to store. - 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.txt, absent AI-crawler robots.txt rules or missing archive canonical tags on Simple —init()skipsLlms_Txt::init(),Ai_Crawlers::init()andCanonical_Urls::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. - Tracks: every event goes through
recordSeoEvent()(_inc/data/record-seo-event.ts), which adds the identifiersAdmin_Page::get_tracks_context()bootstraps plussurfaceand the callingscreen, which is the tab id. The server sends that context only whenAdmin_Page::can_use_analytics()passes, and the client records nothing without it.@automattic/jetpack-analyticsonly queues events;Admin_Page::enqueue_tracks_transport()loads thew.jsthat sends them.
Saving settings
Option-backed settings save through /wp/v2/settings. Clearing a front-page description also removes superseded legacy text, including when the modern option is already empty or absent. Invalid non-empty verification input preserves the saved code; an empty string deliberately clears it.
Sitemap, canonical URL, and verification switches use /jetpack/v4/seo/modules and read live module state. No duplicate sitemap or canonical options are written. On platforms without those modules, sitemap and canonical controls are read-only and verification codes remain editable. WordPress.com Simple is the exception for canonical URLs: it has no modules, so the same route saves its switch to the jetpack_canonical_urls_enabled option (off when missing), and Canonical_Urls loads the Jetpack plugin's canonical-urls module file, which prints the archive canonical tags. Simple's platform sitemap is unchanged.
On private and coming-soon Atomic sites, module writes temporarily bypass wpcomsh's output-suppression filter. The original filter is restored after the switch, so a saved preference never publishes the site or enables private sitemap output.
UI conventions
Text hierarchy
@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).
Element rendering
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.
Development
# 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 test
The built JS/CSS lives in build/ and is included in the vendored Jetpack plugin distribution via .gitattributes (/build/** production-include).