Link previews that show what's behind the link

The picture people see first
When someone shares a link to this site on Discord or anywhere else, the first thing people see isn't the page. It's the preview: a title, a line of text and a picture. For a lot of people that card is the whole impression, so I wanted it to say something about the page it points to.
Next.js makes the picture easy to generate. An opengraph-image.tsx file in a route renders a 1200 by 630 image from JSX with next/og, and Next.js adds the meta tags for it. Mine use the same font as the site, JetBrains Mono, and the same dark colours.
My first version technically worked. Looking at the images side by side, it didn't work very well.
What was wrong with the first version
- Every page looked the same. Only posts and projects had their own image. A link to the projects page, the contact page or the imprint all showed the same "Building useful things for the web."
- Covers were cut in half. A post's or project's cover filled a strip on the right, cropped to fit. Screenshots lost their left half, and headlines broke off mid-word.
- Long titles were cut off. A title that needed more than three lines ended in an ellipsis.
- Technologies broke across lines. "Tailwind CSS" could end up as "Tailwind" at the end of one line and "CSS" at the start of the next, which reads like two different things.
- The alt text said nothing. Every post's image was described as "niso.moe - Post".
A preview for every page
The fix for the first point was mostly writing. Each page now has its own opengraph-image.tsx with a short heading of its own, such as "Things I've built." for the projects or "Get in touch." for the contact page. The lists show a count that updates itself, and the German legal pages have German previews.
Covers that stay whole
Instead of cropping the cover to a fixed box, it now keeps its own shape. It's scaled to 780 pixels wide, tilted by five degrees and placed so it runs off the right edge of the image, like a product shot. Some of it is hidden past the edge, but the important part of a screenshot, usually its left side with the headline, stays visible.
The image renderer can't read WebP, which is what my image store serves. So the cover is fetched, scaled and turned into a PNG with sharp before it goes into the picture. Only pictures from my own image server are fetched, and if anything goes wrong, the preview is simply drawn without a cover.
Titles that fit
Rather than cutting off long titles, the title now gets smaller until it fits. JetBrains Mono is a monospaced font, so every letter is 0.6 times the font size wide, and the number of characters per line is easy to work out:
function titleSize(title: string, width: number, max: number) {
for (let size = max; size > 36; size -= 2) {
const perLine = Math.floor(width / (size * 0.6 - 2.5));
if (Math.ceil((title.length * 1.15) / perLine) <= 3) return size;
}
return 36;
}The extra 15 percent allows for the space lost when a line wraps at a word instead of mid-word. It's not exact, but it doesn't have to be.
The technologies are now separate pieces that each stay on one line, so a name is never split. A project shows at most four of them, which keeps the list to two lines.
Alt text with the real title
A fixed alt export can't contain the post's title. generateImageMetadata can: it runs with the route's parameters and returns the image's details, alt text included.
export async function generateImageMetadata({
params,
}: {
params: { postId: string };
}) {
// Only published posts, so the alt text can't leak the title of a draft
const post = await getPublishedPost(params.postId);
return [
{
id: 'cover',
alt: post ? `${post.title}, a post on niso.moe` : 'Posts on niso.moe',
size: OG_SIZE,
contentType: 'image/png',
},
];
}There is a catch. With generateImageMetadata, Next.js generates its own generateStaticParams for the image route, so my own one for prerendering every post's image had to go. The images are now made on the first request instead of during the build. That costs less than it sounds, because the rendering function is cached with 'use cache', keyed by the preview's text and the cover's address. A new cover always has a new address, so it never shows a stale picture.
The details around the picture
A few smaller things changed in the tags around the image as well:
- No site name in the title. Discord and others show the site name on their own, so "niso.moe - Ludo" turned into "niso.moe" above "niso.moe - Ludo". The preview's title is now just the page's name.
- The right language. The German pages now say they are German, and the English and German legal pages point to each other.
- Descriptions that end on a sentence. A post's description used to be cut at 160 characters, mid-sentence. It now ends on the last whole sentence that fits.
What it looks like now
Every preview has the site's grid, fading into a violet glow in the bottom right corner, the page's own heading, and for posts and projects their cover, whole and slightly tilted. It's a small thing, but it's the part of the site most people see first.
No comments yet. Be the first!