All writing
  • marketing
  • engineering
  • geo
  • seo

How to leverage dynamic social image previews to delight your user and improve your GEO

Your users are sharing links, show them better content in image previews

You noticed that your users are sharing links to your site with their friends and colleagues. This is a great GEO and SEO opportunity. Most sites show the exact same preview image for every link (remember og:image tags?), usually a logo on a coloured background. You can do better by showing a dynamic image that is different for every page. Here is one such image from my product.

A share image for the DJUST Sales listing on AI Agents Live. The top half shows its logo, name, tag line, a 4.7 rating
from 6 ratings and two category pills. The bottom half quotes the latest review, written in French by Marie
Balandreau.

This image is live, so by the time you read this it may quote a newer review than the one I saw. That is the whole idea. The top half is a short version of the listing, with its logo, name, tag line, rating and categories. The bottom half quotes the most recent written review. If a listing has no written review yet, the bottom half shows our logo and the line “See more on AI Agents Live”, translated into the reader’s language.

Here is how you can do it. As usual I’m using my beloved Rails to achieve this. The ideas carry over to any server framework, so you can follow along even if you write Python or Node.

When a person pastes a link into a social network, a chat app or a team messenger, that service does not wait for anybody to click. A small program (a crawler, sometimes called a link preview bot) fetches your page in the background. It reads the HTML, looks for a handful of <meta> tags and builds a card from them. That card has a title, a short description and, most visibly, an image.

The tags come from the Open Graph protocol, which Facebook created and almost every other service adopted. The image is og:image. X reads its own twitter:image tag and the other networks read og:image, so I set both and point them at the same URL. The protocol also defines og:image:width, og:image:height, og:image:type and og:image:alt. Width and height help the crawler lay out the card before the image downloads. The alt text describes what the image shows for people who use screen readers.

Size matters here. Facebook’s own guide to images in link shares asks for at least 1200 by 630 pixels and an aspect ratio close to 1.91:1, so that the image shows in full without cropping. I use exactly 1200 by 630 for every image. It looks sharp on high resolution screens, and I have not seen a network crop it badly yet.

Here is what the head of a listing page sends now.

<% og_image_url = share_image_agent_url(id: @agent.sharable, v: ShareImage.new(@agent).version) %>
<meta property="og:image" content="<%= og_image_url %>">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="<%= ShareImage::WIDTH %>">
<meta property="og:image:height" content="<%= ShareImage::HEIGHT %>">
<meta property="og:image:alt" content="<%= content_for :title %>">
<meta name="twitter:image" content="<%= og_image_url %>">
<meta name="twitter:card" content="summary_large_image">

The twitter:card value summary_large_image asks for the big card instead of the small square thumbnail. Without it your carefully made image shows up the size of a postage stamp.

Why a dynamic image helps GEO and SEO

Let me be honest about this part, because it is easy to oversell. I do not know of any search engine or AI assistant that ranks a page higher because its preview image is pretty. The image is not a ranking signal that I can point to.

What the image changes is the conversation around your link. In my guide to generative engine optimization I wrote that AI assistants lean on what other people say about you, in reviews, forum threads and comparisons, much more than on your own homepage. A shared link is exactly that kind of third-party mention. A preview that carries real information makes the people in that thread more likely to click, read and reply. Some of them leave their own review, which then shows up on the next person’s preview. The image does not do GEO by itself. It helps the slow loop that GEO depends on.

There is also a plain trust argument. A generic logo tells the reader nothing they could not guess from the domain name. A card that says “4.7 stars from 6 ratings” and quotes a real customer in their own words gives the reader a reason to click before they leave the chat. That is social proof (other people’s experience used as evidence), and it works best when it is specific and recent.

Decide what goes into the image

Before writing any code, I sat down and decided what the image should say. The rule I gave myself was that the top half must look like the short card we already show in lists on the site, so the preview and the page feel like the same product. That card has the logo, the name, the tag line, the star rating with the number of ratings and two or three small pills for the type and categories.

The bottom half took more thought. My first idea was to show the best review, but “best” is a choice I would have to defend, and a five-star review from two years ago is less useful than a fair review from last week. So the image shows the latest written review. Reviews that only have stars are skipped, because a row of stars without words gives the reader nothing to read. I kept the rule that simple on purpose. Every written review on our site comes from a signed-in account, so requiring text also means the quote always has a real person behind it.

