0

9 min left

0% complete

Blog

How to Build Rich Link Previews with Open Graph Metadata

Imagine pasting a URL into a chat and instantly seeing a beautifully formatted card with a title, description, and image — not just a plain text link. This seamless experience is powered by Open Graph

·9 min read·

Imagine pasting a URL into a chat and instantly seeing a beautifully formatted card with a title, description, and image — not just a plain text link. This seamless experience is powered by Open Graph (OG) metadata, a protocol that turns web pages into rich, shareable objects across platforms like Facebook, Twitter, Slack, and more.

In this post, we’ll walk through how to build a link preview service that fetches and parses OG metadata, handles edge cases, and delivers fast, reliable previews — all while keeping user experience and performance in mind.


What Is Open Graph Metadata?

Open Graph is a standard introduced by Facebook in 2010 to enable any web page to become a rich object when shared on social platforms. When you share a link, apps use OG metadata to generate previews like this:

🔍 Title

📌 Description

🖼️ Image

🌐 Website name

This metadata lives in the <head> of an HTML document using <meta> tags with the property attribute:

<meta property="og:title" content="GitHub - dotnet/aspnetcore" />
<meta property="og:description" content="ASP.NET Core is a cross-platform .NET framework..." />
<meta property="og:image" content="https://opengraph.githubassets.com/abc123/dotnet/aspnetcore" />
<meta property="og:url" content="https://github.com/dotnet/aspnetcore" />
<meta property="og:site_name" content="GitHub" />

These tags tell clients what to display — no guesswork required.

Key OG Tags We Extract

OG PropertyDescriptionFallback
og:titlePreview title<title> tag
og:descriptionShort summary<meta name="description">
og:imageThumbnail imageNone
og:urlCanonical URL<link rel="canonical">
og:site_nameSource site nameNone

If og:title is missing, for example, we fall back to the regular <title> tag. This layered approach ensures previews still work even when OG tags aren't perfectly implemented.


Extracting Data From HTML: The Technical Details

To extract this metadata, we need to fetch a URL, parse its HTML, and pull out relevant tags. Here's how we do it using C# and the popular HtmlAgilityPack library.

Parsing Open Graph Tags

We use XPath queries to locate meta tags by their property or name attributes:

private static string GetMetaContent(HtmlDocument doc, string property)
{
    // First, try Open Graph style: <meta property="og:title" content="...">
    var node = doc.DocumentNode.SelectSingleNode($"//meta[@property='{property}']");
    if (node != null)
        return node.GetAttributeValue("content", null);

    // Fall back to standard meta tags: <meta name="description" content="...">
    node = doc.DocumentNode.SelectSingleNode($"//meta[@name='{property}']");
    return node?.GetAttributeValue("content", null);
}

This allows us to prioritize OG tags but fall back gracefully to standard SEO metadata.

Grabbing the Page Title

The <title> tag is straightforward but essential:

private static string GetTitleTag(HtmlDocument doc)
{
    var titleNode = doc.DocumentNode.SelectSingleNode("//head/title");
    return titleNode?.InnerText?.Trim();
}

Finding the Favicon

Favicons come in many forms. We check them in order of preference:

<link rel="icon" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />

Our parser tries each rel value in sequence:

private static string GetFaviconUrl(HtmlDocument doc, Uri baseUri)
{
    var selectors = new[]
    {
        "//link[@rel='icon']",
        "//link[@rel='shortcut icon']",
        "//link[@rel='apple-touch-icon']",
        "//link[@rel='apple-touch-icon-precomposed']"
    };

    foreach (var selector in selectors)
    {
        var node = doc.DocumentNode.SelectSingleNode(selector);
        var href = node?.GetAttributeValue("href", null);
        if (!string.IsNullOrWhiteSpace(href))
        {
            return ResolveUrl(href, baseUri); // Handle relative URLs
        }
    }

    // Default fallback
    return new Uri(baseUri, "/favicon.ico").ToString();
}

This ensures we get the best available icon — even if it's hosted externally or uses a relative path.


Real-World Example: YouTube

Let’s say we’re fetching a YouTube video at https://www.youtube.com/watch?v=dQw4w9WgXcQ. The page includes:

<meta property="og:title" content="Rick Astley - Never Gonna Give You Up (Official Music Video)">
<meta property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg">
<meta property="og:site_name" content="YouTube">
<link rel="icon" href="https://www.youtube.com/s/desktop/favicon.ico">

Our service parses this and returns structured JSON:

{
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "title": "Rick Astley - Never Gonna Give You Up (Official Music Video)",
  "description": "The official video for \"Never Gonna Give You Up\" by Rick Astley...",
  "image": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
  "siteName": "YouTube",
  "favicon": "https://www.youtube.com/s/desktop/favicon.ico"
}

Clean, consistent, and ready for rendering.


How the Link Preview Service Works

The full flow from user action to preview rendering:

  1. ✅ User pastes a URL
  2. 🚀 Client sends URL to a GraphQL endpoint
  3. 🔽 Backend fetches the target page's HTML
  4. 🧠 Parses and extracts OG tags and favicon
  5. 💾 Caches the result for future use
  6. 📤 Returns structured preview data
  7. 🖼️ Client renders a rich card
graph LR
Client --> GraphQL --> LinkPreviewService --> HTTP[Fetch HTML] --> Parser --> Cache --> Response --> Client

This pipeline ensures we only do expensive work once — and deliver quickly thereafter.


GraphQL Integration

We expose the preview functionality via a simple GraphQL query:

