Open Graph and Twitter Cards Meta Tags in WordPress Without a Plugin

Open Graph (OG) protocol meta tags — defined by Facebook in 2010 and now consumed by every major social platform, Slack, Discord, WhatsApp, iMessage, and LinkedIn — turn ordinary web pages into structured social-share objects by providing <meta property="og:title">, <meta property="og:description">, <meta property="og:image">, <meta property="og:url">, and <meta property="og:type"> tags in the document <head>. Twitter (X) Cards are a parallel system — Twitter scrapers read OG tags first but prefer Twitter-specific <meta name="twitter:card"> and <meta name="twitter:image"> tags when both are present. The most impactful card type is summary_large_image, which renders a full-width image with title and description below — it produces significantly higher click-through rates in social feeds than summary (small thumbnail). WordPress does not output any OG or Twitter meta tags by default — every plugin-free WordPress theme serves pages without social metadata, meaning every share shows only the URL with no image, title, or description preview. The correct hook for injecting <meta> tags is wp_head — output buffering is not needed; echo each <meta> tag directly. Image requirements: Facebook recommends 1200×630px, minimum 600×315px — images below 300px in either dimension are not shown; Twitter summary_large_image requires minimum 300×157px and recommends 1200×600px with a 2:1 ratio; maximum file size for both is 5MB for the scraper to process. The canonical URL in og:url should always be the full absolute URL including protocol and trailing slash if that is the canonical form — WordPress’s get_permalink() returns the correct canonical URL for posts and pages, but home_url( ‘/’ ) should be used for the front page and get_term_link() for taxonomy archives. The Structured Data JSON-LD post covered machine-readable content for search engines; Open Graph meta covers machine-readable content for social network scrapers — both use wp_head and are complementary without overlap.

Problem: A WordPress recipe blog’s posts share as bare URLs on Facebook, Pinterest, and Slack with no image, no recipe name, and no description — the social platforms cannot determine which image to use because the page has no OG meta tags and multiple images in the post content. Click-through rate from social shares is low.

Solution: Add a wp_head hook that outputs complete OG and Twitter Card meta tags using the post featured image as og:image, the post excerpt or auto-generated description as og:description, and falls back gracefully for archives, the home page, and custom post types.

// functions.php (or a plugin file)

add_action( 'wp_head', 'theme_output_social_meta_tags', 5 );

function theme_output_social_meta_tags(): void {
    global $post;

    // ── Determine title, description, URL, image ──────────────────────────
    $title       = '';
    $description = '';
    $url         = '';
    $image_url   = '';
    $image_w     = 0;
    $image_h     = 0;
    $og_type     = 'website';

    if ( is_singular() && isset( $post ) ) {
        $og_type     = 'article';
        $title       = wp_strip_all_tags( get_the_title( $post ) );
        $url         = get_permalink( $post );

        // Prefer manually-written excerpt; fallback: first 160 chars of content
        if ( $post->post_excerpt ) {
            $description = wp_strip_all_tags( $post->post_excerpt );
        } else {
            $description = wp_trim_words( wp_strip_all_tags( $post->post_content ), 30, '' );
        }

        // Featured image at 1200×630 if it exists
        if ( has_post_thumbnail( $post ) ) {
            $thumb = wp_get_attachment_image_src( get_post_thumbnail_id( $post ), 'og-image' );
            if ( $thumb ) {
                [ $image_url, $image_w, $image_h ] = $thumb;
            }
        }

        // Fallback to first image attached to the post
        if ( ! $image_url ) {
            $attachments = get_posts( [
                'post_type'      => 'attachment',
                'post_mime_type' => 'image',
                'post_parent'    => $post->ID,
                'numberposts'    => 1,
                'fields'         => 'ids',
            ] );
            if ( $attachments ) {
                $src = wp_get_attachment_image_src( $attachments[0], 'large' );
                if ( $src ) [ $image_url, $image_w, $image_h ] = $src;
            }
        }

    } elseif ( is_front_page() ) {
        $title       = wp_strip_all_tags( get_bloginfo( 'name' ) );
        $description = wp_strip_all_tags( get_bloginfo( 'description' ) );
        $url         = home_url( '/' );

    } elseif ( is_category() || is_tag() || is_tax() ) {
        $term        = get_queried_object();
        $title       = wp_strip_all_tags( single_term_title( '', false ) );
        $description = wp_strip_all_tags( term_description( $term ) );
        $url         = get_term_link( $term );

    } elseif ( is_author() ) {
        $author      = get_queried_object();
        $title       = esc_html( $author->display_name );
        $description = wp_strip_all_tags( get_the_author_meta( 'description', $author->ID ) );
        $url         = get_author_posts_url( $author->ID );
    }

    // Fallback: site default OG image (set in Customizer or hardcoded)
    if ( ! $image_url ) {
        $default = get_theme_mod( 'og_default_image' );
        if ( $default ) {
            $image_url = esc_url( $default );
            $image_w   = 1200;
            $image_h   = 630;
        }
    }

    // Truncate description to 160 characters
    if ( strlen( $description ) > 160 ) {
        $description = substr( $description, 0, 157 ) . '...';
    }

    // ── Output tags ───────────────────────────────────────────────────────
    if ( ! $title || ! $url ) return;

    echo "

";
    printf( '' . "
", esc_attr( $og_type ) );
    printf( '' . "
", esc_attr( $title ) );
    printf( '' . "
", esc_url( $url ) );
    printf( '' . "
", esc_attr( get_bloginfo( 'name' ) ) );

    if ( $description ) {
        printf( '' . "
", esc_attr( $description ) );
    }
    if ( $image_url ) {
        printf( '' . "
", esc_url( $image_url ) );
        if ( $image_w ) printf( '' . "
", (int) $image_w );
        if ( $image_h ) printf( '' . "
", (int) $image_h );
    }

    echo "

";
    $card_type = $image_url ? 'summary_large_image' : 'summary';
    printf( '' . "
", esc_attr( $card_type ) );
    printf( '' . "
", esc_attr( $title ) );
    if ( $description ) {
        printf( '' . "
", esc_attr( $description ) );
    }
    if ( $image_url ) {
        printf( '' . "
", esc_url( $image_url ) );
    }

    // Optional: add your Twitter/X handle
    $twitter_handle = get_theme_mod( 'twitter_handle' );
    if ( $twitter_handle ) {
        printf( '' . "
", esc_attr( '@' . ltrim( $twitter_handle, '@' ) ) );
    }
    echo "
";
}

// ── Register a custom image size for OG images (1200×630, hard-cropped) ───────
add_action( 'after_setup_theme', function(): void {
    add_image_size( 'og-image', 1200, 630, true );
} );

NOTE: Facebook and LinkedIn cache OG meta tag snapshots aggressively — when you update a post’s featured image or title, the old preview continues showing in social shares until the cache expires (typically 24–72 hours) or is manually cleared. Facebook’s cache can be cleared at developers.facebook.com/tools/debug by entering the URL and clicking “Scrape Again”. LinkedIn’s Post Inspector at linkedin.com/post-inspector provides the same functionality. Also, the custom image size og-image (1200×630) is only generated for images uploaded after you register the size with add_image_size() — existing featured images need to be regenerated with WP-CLI: wp media regenerate --only-missing to create the 1200×630 crop for all existing attachments.