WordPress OG Images for Yoast and Rank Math

Give every WordPress post its own og:image with one plugin file: a 1200x630 card template, signed screenshot URLs, and the Yoast and Rank Math hooks that work.

By Simon Chiu · Updated

WordPress SEO plugins give each post an og:image, but only an image somebody uploaded. This page adds a generated one: a single plugin file that renders a 1200×630 card for each post and points og:image and twitter:image at a signed RenderScreenshot URL. It works with Yoast SEO or Rank Math, and we read both plugins' source to find the hooks that hold up. For the design side of social cards, see the OG image production guide; all integrations are on the integrations hub.

Last tested: 2026-10-18 on WordPress 7.1.3 (official Docker image, PHP 8.3.35) with Yoast SEO 28.7 and, separately, Rank Math 1.0.280. For each plugin we fetched two posts, one with a featured image and one without, and checked the head with curl: exactly one og:image, ours, with 1200×630 tags. Each URL was then sent to a local stub that checks signatures the way the API does, and every one passed. No request went to the live API.

What you can build

  • A card for every post, old and new. Nobody uploads anything. The title, excerpt, site name and date come from the post.
  • Cards that update after an edit. The post's modified time is part of the captured URL, so an edit gives a new image URL that platforms fetch again.
  • One look across authors. The card is HTML and CSS in the plugin, so a designer can change every card at once.

How it works

sequenceDiagram
    participant C as Crawler (LinkedIn, Slack, X)
    participant W as WordPress
    participant R as RenderScreenshot
    C->>W: GET /my-post/
    W-->>C: HTML with og:image = signed API URL
    C->>R: GET signed URL
    R->>W: GET /my-post/?rs_card=1&v=<modified time>
    W-->>R: 1200x630 card HTML
    R-->>C: PNG (later fetches reuse it for 0 credits)

WordPress never waits for a render. It only computes an HMAC when it builds the page, and the crawler fetches the image from the API.

Prerequisites

  • WordPress with Yoast SEO or Rank Math active, and a way to add a plugin file (SFTP, your host's file manager or a deploy pipeline).
  • A signing key pair. In the RenderScreenshot dashboard, open API Keys, choose Create Key, then Signing key pair, and copy both values. Create a free account if you have none.
  • Add the keys to wp-config.php, above the line that says to stop editing. Constants keep the secret out of the database and out of database backups:
define( 'RS_PUBLIC_KEY_ID', 'rs_pub_...' );
define( 'RS_SECRET_KEY', 'rs_secret_...' );

The plugin file

Save this as wp-content/plugins/rs-og-image.php and activate RenderScreenshot OG images under Plugins. It also works in wp-content/mu-plugins/, where it cannot be switched off by accident. You can paste the body into a theme's functions.php, but a theme switch would then remove your cards.

<?php
/**
 * Plugin Name: RenderScreenshot OG images
 * Description: Per-post og:image and twitter:image from signed RenderScreenshot URLs. Works with Yoast SEO or Rank Math.
 * Version: 1.0.0
 *
 * Put the two keys in wp-config.php, never in the database:
 *   define( 'RS_PUBLIC_KEY_ID', 'rs_pub_...' );
 *   define( 'RS_SECRET_KEY', 'rs_secret_...' );
 */

defined( 'ABSPATH' ) || exit;

const RS_OG_ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot';

/** Encode like the API's signature check: space -> "+", only A-Z a-z 0-9 - _ . ~ unescaped. */
function rs_og_encode( string $value ): string {
    return str_replace( '%20', '+', rawurlencode( $value ) );
}

/** The signed image URL for the current singular post, or null to leave the SEO plugin alone. */
function rs_og_image_url(): ?string {
    static $memo = false;
    if ( false !== $memo ) {
        return $memo;
    }
    $memo = null;

    if ( ! defined( 'RS_PUBLIC_KEY_ID' ) || ! defined( 'RS_SECRET_KEY' ) || ! is_singular( 'post' ) ) {
        return $memo;
    }
    $post = get_queried_object();
    if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status || post_password_required( $post ) ) {
        return $memo;
    }

    // The card template below, versioned by the modified time so an edit gets a new render.
    $target = add_query_arg(
        array(
            'rs_card' => '1',
            'v'       => get_post_modified_time( 'U', true, $post ),
        ),
        get_permalink( $post )
    );

    $params = array(
        'url'             => $target,
        'preset'          => 'og_card', // 1200x630 PNG
        'cache_available' => 'true',    // crawlers that fetch again reuse the render for 0 credits
        'cache_ttl'       => (string) WEEK_IN_SECONDS,
        'key_id'          => RS_PUBLIC_KEY_ID,
        // A weekly boundary 7 to 14 days ahead: stable for page caches, inside the 30-day maximum.
        'expires'         => (string) ( ( intdiv( time(), WEEK_IN_SECONDS ) + 2 ) * WEEK_IN_SECONDS ),
    );

    $pairs = array();
    foreach ( $params as $key => $value ) {
        $pairs[] = rs_og_encode( $key ) . '=' . rs_og_encode( (string) $value );
    }
    sort( $pairs, SORT_STRING );
    $query = implode( '&', $pairs );

    $memo = RS_OG_ENDPOINT . '?' . $query . '&signature=' . hash_hmac( 'sha256', $query, RS_SECRET_KEY );
    return $memo;
}