Then I planned for the empty case. A new listing has no written review. Leaving half the image blank looks broken, and inventing a quote is out of the question. So the bottom half shows our logo and one short line, “See more on AI Agents Live”. It is honest, it looks finished and it gives our brand a little space on cards for new listings.

Last, I decided that the image must respect the language of the page. Our site runs in eleven locales, including Marathi, Hindi, Japanese, Korean and both Chinese scripts. If a French page shares a card that says “6 ratings” in English, it looks like a cheap copy. Every label in the image goes through the same translation files as the rest of the site, and dates use the locale’s own long format. The review itself stays in whatever language the reviewer wrote it, which is why an English card can quote a French customer, like the example above.

Pick a way to draw the picture

There are three common ways to turn data into a PNG on a server, and each has a real cost.

The first is a headless browser. You build the card as an HTML page, open it in a browser with no window and take a screenshot. You get full CSS, real fonts and layout for free. You also get a browser in your production container, which is large, slow to start and one more thing to patch. For one image size and one layout, that felt like moving a house to hang a picture.

The second is SVG converted to PNG. SVG is lovely for shapes, stars and rounded rectangles, but it has no automatic line wrapping. A long review would run off the right edge of the image unless you measure and break every line yourself.

The third is an image library that can draw text and stack layers. Our app already uses libvips for Active Storage, the part of Rails that makes thumbnails of uploaded logos. libvips describes itself as a fast image processing library with low memory needs. It also has a text function built on Pango, the text layout engine that the GNOME desktop on Linux uses. Pango wraps lines to a width, shapes complex scripts like Devanagari and accepts a little markup for colour and weight. Since libvips was already installed in the production image, this option added no new system dependency for the drawing itself.

So I used SVG for the shapes (stars, pills, rounded frames) and Pango text for every word, and let libvips stack them together. Each piece becomes a small image, and the final picture is all of those pieces composited onto a white canvas at known positions.

def render
  layers = []
  layers << [ shape(%(<rect width="1200" height="8" fill="#f59e0b"/>), 1200, 8), 0, 0 ]
  layers.concat(listing_layers)
  layers << [ divider, GUTTER, HALF ]
  layers.concat(review ? review_layers : see_more_layers)

  base = Vips::Image.black(WIDTH, HEIGHT).new_from_image([ 255, 255, 255, 255 ]).copy(interpretation: :srgb)
  base.composite(layers.map(&:first), :over, x: layers.map { it[1] }, y: layers.map { it[2] })
    .flatten(background: [ 255, 255, 255 ])
end

Two small helpers do most of the layout work. One places images in a row and centres them vertically. The other stacks them in a column. With those two, the rating line is a row of the number, the stars and the count, and the right side of the top half is a column of name, tag line, rating row and pills. The whole column is then centred inside the top half, so a listing without a tag line still looks balanced.

def row(images, gap:)
  width = images.sum(&:width) + gap * (images.size - 1)
  height = images.map(&:height).max
  x = 0
  positions = images.map { |image| [ x, (height - image.height) / 2 ].tap { x += image.width + gap } }
  Vips::Image.black(width, height, bands: 4).copy(interpretation: :srgb)
    .composite(images, :over, x: positions.map(&:first), y: positions.map(&:last))
end

One gotcha cost me a few minutes. A blank canvas made with Vips::Image.black has no colour space, and libvips refuses to composite colour images onto it. Tagging the canvas as sRGB with copy(interpretation: :srgb) fixed it. If you see the error “no known route from multiband to srgb”, that is the cause.

Text is the hard part

Shapes are easy. Text is where an image like this succeeds or fails, because you do not control what people write. A listing can have a name that is three words long or thirty. A review can be one line or two thousand characters.

Pango handles the wrapping. You give it a width and it breaks lines at word boundaries. What it does not do is stop after three lines. So I wrote a small fitting function. It renders the text, checks the height and, if the text is too tall, searches for the longest beginning of the text that still fits with an ellipsis (the “…” character) at the end. A binary search keeps this to a handful of renders, even for a review that runs to several paragraphs.

def fitted_text(string, max_height:, **options)
  image = text(string, **options)
  return image if image.height <= max_height

  graphemes = string.each_grapheme_cluster.to_a
  low, high = 0, graphemes.size
  while low < high
    mid = (low + high + 1) / 2
    if text("#{graphemes.first(mid).join.rstrip}…", **options).height <= max_height
      low = mid
    else
      high = mid - 1
    end
  end
  text("#{graphemes.first(low).join.rstrip}…", **options)
end

