You publish a blog post, share it on Twitter, and... the preview card is broken. No image, a truncated title, or the wrong description. Sound familiar? Open Graph tags control how your content appears when shared on social platforms, and getting them wrong means fewer clicks.
An OG tag checker validates these meta tags before your content goes live, catching issues like missing images, incorrect dimensions, or absent descriptions. In this guide, we'll build one from scratch using an API, and show you exactly which tags matter and how to validate them programmatically.
Open Graph (OG) is a protocol created by Facebook that lets you control how URLs are previewed when shared on social media. When someone pastes your link into Twitter, Slack, LinkedIn, or iMessage, the platform reads your OG tags to build a rich preview card.
The essential tags are:
| Tag | Purpose | Required? |
|---|---|---|
| og:title | The title shown in the card | Yes |
| og:description | A short summary (usually 1-2 sentences) | Strongly recommended |
| og:image | The preview image URL | Yes (for rich cards) |
| og:url | The canonical URL | Recommended |
| og:type | Content type (article, website, etc.) | Recommended |
| og:site_name | Your site/brand name | Optional |
Twitter also uses its own twitter:card tags, but falls back to OG tags when they're missing. So getting your OG tags right covers most platforms automatically.
Before we build a checker, here are the issues you're looking for:
The fastest way to check OG tags is to use a meta tag extraction API. MetaPeek (part of the GrabShot suite) extracts all meta tags from any URL, including Open Graph, Twitter Card, and standard HTML meta tags.
curl "https://metapeek.grabshot.dev/api/extract?url=https://example.com" \
-H "X-API-Key: YOUR_API_KEY"
The response includes all OG tags found on the page:
{
"url": "https://example.com",
"meta": {
"og:title": "Example Domain",
"og:description": "This domain is for illustrative examples.",
"og:image": "https://example.com/og-image.png",
"og:url": "https://example.com",
"og:type": "website",
"twitter:card": "summary_large_image"
},
"title": "Example Domain",
"description": "This domain is for illustrative examples."
}
Let's build a complete checker that extracts tags and validates them against best practices:
const https = require('https');
const API_KEY = 'YOUR_API_KEY';
const METAPEEK_URL = 'https://metapeek.grabshot.dev/api/extract';
async function fetchMeta(url) {
const endpoint = `${METAPEEK_URL}?url=${encodeURIComponent(url)}`;
const res = await fetch(endpoint, {
headers: { 'X-API-Key': API_KEY }
});
return res.json();
}
function validateOgTags(data) {
const issues = [];
const warnings = [];
const meta = data.meta || {};
// Required tags
if (!meta['og:title']) {
issues.push('Missing og:title');
} else if (meta['og:title'].length > 70) {
warnings.push(`og:title is ${meta['og:title'].length} chars (recommended: under 70)`);
}
if (!meta['og:image']) {
issues.push('Missing og:image - share cards will have no preview image');
} else {
if (!meta['og:image'].startsWith('https://')) {
issues.push('og:image should be an absolute HTTPS URL');
}
}
if (!meta['og:description']) {
warnings.push('Missing og:description - platforms will guess from page content');
} else if (meta['og:description'].length > 160) {
warnings.push(`og:description is ${meta['og:description'].length} chars (recommended: under 160)`);
}
// Recommended tags
if (!meta['og:url']) {
warnings.push('Missing og:url - recommended for canonical URL');
}
if (!meta['og:type']) {
warnings.push('Missing og:type - defaults to "website"');
}
// Twitter-specific
if (!meta['twitter:card'] && !meta['og:image']) {
issues.push('No twitter:card or og:image - Twitter will show a plain link');
}
return {
url: data.url,
tags: meta,
issues,
warnings,
score: issues.length === 0
? (warnings.length === 0 ? 'perfect' : 'good')
: 'needs-fix'
};
}
// Usage
(async () => {
const url = process.argv[2] || 'https://grabshot.dev';
console.log(`Checking OG tags for: ${url}\n`);
const data = await fetchMeta(url);
const result = validateOgTags(data);
console.log('Tags found:');
for (const [key, value] of Object.entries(result.tags)) {
if (key.startsWith('og:') || key.startsWith('twitter:')) {
console.log(` ${key}: ${value}`);
}
}
if (result.issues.length > 0) {
console.log('\n❌ Issues:');
result.issues.forEach(i => console.log(` - ${i}`));
}
if (result.warnings.length > 0) {
console.log('\n⚠️ Warnings:');
result.warnings.forEach(w => console.log(` - ${w}`));
}
console.log(`\nScore: ${result.score}`);
})();
Here's the same checker in Python:
import requests
import sys
API_KEY = "YOUR_API_KEY"
METAPEEK_URL = "https://metapeek.grabshot.dev/api/extract"
def fetch_meta(url):
resp = requests.get(
METAPEEK_URL,
params={"url": url},
headers={"X-API-Key": API_KEY}
)
resp.raise_for_status()
return resp.json()
def validate_og_tags(data):
meta = data.get("meta", {})
issues = []
warnings = []
# Check og:title
title = meta.get("og:title", "")
if not title:
issues.append("Missing og:title")
elif len(title) > 70:
warnings.append(f"og:title is {len(title)} chars (recommended: under 70)")
# Check og:image
image = meta.get("og:image", "")
if not image:
issues.append("Missing og:image - no preview image on social shares")
elif not image.startswith("https://"):
issues.append("og:image must be an absolute HTTPS URL")
# Check og:description
desc = meta.get("og:description", "")
if not desc:
warnings.append("Missing og:description")
elif len(desc) > 160:
warnings.append(f"og:description is {len(desc)} chars (recommended: under 160)")
# Check og:url
if not meta.get("og:url"):
warnings.append("Missing og:url")
# Check twitter:card
if not meta.get("twitter:card") and not image:
issues.append("No twitter:card or og:image for Twitter previews")
return {"issues": issues, "warnings": warnings, "meta": meta}
if __name__ == "__main__":
url = sys.argv[1] if len(sys.argv) > 1 else "https://grabshot.dev"
print(f"Checking OG tags for: {url}\n")
data = fetch_meta(url)
result = validate_og_tags(data)
for key, val in result["meta"].items():
if key.startswith(("og:", "twitter:")):
print(f" {key}: {val}")
if result["issues"]:
print("\n❌ Issues:")
for i in result["issues"]:
print(f" - {i}")
if result["warnings"]:
print("\n⚠️ Warnings:")
for w in result["warnings"]:
print(f" - {w}")
status = "needs-fix" if result["issues"] else "good"
print(f"\nStatus: {status}")
Checking that og:image exists in the HTML is only half the battle. You also need to verify the image actually loads and meets size requirements. Here's how to add image validation:
async function validateOgImage(imageUrl) {
try {
const res = await fetch(imageUrl, { method: 'HEAD' });
if (!res.ok) {
return { valid: false, error: `Image returns ${res.status}` };
}
const contentType = res.headers.get('content-type');
if (!contentType || !contentType.startsWith('image/')) {
return { valid: false, error: `Not an image: ${contentType}` };
}
const size = parseInt(res.headers.get('content-length') || '0');
if (size > 8 * 1024 * 1024) {
return { valid: false, error: 'Image exceeds 8MB (Facebook limit)' };
}
return { valid: true, contentType, size };
} catch (err) {
return { valid: false, error: err.message };
}
}
For full image dimension checking, you can combine this with a screenshot API to capture how the share card actually renders, giving you a pixel-perfect preview.
Numbers and validation rules are useful, but what teams really want is to see how the link will look when shared. You can generate a visual preview by capturing a screenshot of a share card mockup:
# Capture how your page looks as a Twitter card preview
curl "https://grabshot.dev/api/screenshot?url=https://cards-dev.twitter.com/validator&width=600&height=400&format=png" \
-H "X-API-Key: YOUR_API_KEY" \
--output twitter-preview.png
Or build a simple HTML template that renders the OG data as a card, then screenshot that template with GrabShot:
# Pass your card template with dynamic OG data
curl -X POST "https://grabshot.dev/api/screenshot" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<div style=\"width:600px;height:315px;background:#fff;font-family:sans-serif;padding:20px\"><img src=\"OG_IMAGE_URL\" style=\"width:100%;height:200px;object-fit:cover\"><h3>OG_TITLE</h3><p style=\"color:#666\">example.com</p></div>",
"width": 600,
"height": 315,
"format": "png"
}' \
--output card-preview.png
Use MetaPeek to extract and validate Open Graph tags from any URL. Free tier includes 25 requests per month.
Try MetaPeek FreeThe best time to catch broken OG tags is before deployment. Add a check to your CI pipeline:
# .github/workflows/og-check.yml
name: OG Tag Validation
on: [pull_request]
jobs:
check-og-tags:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start preview server
run: npm start &
- name: Validate OG tags
run: |
URLS=$(find public -name "*.html" | sed 's|public|http://localhost:3000|')
for url in $URLS; do
echo "Checking: $url"
RESULT=$(curl -s "https://metapeek.grabshot.dev/api/extract?url=$url" \
-H "X-API-Key: ${{ secrets.METAPEEK_KEY }}")
# Check for og:title
TITLE=$(echo $RESULT | jq -r '.meta["og:title"] // empty')
if [ -z "$TITLE" ]; then
echo "❌ Missing og:title on $url"
exit 1
fi
# Check for og:image
IMAGE=$(echo $RESULT | jq -r '.meta["og:image"] // empty')
if [ -z "$IMAGE" ]; then
echo "❌ Missing og:image on $url"
exit 1
fi
echo "✅ $url - OK"
done
This catches missing OG tags on every pull request, so broken share cards never make it to production.
Use this as a quick reference when auditing any page:
Broken OG tags are one of those invisible problems that silently hurt your click-through rates. Every shared link without a proper preview card is a missed opportunity. Building an automated OG tag checker into your workflow, whether as a standalone script or a CI/CD step, takes minutes and saves you from discovering issues only after the link is already live.
The combination of MetaPeek for tag extraction and GrabShot for visual previews gives you both the data and the visual confirmation that your share cards look right. Start with the free tier and automate from there.