/* ---------- The 1200x630 card that the API captures ---------- */

add_action(
    'template_redirect',
    function () {
        if ( ! isset( $_GET['rs_card'] ) || ! is_singular( 'post' ) ) { // phpcs:ignore WordPress.Security.NonceVerification
            return;
        }
        $post = get_queried_object();
        header( 'X-Robots-Tag: noindex' ); // the card is for the renderer, not for search results
        // Every value is escaped: titles and excerpts are user content and may contain HTML.
        ?>
<!doctype html>
<html><head><meta charset="utf-8"><meta name="robots" content="noindex">
<style>
  html, body { margin: 0; width: 1200px; height: 630px; }
  body { box-sizing: border-box; padding: 72px; display: flex; flex-direction: column; justify-content: space-between;
         font: 28px/1.4 system-ui, sans-serif; background: #0f172a; color: #e2e8f0; }
  h1 { font-size: 64px; line-height: 1.1; margin: 0 0 24px; color: #fff; }
  p { margin: 0; max-height: 120px; overflow: hidden; }
  footer { display: flex; justify-content: space-between; color: #94a3b8; }
</style></head>
<body>
  <main>
    <h1><?php echo esc_html( wp_strip_all_tags( get_the_title( $post ) ) ); ?></h1>
    <p><?php echo esc_html( wp_trim_words( get_the_excerpt( $post ), 30 ) ); ?></p>
  </main>
  <footer>
    <span><?php echo esc_html( get_bloginfo( 'name' ) ); ?></span>
    <span><?php echo esc_html( get_the_date( '', $post ) ); ?></span>
  </footer>
</body></html>
        <?php
        exit;
    }
);

/* ---------- Yoast SEO: swap its image tags for ours ---------- */

add_filter(
    'wpseo_frontend_presenters',
    function ( $presenters ) {
        if ( null === rs_og_image_url() ) {
            return $presenters;
        }
        return array_filter(
            $presenters,
            function ( $presenter ) {
                return ! $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Image_Presenter
                    && ! $presenter instanceof \Yoast\WP\SEO\Presenters\Twitter\Image_Presenter;
            }
        );
    }
);

add_action(
    'wpseo_head',
    function () {
        $url = rs_og_image_url();
        if ( null === $url ) {
            return;
        }
        printf( '<meta property="og:image" content="%s" />' . "\n", esc_url( $url ) );
        echo '<meta property="og:image:width" content="1200" />' . "\n";
        echo '<meta property="og:image:height" content="630" />' . "\n";
        echo '<meta property="og:image:type" content="image/png" />' . "\n";
        printf( '<meta name="twitter:image" content="%s" />' . "\n", esc_url( $url ) );
    },
    50
);

/* ---------- Rank Math: every image candidate becomes ours ---------- */

foreach ( array( 'facebook', 'twitter' ) as $rs_network ) {
    // Replace any candidate (featured, content or default image) with the signed URL.
    add_filter(
        "rank_math/opengraph/{$rs_network}/image",
        function ( $url ) {
            return rs_og_image_url() ?? $url;
        }
    );
    // Make sure there is a candidate even when the post has no image at all.
    add_action(
        "rank_math/opengraph/{$rs_network}/add_images",
        function ( $image ) {
            if ( null !== rs_og_image_url() ) {
                $image->add_image(); // empty candidate; the filter above fills in our URL
            }
        }
    );
}

add_filter(
    'rank_math/opengraph/facebook/image_array',
    function ( $attachment ) {
        if ( is_array( $attachment ) && ( $attachment['url'] ?? '' ) === rs_og_image_url() ) {
            $attachment += array(
                'width'  => 1200,
                'height' => 630,
                'type'   => 'image/png',
            );
        }
        return $attachment;
    }
);

The card template escapes every value. Titles and excerpts are editor content, and without escaping a title with < or & would break the layout or run markup inside the renderer.

What we found in the SEO plugins

Yoast SEO. The well-known wpseo_opengraph_image filter is the wrong tool here. In Yoast 28.7 it runs once per image Yoast has already collected, so a post with no featured image and no default image never reaches it. When it does run, it changes only the URL, and Yoast still prints the width and height of the original image. The plugin above uses wpseo_frontend_presenters instead to drop Yoast's two image presenters, then prints its own tags on the wpseo_head action. All other Yoast tags stay as they are.

Rank Math. Rank Math removes the query string from image URLs it validates, which would cut the signature off. Images that come through its rank_math/opengraph/{network}/image filter skip that step. So the plugin adds an empty candidate on add_images, lets the filter fill in the signed URL, and adds the size tags with image_array. Because Rank Math drops duplicate URLs, the featured image maps to the same URL and is not printed twice. One more surprise: Rank Math prints no Open Graph tags until you connect an account or skip that step in its setup wizard.

Parameter mapping

Signed parameter Value Why
url permalink + ?rs_card=1&v=<modified time> The card template, versioned by the last edit
preset og_card 1200×630 PNG, see presets
cache_available true A second crawler fetch reuses the render for 0 credits
cache_ttl 604800 Renders up to 7 days old are reused
key_id, expires from wp-config.php, a weekly boundary 7 to 14 days ahead Stable URLs for page caches, inside the 30-day limit
signature HMAC-SHA256 of the sorted query Nobody can change the URL to capture another page

The flat names come from the GET endpoint, the only endpoint that accepts signed URLs.

Errors and retries

The crawler fetches the image, so a failure shows up as a missing preview, not as a PHP error. To see the reason, copy the og:image URL from the page source, replace each &#038; with &, and request it with curl -i. The JSON body names the problem, for example invalid_signature or timeout. The error code list says which codes are worth a retry. Platforms retry on their own schedule; to force a new fetch, use the platform's post inspector or debugger.

Limits and costs

  • Credits. One credit the first time any crawler fetches a card for a post version. Later fetches inside the cache TTL cost 0. See pricing for the credits per plan.
  • Page caches. WP Rocket, LiteSpeed Cache or a CDN store the HTML, and with it the signed URL. Keep the page cache lifetime under 7 days so a cached page never carries an expired URL.
  • Rate limit. A newsletter that links twenty new posts sends twenty first fetches in a short time. The limit is per account; see rate limits.
  • Your server. One extra request per card, for ?rs_card=1, when the API renders it.

Troubleshooting

Symptom Cause Fix
No og:image from our plugin Constants missing, or the post is not a published, public post Check wp-config.php; edit is_singular( 'post' ) to add other post types
Rank Math prints no og: tags Setup wizard not finished Connect or skip the account step
Two og:image tags Another plugin or the theme also prints one Turn off that output
Card shows a login page The site is private or behind basic auth The renderer must reach ?rs_card=1 without a login
invalid_signature The secret belongs to another key pair Copy both values from the same pair
Old card after an edit The platform cached the image The URL changed; ask the platform to re-scrape

For sites that are not on WordPress, the same signed URL pattern works from a Vercel Function. On Webflow, put the card URL in the collection page head. Ghost uses a theme partial and an Admin API script, and Shopify uses a Liquid snippet in the product template.

Frequently asked questions

Why does the wpseo_opengraph_image filter not change my image?
Yoast runs that filter once for each image it already found. A post with no featured image and no default image has none, so the filter never runs. It also keeps the old image's width and height tags. Removing Yoast's image presenters and printing your own tags avoids both problems.
Why does Rank Math print no Open Graph tags at all?
Rank Math loads its front-end output only after you connect a Rank Math account or skip that step in the setup wizard. On a fresh install with neither, no og: tags are printed.
Do I need the RenderScreenshot PHP SDK?
No. Signing a URL is one HMAC-SHA256 call with PHP's built-in hash_hmac, so the plugin has no Composer dependency and works on any host.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.