Back to Blog
Guide February 26, 2026 · 4 min read

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