Notice that it cuts by grapheme clusters, not by characters. A grapheme cluster is what a reader sees as one letter, and the rules for it are written down in the Unicode standard on text segmentation. In Marathi, a consonant plus a vowel sign is one visible letter made of two code points. Cut between them and you get a broken letter at the end of the line. The same goes for emoji made of several code points. Ruby’s each_grapheme_cluster does the right thing, so I never cut half a letter.

The other text problem is escaping. Pango reads a small markup language, so a review that contains < or & would break the render or, worse, change the styling. Every string goes through HTML escaping before it reaches Pango. I learned to treat user text in markup the same way I treat it in a web page.

Fonts and languages

Our site uses the Lexend typeface, and I wanted the image to match. Lexend is published under the SIL Open Font License, which allows bundling it with an app. I added the font file and its licence text to the repository and pass the file to libvips with the fontfile option.

Lexend covers Latin scripts, but not Devanagari, Japanese, Korean or Chinese. Pango solves this with font fallback. When a character is missing from the main font, it asks fontconfig (the font lookup system on Linux) for another font that has it. That only works if such a font is installed. Our production image is a small Debian container with almost no fonts, so I added the Noto font packages to the Dockerfile. Noto was designed to cover nearly every writing system, which makes it a good safety net. Without it, every non-Latin name would render as a row of empty boxes.

Then my laptop disagreed with production. On macOS, Pango uses Apple’s own text system by default, and that system never sees a font loaded through fontfile. The images looked fine, but they were in Helvetica instead of Lexend, and nothing warned me. The fix is one environment variable that tells Pango to use fontconfig on every platform. On Linux this changes nothing, because fontconfig is already the default there.

# config/initializers/pango.rb
ENV["PANGOCAIRO_BACKEND"] ||= "fc"

For the words in the image, I added a small group of translation keys for each locale. There is the “See more” line, the rating count with proper plural forms and a word for a reviewer who has no display name. Dates go through Rails’ I18n.l, so a Japanese card shows the date the way a Japanese reader expects it, and the pill for the listing type uses the same translated label the site already shows in its filters. Numbers are formatted per locale too, so a German card shows “4,3” with a comma.

Wire it into Rails

The image needs a URL. I added a member route on each listing resource, inside the same optional locale scope as the pages. That way the French page points at a French image with a URL like /fr/agents/:id/share_image, and the crawler gets the right language without any cookie or header tricks.

scope "(:locale)", locale: LOCALE_SEGMENT do
  resources :agents, constraints: { id: /[^\/]+/ } do
    member { get :share_image }
  end
end

Two routing details tripped me up. First, our listing ids contain a dot, like 0c3b0c3b.djust-sales-ai-platform. By default Rails treats everything after a dot as a file format, so the route needs a constraint that allows dots in the id. Second, with an optional segment like (:locale) at the front, a positional argument to the URL helper fills the locale, not the id. So share_image_agent_url(@agent.sharable) quietly builds a broken URL. Passing id: as a keyword fixed it, and a test now checks the exact URL in the meta tag.

The controller action is short. It finds a listed record, renders the PNG (or reads it from the cache) and sends it back. Hidden or unpublished listings return “not found”, so a link to a draft never leaks a preview.

def share_image
  send_share_image Agent.listed.find_by_sharable!(params.expect(:id))
end

def send_share_image(record)
  Analytics.capture(event: "share_image_fetched", properties: {
    listing_type: record.class.name.downcase, listing_id: record.id,
    locale: I18n.locale.to_s, fetcher: ShareImage.fetcher(request.user_agent)
  })
  expires_in 0, public: true
  send_data ShareImage.new(record).to_png, type: "image/png", disposition: "inline"
end

Keep it fresh without rendering on every request

Rendering one image takes around a tenth of a second on my laptop, plus a download of the logo and the reviewer’s avatar. That is fine once, but not for every crawler request. So the finished PNG goes into the Rails cache, keyed on everything the picture depends on. That means the locale, the listing’s cache version, the latest review’s cache version and the reviewer’s cache version. When any of those change, the key changes and the next request renders a new image. Old entries are never deleted by hand. They stop being read and fall out of the cache when they expire.

Social networks have their own cache, and that one is harder to control. Most of them store the image per URL for a long time. If the URL never changes, a new review may not appear in previews for weeks. So the page adds a short version string to the image URL, ?v=4b2832d133, made from a hash of the same cache key. A new review means a new version, a new URL and a fresh fetch from every network. The controller ignores the parameter, so an old URL still returns the current image.

