How to Generate a Social Card for Every Product in Your E-Commerce Store
Every product page needs a social card. Auto-generate branded OG images at scale — one per product, from your existing product data — without Figma, Satori, or per-image manual work.
When a customer shares a product link, the preview card is the first impression. A generic site-wide OG image loses the product name, price, and photo. A per-product card — with the product image, name, price, and brand — gets 3× more clicks on social.
Most stores skip it because doing it manually in Figma doesn't scale. Satori and @vercel/og work but require Edge runtime and careful font loading. Here's a simpler path: pass your product data to an HTML template, capture as an image, upload to CDN.
The pattern
product created/updated → render HTML card with product data → PageBolt OG image → upload to CDN → update product metadata
Single product OG image
async function generateProductOgImage(product) {
const html = renderProductCard(product);
const res = await fetch("https://pagebolt.dev/api/v1/og-image", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
width: 1200,
height: 630,
}),
});
if (!res.ok) throw new Error(`PageBolt error ${res.status}: ${await res.text()}`);
return Buffer.from(await res.arrayBuffer());
}
Product card HTML template
function renderProductCard({ name, price, currency, imageUrl, category, brand }) {
const formattedPrice = new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency || "USD",
}).format(price);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px;
height: 630px;
display: flex;
font-family: -apple-system, 'Segoe UI', sans-serif;
background: #fff;
overflow: hidden;
}
.image-panel {
width: 630px;
height: 630px;
flex-shrink: 0;
background: #f8f8f8;
overflow: hidden;
}
.image-panel img {
width: 100%;
height: 100%;
object-fit: cover;
}
.content {
flex: 1;
padding: 56px 48px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.brand {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: #999;
}
.category {
display: inline-block;
background: #f0f0f0;
color: #555;
font-size: 12px;
padding: 4px 10px;
border-radius: 12px;
margin-top: 12px;
}
.name {
font-size: 36px;
font-weight: 800;
line-height: 1.2;
color: #111;
margin-top: 16px;
}
.price {
font-size: 44px;
font-weight: 900;
color: #111;
}
.cta {
display: inline-block;
background: #111;
color: #fff;
font-size: 16px;
font-weight: 600;
padding: 14px 28px;
border-radius: 8px;
width: fit-content;
}
</style>
</head>
<body>
<div class="image-panel">
${imageUrl ? `<img src="${imageUrl}" alt="${name}" />` : ""}
</div>
<div class="content">
<div>
<div class="brand">${brand || "Your Store"}</div>
${category ? `<span class="category">${category}</span>` : ""}
<div class="name">${name}</div>
</div>
<div>
<div class="price">${formattedPrice}</div>
</div>
<div class="cta">Shop now →</div>
</div>
</body>
</html>`;
}
Batch generate for your entire catalog
import { uploadToCDN } from "./storage.js";
async function generateCatalogOgImages(products, { concurrency = 3 } = {}) {
const results = [];
for (let i = 0; i < products.length; i += concurrency) {
const batch = products.slice(i, i + concurrency);
const batchResults = await Promise.allSettled(
batch.map(async (product) => {
const image = await generateProductOgImage(product);
const cdnUrl = await uploadToCDN(image, `og/products/${product.slug}.png`);
console.log(`✓ ${product.name} → ${cdnUrl}`);
return { productId: product.id, cdnUrl };
})
);
results.push(...batchResults);
// Brief pause between batches
if (i + concurrency < products.length) {
await new Promise((r) => setTimeout(r, 300));
}
}
return results;
}
// Run against your full catalog
const products = await db.products.findMany({ where: { active: true } });
await generateCatalogOgImages(products);
Shopify webhook — generate on product create/update
app.post("/webhooks/shopify/products", async (req, res) => {
// Verify Shopify HMAC
const hmac = req.headers["x-shopify-hmac-sha256"];
if (!verifyShopifyWebhook(req.rawBody, hmac)) {
return res.status(401).send("Unauthorized");
}
res.json({ ok: true }); // acknowledge immediately
const product = req.body;
const variant = product.variants?.[0];
const image = await generateProductOgImage({
name: product.title,
price: parseFloat(variant?.price || "0"),
currency: "USD",
imageUrl: product.image?.src,
category: product.product_type,
brand: product.vendor,
slug: product.handle,
});
const cdnUrl = await uploadToCDN(image, `og/products/${product.handle}.png`);
// Update product metafield with OG image URL
await shopify.product.metafield.create(product.id, {
namespace: "seo",
key: "og_image",
value: cdnUrl,
type: "single_line_text_field",
});
console.log(`OG image updated for: ${product.title}`);
});
WooCommerce hook (PHP → Node webhook)
// functions.php
add_action('woocommerce_new_product', 'trigger_og_generation');
add_action('woocommerce_update_product', 'trigger_og_generation');
function trigger_og_generation($product_id) {
$product = wc_get_product($product_id);
wp_remote_post('https://your-og-service.com/webhooks/woocommerce', [
'body' => json_encode([
'id' => $product_id,
'name' => $product->get_name(),
'price' => $product->get_price(),
'slug' => $product->get_slug(),
'image_url' => wp_get_attachment_url($product->get_image_id()),
'category' => implode(', ', wp_get_post_terms($product_id, 'product_cat', ['fields' => 'names'])),
]),
'headers' => ['Content-Type' => 'application/json'],
]);
}
Upload to S3
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({ region: "us-east-1" });
export async function uploadToCDN(buffer, key) {
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: buffer,
ContentType: "image/png",
CacheControl: "public, max-age=31536000",
}));
return `https://${process.env.CLOUDFRONT_DOMAIN}/${key}`;
}
A 1,000-product catalog at 3 concurrent requests takes roughly 6–8 minutes. Run it once to backfill, then keep it current via webhooks on create/update.
Try it free
100 requests/month, no credit card required. OG images, screenshots, PDFs, and video — one API.
Get API Key — Free