Devlog 8: Generating Social Preview Images on the Fly
When people share Tuggy matchups on Twitter or Facebook, the preview card is the first thing others see. A good preview = more clicks = more engagement = more viral spread.
Static preview images are easy, but boring. Every matchup looks the same. I wanted custom previews that show:
- The actual matchup contestants
- Their images/avatars
- Current vote counts
- Clean, branded design
And I wanted it generated automatically for every matchup without manually creating images.
The OG Image Problem
The Open Graph protocol lets you specify a preview image via meta tags:
<meta property="og:image" content="https://tuggy.io/og-image.jpg" />
But that's just one static image for the whole site. Boring.
What I wanted:
<meta property="og:image" content="https://tuggy.io/api/og?matchup=cats-vs-dogs" />
Where each URL generates a unique image based on the matchup data.
Vercel's @vercel/og Library
I discovered Vercel has a library that generates images from JSX using their edge runtime. Perfect for dynamic OG images.

Here's my implementation:
// src/routes/api/og/+server.ts
import { ImageResponse } from '@vercel/og';
import { supabase } from '$lib/supabaseClient';
export async function GET({ url }) {
const slug = url.searchParams.get('slug');
// Fetch matchup data
const { data: matchup } = await supabase
.from('matchups')
.select('*')
.eq('slug', slug)
.single();
if (!matchup) {
return new Response('Matchup not found', { status: 404 });
}
// Generate image from JSX
return new ImageResponse(
(
<div style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
fontFamily: 'Inter',
}}>
<h1 style={{ fontSize: 72, color: 'white', margin: 0 }}>
{matchup.title}
</h1>
<div style={{ display: 'flex', marginTop: 60, gap: 100 }}>
{/* Contestant A */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<img src={matchup.image_a} width={200} height={200} style={{ borderRadius: '50%' }} />
<div style={{ fontSize: 48, color: 'white', marginTop: 20 }}>
{matchup.contestant_a}
</div>
<div style={{ fontSize: 36, color: '#ff6b6b', fontWeight: 'bold' }}>
{matchup.votes_a.toLocaleString()} votes
</div>
</div>
{/* VS */}
<div style={{ fontSize: 96, color: 'white', alignSelf: 'center' }}>
VS
</div>
{/* Contestant B */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<img src={matchup.image_b} width={200} height={200} style={{ borderRadius: '50%' }} />
<div style={{ fontSize: 48, color: 'white', marginTop: 20 }}>
{matchup.contestant_b}
</div>
<div style={{ fontSize: 36, color: '#4ecdc4', fontWeight: 'bold' }}>
{matchup.votes_b.toLocaleString()} votes
</div>
</div>
</div>
<div style={{ position: 'absolute', bottom: 40, fontSize: 32, color: 'rgba(255,255,255,0.8)' }}>
tuggy.io
</div>
</div>
),
{
width: 1200,
height: 630, // Standard OG image size
}
);
}
This generates a PNG image on the fly with:
- Gradient background
- Matchup title
- Both contestant images
- Current vote counts
- Branding
Adding It to Pages
In my matchup page's server load function:
// src/routes/[slug]/+page.server.ts
export async function load({ params }) {
const matchup = await getMatchup(params.slug);
return {
matchup,
ogImage: `https://tuggy.io/api/og?slug=${params.slug}`
};
}
Then in the page template:
<svelte:head>
<meta property="og:image" content={data.ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={data.ogImage} />
</svelte:head>
Now every matchup has a unique social preview.
Caching Considerations
Generating images on every request would be slow and expensive. I added caching:
export async function GET({ url, setHeaders }) {
// ... generate image ...
setHeaders({
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
});
return response;
}
Social platforms (Twitter, Facebook) cache OG images aggressively anyway, so even short caching helps a lot.
Fallback for Missing Images
If a matchup doesn't have custom images, I use emoji or colored circles:
const imageA = matchup.image_a || `https://ui-avatars.com/api/?name=${matchup.contestant_a}&size=200`;
This ensures every matchup gets a preview, even if it's just text-based.
The Impact

Before dynamic OG images: Generic preview card. Low click-through rate on social shares.
After dynamic OG images: Eye-catching previews with live vote counts. Click-through rate jumped ~40%.
People are way more likely to click when they see:
- Actual matchup details (not generic branding)
- Live vote counts (creates FOMO)
- Visual matchup (images of contestants)
It makes shares feel like real-time updates instead of static links.
Takeaway
Social preview cards massively affect viral growth. It's the difference between "link" and "content".
Dynamic image generation sounded hard but Vercel's library made it trivial. It's basically just JSX that renders to PNG.
Caching is essential. Generating images is relatively slow (200-500ms). Serving from cache is instant.
The 1200x630 size is the sweet spot for both Twitter and Facebook. Don't reinvent this.
This is another example of a small detail that compounds into significant impact. Better previews → more clicks → more users → more matchups created → more viral spread.
Investing in shareability pays dividends. These dynamic OG images are a key part of my overall SEO strategy and viral mechanics that make Tuggy naturally shareable.