Advanced OpenGraph Images
You will need to have at least a Professional level subscription to unlock the custom render sizes and transparent backgrounds functionality used in this tutorial.
On occasion you do not have the ability to change the image or link sources after you have published the content. This is the case with email and sites that allow you to provide custom HTML for a profile. This often includes syncronized content, maybe the hero text matches the first item in a grid, or the links should drive to the same place.
Let’s use an email campaign as an example. We want to showcase the top viewed games that the user might be interested in. How we get those games is out of scope so we will be using a strawman implementation.
const getData = async () => {
return [
{
"name": "League of Legends",
"boxArtURL": "/league-of-legends.png",
"url": "https://example.com/league-of-legends",
"viewersCount": "688K"
},
{
"name": "VALORANT",
"boxArtURL": "/valorant.png",
"url": "https://example.com/valorant",
"viewersCount": "123K"
},
{
"name": "World of Warcraft",
"boxArtURL": "/world-of-warcraft.png",
"url": "https://example.com/world-of-warcraft",
"viewersCount": "96K"
},
{
"name": "Fortnite",
"boxArtURL": "/fortnite.png",
"url": "https://example.com/fortnite",
"viewersCount": "91K"
},
{
"name": "Apex Legends",
"boxArtURL": "/apex-legends.png",
"url": "https://example.com/apex-legends",
"viewersCount": "51K"
},
{
"name": "Call of Duty: Modern Warfare II",
"boxArtURL": "/call-of-duty-modern-warfare-ii.png",
"url": "https://example.com/call-of-duty-modern-warfare-ii",
"viewersCount": "341"
}
];
};
Origins
We need to think of our opengraph image template as an API that has two endpoints. One for the hero image and one for the card image. Our hero image doesn’t need to take any parameters since it will use the first record. The card image will require an id
parameter to specific which card to render.
We can accomplish both with one HTML template.
Hero Image Origin
There’s no special considerations for the hero image. We will take the first record and combine it’s name
property with some additional text. Our origin will have a default render parameter of asset=hero
. This will let us use the top game at the time of our origin creation for our default image.
Our render url would look something like this for our hero:
-
/?asset=hero
Because our origin has a default render param of asset=hero
, we can simplify the url we sign to just /
.
Card Origin
The card image has one big thing to consider. If the text is too long, it will need to wrap which will cause the image to have different heights for each card. To handle for this, we will render all of the cards at the same time, then use the clip
helper to only capture the specific HTML element based on the id
query parameter. Our origin will have a default render parameter of asset=card
and id=0
to snapshot the first card for our default origin.
We will generate 6 different render urls for our cards, one for each grid place.
-
/?asset=card&id=0
-
/?asset=card&id=1
-
/?asset=card&id=2
Because our origin has a default render param of asset=card
, we can simplify the url we sign:
-
/?id=0
-
/?id=1
-
/?id=2
Fallback Images
Normally you would want to allow your template to include a fallback version that can be used as the default render image displayed if the render fails. For the hero this could be generic text and for the card it could be a transparent placeholder.
Origin HTML Template
This is the final html template to use for your origins. You can test the individual images by opening the page in your browser and changing the query string. The opengraph image calls will be output in the javascript console.
-
/?asset=hero
-
/?asset=card&id=0
-
/?asset=card&id=1
-
/?asset=card&id=2
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com?plugins=aspect-ratio"></script>
<script src="https://unpkg.com/alpinejs" defer></script>
</head>
<body>
<div x-data="openGraphImage()">
<div class="relative w-[600px] text-white">
<!--
Wait for the `topGame` to load before triggering the `x-init`
-->
<template x-if="topGame">
<a :href="topGame.url" class="block text-6xl font-light leading-tight" x-init="captureHero()">
Explore <span x-text="topGame.name"></span>, one of the most watched categories.
</a>
</template>
</div>
<!--
Render our game cards in a grid for a consistent height with word wrapping. This
also allows us to bottom justify the "Watch Now" link.
We want our cards to be `160px` wide, so we set the container to `960px`.
`160px * 6 = 960px`
-->
<div class="grid grid-cols-6 gap-0 w-[960px]">
<template x-for="(game, index) in games" :key="game.name">
<!-- We need to turn our `index` variable into a string so it will match the query parameter. -->
<a class="relative flex flex-col h-full text-white" :href="game.url" x-init="captureCard(index.toString(), game)">
<div class="aspect-w-3 aspect-h-4">
<img :src="game.boxArtURL" />
</div>
<div class="flex flex-col justify-between flex-1">
<div class="flex-1 mt-1 mb-2 space-y-0.5">
<div x-text="game.name"></div>
<div class="text-sm text-gray-300">
<span x-text="game.viewersCount"></span> viewers
</div>
</div>
<div class="text-purple-500 grow-0">
Watch Now
</div>
</div>
</a>
</template>
</div>
</div>
<script>
window.__og = window.__og || [];
document.addEventListener('alpine:init', () => {
Alpine.data('openGraphImage', () => {
const params = new URLSearchParams(window.location.search);
return {
params: Object.fromEntries(params.entries()),
games: [],
async init() {
// Load the API data on init
this.games = await getData();
},
get topGame() {
// Grab the first record to use for the hero.
return this.games[0];
},
// The `x-init` method for our `hero` asset.
captureHero() {
const { $el, topGame, params } = this;
const { asset } = params;
// Only trigger the helpers when the `asset` query parameter equals `hero`
if ('hero' === asset) {
this.$nextTick(() => {
this.setClipping($el);
this.setLink($el);
this.setMetadata(topGame);
this.preload();
this.ready();
});
}
},
// The `x-init` method for our `hero` asset.
captureCard(index, game) {
const { $el, params } = this;
const { asset, id } = params;
// Only trigger the helpers when the `asset` query parameter equals `card`
// and the index of card being initialized matches the `id` query parameter.
if ('card' === asset && index === id) {
this.$nextTick(() => {
this.setClipping($el);
this.setLink($el);
// For our actual game cards we want to capture the `metadata` and
// ensure the box art is loaded before rendering.
if (game) {
this.setMetadata(game);
this.preload(game.boxArtURL);
}
this.ready();
});
}
},
/* Helper Methods */
setMetadata(metadata) {
this.push(['metadata', metadata]);
},
setClipping(element) {
const { width, height, x, y } = element.getBoundingClientRect();
this.push(['clip', x, y, width, height]);
},
setLink(element) {
this.push(['link', element.href]);
},
preload(...assets) {
this.push(['preload', 'fonts', ...assets]);
},
ready() {
this.push(['ready']);
},
push(args) {
console.info(args);
window.__og.push(args);
}
};
});
});
const getData = async () => {
return [
{
"name": "League of Legends",
"boxArtURL": "/league-of-legends.png",
"url": "https://example.com/league-of-legends",
"viewersCount": "688K"
},
{
"name": "VALORANT",
"boxArtURL": "/valorant.png",
"url": "https://example.com/valorant",
"viewersCount": "123K"
},
{
"name": "World of Warcraft",
"boxArtURL": "/world-of-warcraft.png",
"url": "https://example.com/world-of-warcraft",
"viewersCount": "96K"
},
// ...
];
};
</script>
</body>
</html>
Design HTML Template
Once you have your origin rendering the images, you need to use the cdn.opengraphimage.com
and link.opengraphimage.com
urls on your site or emails.
Hero
<a href="https://link.opengraphimage.com/zF3O_eUZWAlTGuVBLynO8w/advanced-data/?asset=hero&s=b2773ab0ad6fdb13bcd3886e2b8f9c01">
<img src="https://cdn.opengraphimage.com/zF3O_eUZWAlTGuVBLynO8w/advanced-data/?asset=hero&s=b2773ab0ad6fdb13bcd3886e2b8f9c01" alt="">
</a>
Cards
<a href="https://link.opengraphimage.com/zF3O_eUZWAlTGuVBLynO8w/advanced-data/?asset=card&id=0&s=f75c80bdd2df5ccd4871078651feaa90">
<img src="https://cdn.opengraphimage.com/zF3O_eUZWAlTGuVBLynO8w/advanced-data/?asset=card&id=0&s=f75c80bdd2df5ccd4871078651feaa90" alt="">
</a>
<a href="https://link.opengraphimage.com/zF3O_eUZWAlTGuVBLynO8w/advanced-data/?asset=card&id=1&s=e740fdd601b2add424d46c3005f86bb6">
<img src="https://cdn.opengraphimage.com/zF3O_eUZWAlTGuVBLynO8w/advanced-data/?asset=card&id=1&s=e740fdd601b2add424d46c3005f86bb6" alt="">
</a>
<a href="https://link.opengraphimage.com/zF3O_eUZWAlTGuVBLynO8w/advanced-data/?asset=card&id=2&s=fd2c565110767748050fe052362ae023">
<img src="https://cdn.opengraphimage.com/zF3O_eUZWAlTGuVBLynO8w/advanced-data/?asset=card&id=2&s=fd2c565110767748050fe052362ae023" alt="">
</a>
<!-- ... -->