When someone shares your content on social media, a preview card appears with an image, title, and description. These cards dramatically increase click-through rates. Next.js makes it easy to create dynamic Open Graph images and optimize social media previews with the ImageResponse API. Build cards that stand out and drive engagement!
Why Social Media Cards Matter
Impact of Good Social Cards
- 3x higher click-through rates compared to plain links
- Professional appearance builds trust and credibility
- Brand consistency across all social platforms
- Better engagement on Facebook, Twitter, LinkedIn
- Preview control instead of random images
How Social Cards Work
- User shares your URL on social media
- Platform fetches your page's Open Graph metadata
- Platform displays card with og:image, og:title, og:description
- Users see rich preview instead of plain link
- More clicks and better engagement
Open Graph Metadata Basics
Essential Open Graph Tags
import { Metadata } from 'next';
export const metadata: Metadata = {
// Regular metadata
title: 'My Blog Post',
description: 'An amazing blog post about Next.js',
// Open Graph metadata
openGraph: {
title: 'My Blog Post', // Can differ from page title
description: 'An amazing blog post about Next.js',
url: 'https://myblog.com/blog/my-post',
siteName: 'My Blog',
locale: 'en_US',
type: 'article',
// Images array
images: [
{
url: 'https://myblog.com/og-images/my-post.jpg',
width: 1200,
height: 630,
alt: 'My Blog Post Cover',
},
// Fallback image
{
url: 'https://myblog.com/og-images/default.jpg',
width: 1200,
height: 630,
alt: 'My Blog Default Image',
},
],
// Article-specific (optional)
publishedTime: '2024-01-15T00:00:00.000Z',
modifiedTime: '2024-01-16T00:00:00.000Z',
authors: ['John Doe'],
tags: ['Next.js', 'React', 'Web Development'],
},
};
// Generates:
// <meta property="og:title" content="My Blog Post" />
// <meta property="og:description" content="..." />
// <meta property="og:url" content="..." />
// <meta property="og:image" content="..." />
// <meta property="og:image:width" content="1200" />
// <meta property="og:image:height" content="630" />
// ✅ 1200x630 recommended size
// ✅ Multiple images (first is primary)
// ✅ Article-specific metadata
// ✅ Type: 'article' for blog postsOpen Graph Content Types
// Different types for different content
export const metadata: Metadata = {
openGraph: {
// Website (default)
type: 'website',
// Article (blog posts)
type: 'article',
publishedTime: '2024-01-15T00:00:00.000Z',
authors: ['Author Name'],
// Profile (user pages)
type: 'profile',
firstName: 'John',
lastName: 'Doe',
username: 'johndoe',
// Video
type: 'video.other',
video: 'https://example.com/video.mp4',
// Music
type: 'music.song',
// Book
type: 'book',
authors: ['Author Name'],
isbn: '978-3-16-148410-0',
},
};
// ✅ Match type to content
// ✅ Rich snippets on platforms
// ✅ Type-specific propertiesTwitter Cards
Twitter Card Types
export const metadata: Metadata = {
twitter: {
// Card types:
card: 'summary_large_image', // Large image (recommended)
// card: 'summary', // Small thumbnail
// card: 'player', // Video/audio player
// card: 'app', // Mobile app
title: 'My Blog Post',
description: 'An amazing blog post',
creator: '@myhandle', // Content creator
site: '@mysitehandle', // Website handle
images: ['https://myblog.com/twitter-image.jpg'],
// Player card specific
// player: 'https://example.com/player',
// playerWidth: 1280,
// playerHeight: 720,
},
};
// Generates:
// <meta name="twitter:card" content="summary_large_image" />
// <meta name="twitter:title" content="My Blog Post" />
// <meta name="twitter:description" content="..." />
// <meta name="twitter:creator" content="@myhandle" />
// <meta name="twitter:image" content="..." />
// ✅ summary_large_image for blog posts
// ✅ Include creator handle
// ✅ Can differ from Open GraphCombining Open Graph and Twitter
export const metadata: Metadata = {
title: 'My Blog Post',
description: 'Blog post description',
// Open Graph (Facebook, LinkedIn, etc.)
openGraph: {
title: 'My Amazing Blog Post!', // More engaging for social
description: 'Discover how to build amazing things with Next.js',
type: 'article',
images: [
{
url: 'https://myblog.com/og-image.jpg',
width: 1200,
height: 630,
},
],
},
// Twitter (uses Open Graph as fallback)
twitter: {
card: 'summary_large_image',
title: 'My Amazing Blog Post! 🚀', // Can add emoji for Twitter
description: 'Discover how to build amazing things with Next.js',
creator: '@myhandle',
images: ['https://myblog.com/twitter-image.jpg'], // Twitter-specific image
},
};
// ✅ OG title can differ from page title
// ✅ Twitter can differ from OG
// ✅ Platform-specific optimization
// ✅ Twitter uses OG as fallback if not specifiedDynamic OG Image Generation
Static OG Image File
import { ImageResponse } from 'next/og';
// Image metadata
export const alt = 'My Site';
export const size = {
width: 1200,
height: 630,
};
export const contentType = 'image/png';
// Image generation
export default async function Image() {
return new ImageResponse(
(
// JSX/HTML-like syntax
<div
style={{
fontSize: 128,
background: 'linear-gradient(to bottom right, #1e40af, #7c3aed)',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
}}
>
My Site
</div>
),
{
...size,
}
);
}
// ✅ File-based OG image generation
// ✅ HTML/CSS-like syntax
// ✅ Automatic at /opengraph-image
// ✅ No external image neededDynamic OG Image with Route Params
import { ImageResponse } from 'next/og';
export const alt = 'Blog Post';
export const size = {
width: 1200,
height: 630,
};
export const contentType = 'image/png';
export default async function Image({
params
}: {
params: { slug: string }
}) {
// Fetch post data
const post = await fetch(`https://api.example.com/posts/${params.slug}`)
.then(res => res.json());
return new ImageResponse(
(
<div
style={{
fontSize: 60,
background: 'white',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 80px',
}}
>
{/* Title */}
<div
style={{
fontSize: 72,
fontWeight: 'bold',
marginBottom: 20,
color: '#1e40af',
textAlign: 'center',
}}
>
{post.title}
</div>
{/* Author */}
<div
style={{
fontSize: 36,
color: '#6b7280',
}}
>
By {post.author}
</div>
{/* Site name */}
<div
style={{
fontSize: 30,
color: '#9ca3af',
marginTop: 40,
}}
>
myblog.com
</div>
</div>
),
{
...size,
}
);
}
// ✅ Dynamic content from database
// ✅ Post title in image
// ✅ Author name
// ✅ Automatically generated per postAdvanced OG Image with Custom Fonts
import { ImageResponse } from 'next/og';
export default async function Image({
params
}: {
params: { slug: string }
}) {
const post = await getPost(params.slug);
// Load custom font
const interSemiBold = fetch(
new URL('./Inter-SemiBold.ttf', import.meta.url)
).then((res) => res.arrayBuffer());
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fff',
backgroundImage: 'linear-gradient(to bottom right, #e0e7ff, #fce7f3)',
}}
>
{/* Content container */}
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '60px 80px',
maxWidth: '1000px',
}}
>
{/* Category badge */}
<div
style={{
display: 'flex',
fontSize: 28,
fontWeight: 'bold',
color: '#7c3aed',
marginBottom: 20,
textTransform: 'uppercase',
letterSpacing: '0.1em',
}}
>
{post.category}
</div>
{/* Title */}
<div
style={{
fontSize: 80,
fontWeight: 'bold',
color: '#1e293b',
lineHeight: 1.1,
marginBottom: 30,
}}
>
{post.title}
</div>
{/* Metadata */}
<div
style={{
display: 'flex',
fontSize: 32,
color: '#64748b',
alignItems: 'center',
gap: 20,
}}
>
<div>{post.author}</div>
<div>•</div>
<div>{new Date(post.publishedAt).toLocaleDateString()}</div>
</div>
</div>
{/* Logo corner */}
<div
style={{
position: 'absolute',
bottom: 40,
right: 60,
fontSize: 36,
fontWeight: 'bold',
color: '#7c3aed',
}}
>
myblog.com
</div>
</div>
),
{
...size,
fonts: [
{
name: 'Inter',
data: await interSemiBold,
style: 'normal',
weight: 600,
},
],
}
);
}
// ✅ Custom fonts
// ✅ Complex layouts
// ✅ Gradient backgrounds
// ✅ Category, date, author
// ✅ Professional designReusable OG Image Templates
Template Component
interface BlogPostOGProps {
title: string;
author: string;
category: string;
publishedAt: string;
}
export function BlogPostOGTemplate({
title,
author,
category,
publishedAt,
}: BlogPostOGProps) {
return (
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#fff',
backgroundImage: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '60px 80px',
}}
>
{/* Category */}
<div
style={{
fontSize: 32,
fontWeight: 'bold',
color: 'rgba(255, 255, 255, 0.9)',
marginBottom: 20,
textTransform: 'uppercase',
}}
>
{category}
</div>
{/* Title */}
<div
style={{
fontSize: 72,
fontWeight: 'bold',
color: '#fff',
lineHeight: 1.2,
marginBottom: 40,
textShadow: '0 2px 10px rgba(0,0,0,0.2)',
}}
>
{title}
</div>
{/* Footer */}
<div
style={{
display: 'flex',
marginTop: 'auto',
fontSize: 28,
color: 'rgba(255, 255, 255, 0.8)',
alignItems: 'center',
gap: 15,
}}
>
<div>{author}</div>
<div>•</div>
<div>{new Date(publishedAt).toLocaleDateString()}</div>
</div>
</div>
);
}
// ✅ Reusable template
// ✅ Type-safe props
// ✅ Consistent design
// ✅ Easy to maintainUsing Template in Route
import { ImageResponse } from 'next/og';
import { BlogPostOGTemplate } from '@/app/lib/og-templates';
export const size = {
width: 1200,
height: 630,
};
export default async function Image({
params
}: {
params: { slug: string }
}) {
const post = await getPost(params.slug);
return new ImageResponse(
(
<BlogPostOGTemplate
title={post.title}
author={post.author}
category={post.category}
publishedAt={post.publishedAt}
/>
),
{
...size,
}
);
}
// ✅ Clean separation
// ✅ Reuse across routes
// ✅ Consistent branding
// ✅ Easy updatesComplete Social Card Examples
Example 1: E-commerce Product Card
import { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: { id: string };
}): Promise<Metadata> {
const product = await getProduct(params.id);
return {
title: `${product.name} - ${product.brand}`,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
type: 'product',
url: `https://mystore.com/products/${params.id}`,
siteName: 'My Store',
images: [
{
url: product.images[0],
width: 1200,
height: 630,
alt: product.name,
},
],
// Product-specific metadata
'product:price:amount': product.price.toString(),
'product:price:currency': 'USD',
'product:availability': product.inStock ? 'in stock' : 'out of stock',
'product:brand': product.brand,
'product:category': product.category,
},
twitter: {
card: 'summary_large_image',
title: `${product.name} - Only $${product.price}`,
description: product.description,
images: [product.images[0]],
},
};
}
// ✅ Product-specific Open Graph
// ✅ Price and availability
// ✅ Engaging Twitter title
// ✅ Primary product imageimport { ImageResponse } from 'next/og';
export const size = { width: 1200, height: 630 };
export default async function Image({
params
}: {
params: { id: string }
}) {
const product = await getProduct(params.id);
return new ImageResponse(
(
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
backgroundColor: '#fff',
}}
>
{/* Product image side */}
<div
style={{
display: 'flex',
width: '50%',
height: '100%',
backgroundColor: '#f3f4f6',
alignItems: 'center',
justifyContent: 'center',
}}
>
{/* Product image would go here */}
<div
style={{
fontSize: 120,
}}
>
📦
</div>
</div>
{/* Product info side */}
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '50%',
padding: '60px',
justifyContent: 'center',
}}
>
{/* Brand */}
<div
style={{
fontSize: 32,
color: '#6b7280',
marginBottom: 20,
}}
>
{product.brand}
</div>
{/* Product name */}
<div
style={{
fontSize: 56,
fontWeight: 'bold',
color: '#1f2937',
lineHeight: 1.2,
marginBottom: 30,
}}
>
{product.name}
</div>
{/* Price */}
<div
style={{
fontSize: 64,
fontWeight: 'bold',
color: '#10b981',
}}
>
${product.price}
</div>
{/* Stock status */}
<div
style={{
fontSize: 28,
color: product.inStock ? '#10b981' : '#ef4444',
marginTop: 20,
}}
>
{product.inStock ? '✓ In Stock' : '✗ Out of Stock'}
</div>
</div>
</div>
),
{ ...size }
);
}
// ✅ Split layout design
// ✅ Product image placeholder
// ✅ Price highlighted
// ✅ Stock status visibleExample 2: Event Card
import { ImageResponse } from 'next/og';
export default async function Image({
params
}: {
params: { id: string }
}) {
const event = await getEvent(params.id);
const eventDate = new Date(event.date);
return new ImageResponse(
(
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
backgroundColor: '#0f172a',
color: '#fff',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Background pattern */}
<div
style={{
position: 'absolute',
width: '100%',
height: '100%',
backgroundImage: 'radial-gradient(circle, #1e293b 1px, transparent 1px)',
backgroundSize: '50px 50px',
opacity: 0.3,
}}
/>
{/* Content */}
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '80px',
position: 'relative',
}}
>
{/* Date badge */}
<div
style={{
display: 'flex',
flexDirection: 'column',
backgroundColor: '#7c3aed',
padding: '30px',
borderRadius: '20px',
alignItems: 'center',
width: '200px',
marginBottom: 40,
}}
>
<div style={{ fontSize: 72, fontWeight: 'bold' }}>
{eventDate.getDate()}
</div>
<div style={{ fontSize: 36 }}>
{eventDate.toLocaleDateString('en-US', { month: 'short' })}
</div>
</div>
{/* Event name */}
<div
style={{
fontSize: 80,
fontWeight: 'bold',
lineHeight: 1.1,
marginBottom: 30,
}}
>
{event.name}
</div>
{/* Location & Time */}
<div
style={{
display: 'flex',
flexDirection: 'column',
fontSize: 36,
color: '#94a3b8',
gap: 15,
}}
>
<div>📍 {event.location}</div>
<div>🕐 {event.time}</div>
</div>
</div>
</div>
),
{ width: 1200, height: 630 }
);
}
// ✅ Event-specific design
// ✅ Date prominently displayed
// ✅ Location and time
// ✅ Dark, modern themeOpen Graph Image Structure
Organization of OG image generators
Select a file or folder to see details
Testing and Debugging Social Cards
Testing Tools
Essential Testing Tools
- Facebook Sharing Debugger:
https://developers.facebook.com/tools/debug/ - Twitter Card Validator:
https://cards-dev.twitter.com/validator - LinkedIn Post Inspector:
https://www.linkedin.com/post-inspector/ - OpenGraph.xyz:
https://www.opengraph.xyz/ - Meta Tags:
https://metatags.io/
Common Issues and Solutions
// ❌ PROBLEM: Image not showing
// Solution 1: Check image size (1200x630 recommended)
openGraph: {
images: [
{
url: 'https://example.com/og-image.jpg',
width: 1200, // ✅ Specify dimensions
height: 630,
},
],
}
// ❌ PROBLEM: Old image cached
// Solution 2: Add version parameter to URL
openGraph: {
images: ['https://example.com/og-image.jpg?v=2'],
}
// ❌ PROBLEM: Wrong image shown
// Solution 3: Use absolute URLs
openGraph: {
images: ['https://example.com/og-image.jpg'], // ✅ Absolute
// NOT: '/og-image.jpg' // ❌ Relative won't work
}
// ❌ PROBLEM: Different platforms show different images
// Solution 4: Specify platform-specific images
openGraph: {
images: ['https://example.com/og-image.jpg'],
}
twitter: {
images: ['https://example.com/twitter-image.jpg'], // Twitter-specific
}
// ✅ Test on all major platforms
// ✅ Clear cache in testing tools
// ✅ Use absolute URLs
// ✅ Specify dimensionsSocial Card Best Practices
1. Use Correct Image Sizes
// ✅ GOOD: Recommended sizes
// Open Graph: 1200x630 (1.91:1 ratio)
// Twitter summary_large_image: 1200x630
// Twitter summary: 120x120 (1:1 ratio)
export const metadata: Metadata = {
openGraph: {
images: [
{
url: 'https://example.com/og-image.jpg',
width: 1200,
height: 630,
alt: 'Descriptive alt text',
},
],
},
};
// ❌ BAD: Wrong sizes
// 800x600 - Too small
// 2000x2000 - Wrong ratio, will be cropped2. Always Include Alt Text
// ✅ GOOD: Descriptive alt text
openGraph: {
images: [
{
url: 'https://example.com/post-image.jpg',
alt: 'Complete guide to Next.js Server Actions with code examples',
},
],
}
// ❌ BAD: No alt text or generic alt
openGraph: {
images: ['https://example.com/image.jpg'], // No alt
// OR
images: [{ url: '...', alt: 'image' }], // Too generic
}3. Make Titles Engaging for Social
// ✅ GOOD: Social-optimized titles
export const metadata: Metadata = {
// Page title: Professional, keyword-rich
title: 'Next.js Server Actions Tutorial - Complete Guide',
// OG title: Engaging, benefit-focused
openGraph: {
title: 'Build Faster Forms with Next.js Server Actions',
},
// Twitter title: Can be more casual
twitter: {
title: 'Next.js Server Actions are 🔥 - Complete Tutorial',
},
};
// ❌ BAD: Same boring title everywhere
title: 'Tutorial'
openGraph: { title: 'Tutorial' }
twitter: { title: 'Tutorial' }4. Include Fallback Images
// ✅ GOOD: Multiple images with fallback
openGraph: {
images: [
{
url: post.coverImage || 'https://example.com/default-og.jpg',
width: 1200,
height: 630,
},
// Fallback
{
url: 'https://example.com/site-default.jpg',
width: 1200,
height: 630,
},
],
}
// If primary image fails, platforms use second5. Test on All Major Platforms
// ✅ GOOD: Test workflow
// 1. Deploy your changes
// 2. Test on Facebook Sharing Debugger
// 3. Test on Twitter Card Validator
// 4. Test on LinkedIn Post Inspector
// 5. Share on actual platforms
// 6. Clear cache if needed (click "Scrape Again")
// Platforms cache aggressively!
// Always verify on actual platforms after changesKey Takeaways
- 1200x630 pixels - recommended OG image size
- ImageResponse - generate dynamic OG images
- opengraph-image.tsx - file-based image generation
- summary_large_image - best Twitter card type
- Platform-specific - OG and Twitter can differ
- Absolute URLs - always use full URLs for images
- Alt text - accessibility and fallbacks
- Test everywhere - Facebook, Twitter, LinkedIn validators
What's Next?
You've mastered Open Graph and social media cards! Next, we'll explore Sitemap and Robots.txt—generating dynamic sitemaps, configuring robots.txt for search engine crawlers, and optimizing your site for discoverability. You'll complete the SEO fundamentals for production-ready Next.js apps!
We'll cover sitemap.xml generation, robots.txt configuration, XML sitemap best practices, and ensuring search engines can properly crawl and index your content.
🎨 Design Matters
Great OG images significantly increase click-through rates. Invest time in creating eye-catching designs with clear text, consistent branding, and engaging visuals. A/B test different designs to see what works best for your audience!