query LinkPreview($url: String!) {
  linkPreview(url: $url) {
    url
    title
    description
    image
    siteName
    favicon
  }
}

This returns structured data that any frontend can use to build a consistent preview component.

Example Response

{
  "data": {
    "linkPreview": {
      "url": "https://github.com/dotnet/aspnetcore",
      "title": "GitHub - dotnet/aspnetcore",
      "description": "ASP.NET Core is a cross-platform .NET framework...",
      "image": "https://opengraph.githubassets.com/...",
      "siteName": "GitHub",
      "favicon": "https://github.githubassets.com/favicons/favicon.svg"
    }
  }
}

Performance & Trade-offs

Fetching external URLs introduces several challenges:

ChallengeWhy It Matters
Network latencySlows down preview generation
Repeated requestsWastes bandwidth and time
External failuresSites may be down or block requests
Stale dataContent changes but preview doesn’t update

We address these with smart design decisions.


Caching Strategy: Speed Meets Efficiency

To minimize redundant network calls, we cache results for 24 hours using a normalized URL hash.

Cache Key Generation

We use SHA256 to create consistent, unique keys:

private static string GetCacheKey(string url)
{
    using var sha256 = SHA256.Create();
    var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(url.ToLowerInvariant()));
    return $"LinkPreview:{Convert.ToBase64String(hashBytes)}";
}

Normalizing the URL (lowercase, trimmed) ensures HTTP://EXAMPLE.COM and http://example.com are treated the same.

Caching Best Practices

  • ✅ Cache successful results for 24 hours
  • ✅ Cache failed attempts too — prevents hammering broken links
  • ✅ Use IMemoryCache for fast, in-process access
  • ✅ Log cache hits/misses for observability

Example logs:

[LinkPreview] CACHE MISS - Fetching URL: https://github.com/dotnet/aspnetcore
[LinkPreview] CACHE HIT for URL: https://github.com/dotnet/aspnetcore

This keeps your system fast and resilient.


Optimize Further: Embed Previews in Item Data

To reduce round trips, we embed the link preview directly in item responses — if it’s already cached.

How It Works

  • When a client requests an item with a URL…
  • The server checks if a preview is cached
  • If yes → include it in the response
  • If no → return linkPreview: null, and the client can fetch it separately
query Item($id: String!) {
  item(id: $id) {
    id
    name
    link
    linkPreview {
      url
      title
      description
      image
      siteName
      favicon
    }
  }
}

Case 1: Preview Cached ✅

{
  "item": {
    "link": "https://github.com/dotnet/aspnetcore",
    "linkPreview": { "title": "GitHub - dotnet/aspnetcore", ... }
  }
}

Case 2: Not Cached ⏳

{
  "item": {
    "link": "https://example.com/new-link",
    "linkPreview": null
  }
}

This "opportunistic embedding" means users get instant previews when possible — without forcing expensive fetches every time.


Client-Side Implementation Guide

On the frontend, handling previews smoothly requires a two-step strategy.

Pseudocode: Fetch with Fallback

async function fetchItemDetails(itemId: string) {
  const itemResponse = await graphqlClient.query({
    query: ITEM_QUERY,
    variables: { id: itemId }
  });

  const item = itemResponse.data.item;

  if (item.linkPreview) {
    // ✅ Already available — render immediately
    renderLinkPreview(item.linkPreview);
  } else if (item.link) {
    // 🔄 Fetch preview separately
    const previewResponse = await graphqlClient.query({
      query: LINK_PREVIEW_QUERY,
      variables: { url: item.link }
    });
    renderLinkPreview(previewResponse.data.linkPreview);
  }
}

Client Checklist

TaskRecommendation
✅ Update queriesAdd linkPreview { ... } to item queries
✅ Handle nullAlways check if preview exists before rendering
✅ Fallback fetchCall linkPreview(url) if not embedded
✅ Show loadingUse skeleton loaders during async fetch
✅ Optional cachingStore previews in local state or localStorage

This approach balances performance and UX — fast when possible, graceful when not.


Why This Architecture Works

We made several key design decisions to ensure reliability and efficiency:

  1. SHA256 cache keys → Consistent, secure, and URL-length agnostic
  2. Fail-fast caching → Even errors are cached to avoid repeated failures
  3. Fallback extraction → Ensures previews work even with incomplete OG tags
  4. Relative URL resolution → Correctly resolves /images/preview.jpg to full URLs
  5. User-Agent header → Reduces chance of being blocked as a bot
  6. Opportunistic embedding → Minimizes client round trips
  7. Cache peekTryGetCachedPreview never triggers a network call — safe for frequent checks

Dependencies You’ll Need

This implementation relies on a few battle-tested tools:

  • HtmlAgilityPack – Robust HTML parsing (even for malformed markup)
  • IMemoryCache – Fast in-memory caching (part of .NET)
  • ILogger – For monitoring cache behavior and errors
  • HttpClient – To fetch external URLs with proper timeouts and headers

All are lightweight and integrate easily into modern backends.


Key Takeaways

Building rich link previews isn’t just about pulling metadata — it’s about doing it smartly and efficiently.

Use Open Graph tags to generate accurate, attractive previews

🔁 Cache aggressively to reduce latency and external requests

🛡️ Handle failures gracefully — both in parsing and network calls

Embed when possible to reduce client-side round trips

🎯 Prioritize UX with loading states and fallback behaviors

With this approach, your application delivers a polished, social-media-like sharing experience — turning plain links into engaging visual content.

Ready to make your links shine? Start with OG tags, add a parser, and layer in caching. The result? A seamless preview system that feels fast, reliable, and modern.

Contents