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 oneog: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 & 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.