I also thought about storing each image as a file in object storage instead of the cache. That would survive cache evictions and could be served straight from a CDN. It would also mean deleting files when they go stale, cleaning up when a listing is hidden and tidying images for locales nobody shares in. For now, the cache does all of that for free. I will switch when the images start pushing other entries out of the cache, and not before.

The last piece was measurement. If I spend a day on an image, I want to know whether anybody sees it. Every fetch of the image sends an event to our product analytics tool, with the listing, the locale and the name of the service that fetched it.

The service name comes from the User-Agent header, because link preview bots identify themselves. Facebook’s crawler says facebookexternalhit, LinkedIn’s says LinkedInBot and so on. A short list of patterns maps them to friendly names, and anything that looks like a bot but is not on the list goes into an “other bot” bucket. My favourite surprise was Telegram, whose crawler calls itself “TelegramBot (like TwitterBot)”. My first version counted every Telegram share as an X share, until a test caught it. The fix was to check for Telegram before X.

FETCHERS = {
  /facebookexternalhit|Facebot|meta-externalagent/i => "facebook",
  /TelegramBot/i => "telegram",
  /Twitterbot/i => "x",
  /LinkedInBot/i => "linkedin",
  /Slackbot|Slack-ImgProxy/i => "slack",
  /Discordbot/i => "discord",
  /WhatsApp/i => "whatsapp",
  /bot|crawler|spider|preview|fetch/i => "other_bot"
}.freeze

Counting forced a trade-off with HTTP caching. My first version sent a one-day public cache header. That is good for speed, but it lets a proxy or CDN answer repeat requests without ever reaching the app, so those fetches would never be counted. I changed it to max-age=0. Every fetch now reaches Rails, and since the PNG is already in the cache, a repeat costs a cache read, not a render.

Be careful what this number means. Networks cache the image on their side, so one fetch can be seen by many people. The count tells you how often your links get shared and unfurled, and on which networks. It does not tell you how many people looked at the card. I find it most useful as a trend, and the split by network tells me where people talk about our listings.

Plan for failure

An image that fails is worse than a plain one, because the crawler shows an empty grey box. So every external step has a fallback. If the logo cannot be downloaded from storage, or libvips cannot read it, the image draws a soft tile with the first letter of the listing’s name. The same goes for the reviewer’s avatar, which falls back to the cartoon avatars the site already uses. Both failures are logged, so I can find them later, but neither breaks the picture.

Some of our service listings have a logo that lives on someone else’s server. I decided not to fetch those while a crawler is waiting. A slow or broken third-party server would make our image slow or broken too. Those listings get the letter tile, and I am fine with that trade.

Test the words, not the pixels

Testing an image sounds hard, but most of the bugs I care about are about content, not pixels. Did it quote the right review? Did it skip the star-only one? Is the date in French on the French image? To answer those, the tests use a tiny subclass of the image renderer that records every string it draws. Then a test can say “the drawn text includes the French word for anonymous” without reading a single pixel.

class RecordingShareImage < ShareImage
  def drawn = @drawn ||= []

  private

  def text(string, **)
    drawn << string
    super
  end
end

Around that, a few plain checks cover the rest. Every locale renders an image of exactly 1200 by 630. Every locale has the translation keys. A name of thirty words and a review of a thousand words both end with an ellipsis instead of overflowing. A listing with Japanese and Marathi in its name renders. An unreadable logo still produces an image. The version changes when a new review arrives. None of these needed a screenshot comparison tool, and they run in a couple of seconds.

I still looked at the real images with my own eyes, many times, in English, German, Japanese and Marathi. Tests tell you the words are right. Only your eyes tell you the spacing is right.

A plan for your week

If you want to do the same for your product, here is a plan that fits in a few evenings.

  1. On the first evening, share three of your own links in a chat app and look at the previews honestly. Write down what a stranger would learn from them.
  2. On the second evening, decide what one page’s card should say. Pick the facts that would make you click, and plan the empty case for pages that do not have those facts yet.
  3. On the third evening, build the renderer with the image library your app already has, at 1200 by 630, and check it with a long name, a long quote and one non-Latin script.
  4. On the fourth evening, add the route, the meta tags with width, height and alt, the cache and the version parameter on the URL.
  5. On the fifth evening, add one analytics event per fetch, deploy, and paste a link into Facebook’s Sharing Debugger and LinkedIn’s Post Inspector to see the real cards.

None of this needs a new service or a big budget. Your users already share your links. Give the people on the other side of that link a reason to click, and let your customers do the talking for you.