Extending Hydrogen SEO

Add post types, change output and integrate your own code.

4 min read
On this page
  1. Add a custom post type to the metabox and analysis
  2. Add custom template variables
  3. Inject custom schema
  4. Customise sitemap output
  5. Disable frontend output for headless setups
  6. Module architecture overview
  7. Related reading

Hydrogen SEO is built to be extended: every meaningful value passes through a filter, every meaningful event fires an action, and every feature lives in a self-contained module. This guide collects the practical recipes — custom post types, custom template variables, custom schema, sitemap tweaks, headless setups — with working code you can drop into a small custom plugin. Signatures are verified against the plugin source (Beta); for the full catalogue see the Filters Reference and Actions Reference.

A note on where to put this code: prefer a tiny must-use or custom plugin over your theme's functions.php, so SEO behaviour survives a theme switch.

Add a custom post type to the metabox and analysis

By default the SEO Settings sheet covers post and page, plus whatever is enabled in settings, and the SEO score covers every public post type except attachments. To cover a custom post type in code, extend both lists — and usually schema too:

php
add_action( 'hydrogen_seo_modules_loaded', function () {
    // Show the SEO panel on the CPT's edit screen.
    add_filter( 'hydrogen_seo_metabox_post_types', function ( $post_types ) {
        $post_types[] = 'portfolio';
        return $post_types;
    } );

    // Give it an SEO score (only needed if the post type is not public).
    add_filter( 'hydrogen_seo_seo_analysis_post_types', function ( $post_types ) {
        $post_types[] = 'portfolio';
        return $post_types;
    } );

    // Let the schema module generate JSON-LD for it.
    add_filter( 'hydrogen_seo_schema_markup_post_types', function ( $post_types ) {
        $post_types[] = 'portfolio';
        return $post_types;
    } );
} );

Two caveats worth knowing. The metabox list is intersected with public post types, so a CPT registered with 'public' => false will not appear no matter what you add here. And custom taxonomies work the same way through hydrogen_seo_metabox_taxonomies.

Add custom template variables

Template variables like %title% and %site_name% are resolved through one replacement map, and hydrogen_seo_variable_replacements lets you extend it. Your variable then works in every title, description and schema field that supports smart tags.

php
add_filter( 'hydrogen_seo_variable_replacements', function ( $replacements, $post, $term ) {
    // A static brand suffix.
    $replacements['%brand%'] = 'Acme Co.';

    // A per-post value pulled from custom fields.
    if ( $post instanceof WP_Post ) {
        $price = get_post_meta( $post->ID, '_product_price', true );
        $replacements['%price%'] = $price ? '$' . $price : '';
    }

    // A per-term value.
    if ( $term && isset( $term->term_id ) ) {
        $replacements['%term_count%'] = (string) $term->count;
    }

    return $replacements;
}, 10, 3 );

Now a title template like %title% (%price%) %separator% %brand% resolves everywhere. Empty replacements collapse cleanly, so returning '' for missing data is fine. Custom tokens added here will not appear in the @ variable picker UI, but they resolve correctly on output.

Inject custom schema

Three layers, from narrow to broad — see the schema markup overview for what ships out of the box:

Adjust a post's generated schema with hydrogen_seo_schema_markup( $schema, $post_id, $schema_type ):

php
add_filter( 'hydrogen_seo_schema_markup', function ( $schema, $post_id, $schema_type ) {
    if ( 'Product' === $schema_type ) {
        $schema['brand'] = [ '@type' => 'Brand', 'name' => 'Acme Co.' ];
    }
    return $schema;
}, 10, 3 );

Adjust schema at generation time (you get the full WP_Post) with hydrogen_seo_generated_schema( $schema, $post, $schema_type ).

Append entirely new JSON-LD blocks with hydrogen_seo_schemas( $schemas ), which filters the final list the frontend prints:

php
add_filter( 'hydrogen_seo_schemas', function ( $schemas ) {
    if ( is_singular( 'portfolio' ) ) {
        $schemas[] = [
            '@context' => 'https://schema.org',
            '@type'    => 'CreativeWork',
            'name'     => get_the_title(),
            'url'      => get_permalink(),
        ];
    }
    return $schemas;
} );

Always validate the result — see validate rich results.

Customise sitemap output

The sitemap pipeline exposes hooks at every level. The most common need is excluding items programmatically with hydrogen_seo_sitemap_exclude_item( $excluded, $object_id, $type ), where $type is post or term:

php
// Exclude all posts in a "hidden" category from the sitemap.
add_filter( 'hydrogen_seo_sitemap_exclude_item', function ( $excluded, $object_id, $type ) {
    if ( 'post' === $type && has_category( 'hidden', $object_id ) ) {
        return true;
    }
    return $excluded;
}, 10, 3 );

Beyond exclusion you can shape priorities (hydrogen_seo_sitemap_post_priority), curate image entries (hydrogen_seo_sitemap_post_images), or post-process raw XML (hydrogen_seo_sitemap_index_xml, hydrogen_seo_sitemap_post_type_xml, hydrogen_seo_sitemap_taxonomy_xml). After changing sitemap logic, clear the sitemap cache from the dashboard (or POST /sitemap/cache/clear) so changes take effect — details in XML sitemaps.

Disable frontend output for headless setups

On a headless WordPress install the theme never renders, so printing titles, meta and schema into wp_head is wasted work — and worse, your JavaScript frontend may duplicate it. One filter turns all of Hydrogen SEO's frontend output off while keeping the admin experience, analysis, sitemaps and REST API intact:

php
add_filter( 'hydrogen_seo_enable_frontend', '__return_false' );

Your decoupled frontend can then read SEO data over REST — GET /wp-json/hydrogen-seo/v1/metabox/post/{id} returns the resolved SEO fields for a post (authenticated; see The Plugin REST API) — and render its own <head>. The same filter also works conditionally, for example disabling output only on specific routes you serve headlessly.

Module architecture overview

Understanding the plugin's shape makes everything above less magical.

  • Everything is a module. Each feature lives in inc/modules/{module}/ — among them titles-meta, schema-markup, sitemap, robots-editor, robots-settings, redirections, homepage-settings, metabox, seo-analysis, rank-analysis, metadata-generation, content-ai, image-alt, image-alt-bulk, featured-image, llms-txt, embeddings and dashboard.
  • A module loader boots them. inc/modules/module-loader.php holds a registry of module IDs mapped to an Init class, an enabled flag and a load priority. Low priorities load first (dashboard at 1, metabox at 3, titles-meta at 5, sitemap at 50). Once every enabled module is instantiated, hydrogen_seo_modules_loaded fires with the loaded instances.
  • A typical module has an init.php (wires WordPress hooks, often fires its own *_init action), a controller.php (feature logic and settings defaults), an api.php (REST routes extending the shared base class), and utils.php helpers.
  • REST is uniform. Module APIs extend a base REST class that registers routes under hydrogen-seo/v1, applies a default manage_options permission check, and offers a standard GET /{base}, POST /{base}, POST /{base}/reset trio for settings modules. The hydrogen_seo_register_rest_routes action lets you add routes in the same namespace.

The practical consequence: to integrate with a feature, hook hydrogen_seo_modules_loaded (or the feature's own init action like hydrogen_seo_schema_markup_init), add your filters there, and read or write data through the same REST endpoints the UI uses. If you build something on top of Hydrogen SEO you would like others to use, the plugin is GPL — the support channel is the right place to compare notes while the developer platform is in Beta.

Was this page helpful?
Still stuck? Contact support