Search by

automattic / jetpack-seo

automattic

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

Statistics

Installs: 2 045

Dependents: 1

Suggesters: 0

Stars: 0

v0.10.2 2026-10-28 19:56 UTC

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.php post-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 on wp_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\Initializer boots the package and gates the surface behind the legacy rsm_jetpack_seo filter or per-site seo-admin-ui feature, then its discoverability cohort; Admin_Page registers the admin menu via Admin_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 on current_screen so 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/route route under routes/, exporting a stage — the per-route pattern shared with Forms. The Content route also exports an inspector (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 the AdminPage chrome from @automattic/jetpack-components and 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 via rest_preload_api_request() (Admin_Page::inject_script_data(), on the jetpack_admin_js_script_data filter — wp-build pages load as ES modules, so wp_localize_script can'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.ts shows a loading skeleton, retries silently, and surfaces a recoverable "Try again" state only on genuine failure. google_verify and site stay synchronous on window.JetpackScriptData.seo. The Content route fetches posts and pages live from core /wp/v2/posts and /wp/v2/pages (no custom endpoint), with SEO meta returned via registered show_in_rest post 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 with show_in_rest in Dashboard_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/settings after 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-seo entitlement after the March 2026 rebundling) the dashboard reduces to a free subset and surfaces an upsell. The gate is Host::is_wpcom_platform() && ! Current_Plan::supports( 'advanced-seo' ) (Initializer::is_gated()): advanced-seo is in the free plan's supports list, so supports() returns true on self-hosted (never gated) and hijacks to wpcom_site_has_feature() on WordPress.com, false below Premium. It's surfaced to the client as the seo.gating script-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() skips Llms_Txt::init(), Ai_Crawlers::init() and Canonical_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 identifiers Admin_Page::get_tracks_context() bootstraps plus surface and the calling screen, which is the tab id. The server sends that context only when Admin_Page::can_use_analytics() passes, and the client records nothing without it. @automattic/jetpack-analytics only queues events; Admin_Page::enqueue_tracks_transport() loads the w.js that 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 are h2 (the page <h1> is in Admin_Page), so a module's own sub-headings are h3. 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 wrapper role="group" and aria-labelledby pointing at it (see google-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).