This is a quick tutorial about enabling unfurling for your Astro posts. Supporting social media unfurling doesn’t take much effort, and makes a huge difference on how your links look when they’re shared!
Unfurling is when social media apps displays a preview of the content below a link, which can include a title, some text, an image, and more. For example, here’s the Slack unfurl for my Coupling and modularization post:

Slack isn’t the only platform to support unfurling links — Facebook, X, etc. do as well. Even RCS (the modern replacement for SMS/MMS) unfurls links. Slack is pretty much the only one that I actually use, though. Anyway, we’ll be implementing unfurling using the Open Graph Protocol, so it should work for all those platforms at once.
The post I linked above, Everything you ever wanted to know about unfurling but were afraid to ask, has a great summary of how unfurling works and what options are out there. So I’ll gloss over all that and focus on how to add unfurling metadata in Astro specifically.
The basics
Okay, let’s say we have a Markdown post with the following metadata:
title: "Unfurling posts with Astro"
author: "Luke Turner"
date: 2025-07-31
tags: ["meta", "tutorial"]
summary: |
This is a summary of the post content!
We already use this metadata in various places in our site, and now we want to use it for unfurling as well. Without adding any additional metadata, we can already set the following Open Graph annotations:
og:type
(should be set toarticle
for a blog post)og:title
og:description
og:url
og:locale
We can also set the following Open Graph annotations that are specific to article
-type pages:
article:author
article:published_time
article:tag
(can have multiple)
All we need to do is add the following to our pages/posts/[...id].astro
(or whatever you call your post page):
The following examples assumes the following code in your Astro component:
const { entry } = Astro.props;
Also, adjust the og:locale
if you want a value other than en_US
.
<meta property="og:type" content="article" />
<meta property="og:title" content={entry.data.title} />
<meta property="og:description" content={entry.data.summary} />
<meta property="og:url" content={Astro.request.url} />
<meta property="og:locale" content="en_US" />
<meta property="article:author" content={entry.data.author} />
<meta
property="article:published_time"
content={entry.data.date.toISOString()}
/>
{entry.data.tags.map(tag =>
<meta property="article:tag" content={tag} />
)}
We can also add an og:site_name
element with our site name. This is hardcoded, so you need to adjust the value for your specific site:
<meta property="og:site_name" content="My site name" />
And finally, we should add the following for Twitter/X to unfurl our posts:
<meta name="twitter:card" content="summary_large_image" />
At this point, we have some basic unfurling.
But there’s more we can do!
Adding an image
The coolest thing with unfurling, in my opinion, is being able to include an image. Astro makes this surprisingly slick.
First, we need to add an image
property to the metadata schema for our post collection:
const posts = defineCollection({
// ... other properties ...
schema: ({ image }) => z.object({
// ... other properties ...
image: image().optional()
})
});
The cool thing about this is we can refer to the image with a relative local path, and Astro will import it automatically and tell us the resulting URL in the compiled site.
For example, we can reference an image like in our frontmatter this:
# ... other post metadata ...
image: "@assets/unfurling/slack_unfurl.png"
And Astro will automatically import the image and tell us the final URL, which will be something like:
/_astro/slack_unfurl.Bql0cxLn.png
Next we need to update our post template to include an og:image
annotation.
In pages/posts/[...id].astro
JavaScript, add:
const ogImage = entry.data.image
? new URL(entry.data.image.src, Astro.url)
: null;
And finally add a <meta>
element for the og:image
annotation:
{ogImage
? <meta property="og:image" content={ogImage} />
: null}
A couple notes on this:
- Since the
image
property is optional, we need to handle the case where a post doesn’t have an image. If every post has an image, we could remove the ternaries. - The
og:image
value must be a fully-qualified URL./images/foo.png
won’t work — it has to behttps://myblog.com/images/foo.png
. The above code handles this by adding theAstro.url
base URL to the image’s path.
That’s it for the plumbing! Now we can just add the image
property to our posts’ metadata with the import path for an image asset, and that image will automatically be used when unfurling!
Reading time
One cool feature of the Twitter-specific unfurling annotations is the ability to add arbitrary key-value pairs that get rendered (by some platforms) when the post is unfurled. They’re defined like this:
<meta name="twitter:label1" content="Favorite ice cream" />
<meta name="twitter:data1" content="Chocolate peanut butter" />
<meta name="twitter:label2" content="Did AI write this?" />
<meta name="twitter:data2" content="No" />
<!-- etc. -->
We can use this to add a Reading time annotation that gets calculated automatically based on the article’s length.
Calculating the reading time requires a few additional dependencies. We could just pass our raw Markdown content through the reading-time library, but for maximum accuracy, we’ll use mdast to convert our Markdown content into a string first. This way, the reading-time
library isn’t counting markup that users don’t actually see (like links with long URLs).
So let’s install those dependencies:
npm i reading-time mdast-util-from-markdown mdast-util-to-string
Then add the following to your pages/posts/[...id].astro
JavaScript:
import readingTime, { type ReadTimeResults } from 'reading-time';
import {fromMarkdown as mdastFromMarkdown} from 'mdast-util-from-markdown';
import {toString as mdastToString} from 'mdast-util-to-string';
import { type CollectionEntry } from 'astro:content';
// ... other code ...
const readTime = getReadingTime(entry);
function getReadingTime(post: CollectionEntry<"posts">): ReadTimeResults {
const body = mdastToString(mdastFromMarkdown(post.body || ''));
return readingTime(body);
}
Finally, we can add the <meta>
annotations to expose the reading time:
<meta name="twitter:label1" content="Reading time" />
<meta
name="twitter:data1"
content={Math.ceil(readTime.minutes) + " minutes"}
/>
And that’s it!
Of course, estimated reading time isn’t only useful for unfurling — I’ve added it to the top of each of my posts as well as all my “list of post” pages, so users know what they’re getting into.
Well, I said at the beginning that it wouldn’t take much effort to support unfurling, and I hope this post has proved the point. But, of course, it’s never that simple in the real world!
The big problem now: if we want our unfurls to consistently look good, we need to make sure that each post has metadata that looks good when unfurled:
- A title that’s not too long or too short.
- A description that’s, again, not too long or too short.
- An image that is the proper size and aspect ratio.
I use https://socialsharepreview.com/ to check what a post looks like on various platforms without actually, you know, joining the platforms, but it’s hard to have a pixel-perfect unfurl everywhere.
Having said that, I’m not very picky about this personally, just having an image is good enough for my little personal blog. But in a marketing-focused context, you should definitely review the unfurled appearance of each post, and make sure your images aren’t getting weirdly cropped!