mgx

how to add popover previews for links on hover

You might have seen this "link preview" thing on my website - I implemented it using PHP. But I thought I'd share another approach that's even easier to set up.

This guide will show you how to implement a popover link preview system on your blog in minutes, without a complex backend or heavy plugins, by leveraging Cloudflare Workers (free tier) and a small piece of JavaScript.

The popover preview system has two parts:

  1. A Cloudflare Worker that fetches and parses web pages
  2. A frontend script that handles hover events and shows popover previews

The Back-end (Cloudflare Worker)

export default {
  async fetch(request) {
    const { searchParams } = new URL(request.url);
    const targetUrl = searchParams.get("url");

    const origin = request.headers.get("Origin");
    const allowedOrigin = "https://yourdomain.com";
    // Good enough to prevent casual hotlinking.

    // Build CORS headers only if Origin matches
    const corsHeaders = {
      "Content-Type": "application/json",
      "Access-Control-Allow-Methods": "GET, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type",
      ...(origin === allowedOrigin ? { "Access-Control-Allow-Origin": allowedOrigin } : {}),
    };

    // Handle preflight OPTIONS requests
    if (request.method === "OPTIONS") {
      return new Response(null, { headers: corsHeaders });
    }

    if (!targetUrl) {
      return new Response(JSON.stringify({ error: "No URL provided" }), {
        status: 400,
        headers: corsHeaders,
      });
    }

    // Validate URL
    let url;
    try {
      url = new URL(targetUrl);
    } catch (e) {
      return new Response(JSON.stringify({ error: "Invalid URL" }), {
        status: 400,
        headers: corsHeaders,
      });
    }

    let html;
    try {
      const controller = new AbortController();
      const timeout = setTimeout(() => controller.abort(), 10000);
      const resp = await fetch(url.toString(), {
        redirect: "follow",
        signal: controller.signal,
        headers: {
          "User-Agent":
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
            "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
          "Accept":
            "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
          "Accept-Language": "en-US,en;q=0.9",
          "Connection": "keep-alive",
        },
      });
      clearTimeout(timeout);
      html = await resp.text();
    } catch (e) {
      return new Response(
        JSON.stringify({ error: "Failed to fetch the URL (timeout or blocked)" }),
        { status: 500, headers: corsHeaders }
      );
    }

    const getMeta = (pattern) => {
      const match = html.match(pattern);
      return match ? match[1] : null;
    };

    let title =
      getMeta(/<title>(.*?)<\/title>/i) ||
      getMeta(/<meta[^>]+property=["']og:title["'][^>]+content=["'](.*?)["']/i) ||
      getMeta(/<meta[^>]+name=["']twitter:title["'][^>]+content=["'](.*?)["']/i);

    let description =
      getMeta(/<meta[^>]+name=["']description["'][^>]+content=["'](.*?)["']/i) ||
      getMeta(/<meta[^>]+property=["']og:description["'][^>]+content=["'](.*?)["']/i) ||
      getMeta(/<meta[^>]+name=["']twitter:description["'][^>]+content=["'](.*?)["']/i);

    let ogImage =
      getMeta(/<meta[^>]+property=["']og:image["'][^>]+content=["'](.*?)["']/i) ||
      getMeta(/<meta[^>]+name=["']twitter:image["'][^>]+content=["'](.*?)["']/i) ||
      getMeta(/<meta[^>]+name=["']thumbnail["'][^>]+content=["'](.*?)["']/i);

    if (ogImage && !/^https?:\/\//i.test(ogImage)) {
      ogImage = `${url.protocol}//${url.host}${ogImage}`;
    }

    if (!title) {
      const h1 = html.match(/<h1[^>]*>(.*?)<\/h1>/i);
      title = h1 ? h1[1].replace(/<[^>]+>/g, ") : null;
    }

    if (!description) {
      const p = html.match(/<p[^>]*>(.*?)<\/p>/i);
      description = p ? p[1].replace(/<[^>]+>/g, ") : null;
    }

    if (!title && !description && !ogImage) {
      return new Response(JSON.stringify({ error: "No previewable content found" }), {
        status: 204,
        headers: corsHeaders,
      });
    }

    return new Response(
      JSON.stringify({ url: targetUrl, title, description, ogImage }),
      { headers: corsHeaders }
    );
  },
};

The Front-end JS & Popover HTML

<script>
async function showPreview(e) {
  const url = e.target.href;
  const previewBox = document.getElementById("preview-box");

  try {
    const res = await fetch(
      "https://your-worker.your-subdomain.workers.dev/?url=" + encodeURIComponent(url)
    );
    if (!res.ok) return;
    const data = await res.json();

    previewBox.innerHTML = `
      <div style="display:flex; flex-direction:column; gap:4px;">
        <strong>${data.title || "}</strong>
        <em>${data.description || "}</em>
        ${data.ogImage ? `<img src="${data.ogImage}" style="width:100%; height:auto;">` : "}
      </div>
    `;
    previewBox.style.display = "block";

    const boxWidth = previewBox.offsetWidth;
    const boxHeight = previewBox.offsetHeight;
    const pageWidth = window.innerWidth;
    const pageHeight = window.innerHeight;

    // Determine horizontal position 
    let left = e.pageX + 10; 
    if (left + boxWidth > pageWidth) {
      left = e.pageX - boxWidth - 10; 
      if (left < 0) left = 10; 
    }

    // Determine vertical position
    let top = e.pageY + 10;
    if (top + boxHeight > pageHeight) {
      top = pageHeight - boxHeight - 10; 
      if (top < 0) top = 10; 
    }

    previewBox.style.left = left + "px";
    previewBox.style.top = top + "px";
  } catch (err) {
    console.error(err);
  }
}

function hidePreview() {
  document.getElementById("preview-box").style.display = "none";
}

// Only select links inside specific container(s)
const containerSelector = "main"; // main works better for bearblog.dev users
document.querySelectorAll(`${containerSelector} a`).forEach(link => {
  link.addEventListener("mouseover", showPreview);
  link.addEventListener("mouseout", hidePreview);
});
</script>
<div id="preview-box" style="
  position:absolute;
  display:none;
  padding:8px;
  background:#fff;
  border:1px solid #ccc;
  z-index:999;
  width:300px;        
  box-sizing:border-box;
  word-wrap:break-word;
  overflow-wrap:break-word;
"></div>

How To Set It Up

Step 1: Create a Cloudflare Worker

  1. Sign up for Cloudflare (if you don't have an account) at cloudflare.com
  2. Go to Workers & Pages in your Cloudflare dashboard
  3. Click "Create a Worker"
  4. Delete the default code and paste the worker code from worker.js
    • Inside the code, you’ll see:
      const allowedOrigin = "https://yourdomain.com"; 
      
    • Update https://yourdomain.com to your own domain.
  5. Click "Save and Deploy"
  6. Copy your worker URL (it will look like https://your-worker.your-subdomain.workers.dev)

Step 2: Add the Frontend Script + HTML snippet

  1. Open your website's header or footer template
  2. Add the JavaScript code and HTML snippet from client.html to your page
  3. Update the worker URL in the JavaScript:
    const res = await fetch(
      "https://your-worker.your-subdomain.workers.dev/?url=" + encodeURIComponent(url)
    );
    
  4. Add the preview box HTML to your page:
    <!-- customize this -->
    <div id="preview-box" style="
      position:absolute;
      display:none;
      padding:8px;
      background:#fff;
      border:1px solid #ccc;
      z-index:999;
      width:300px;        
      box-sizing:border-box;
      word-wrap:break-word;
      overflow-wrap:break-word;
    "></div>
    

Step 3: Test It

  1. Save your changes and refresh your page
  2. Hover over any link - you should see a popover preview appear
  3. The front-end script is designed to be easily customizable. So make it your own.

Have fun.

Note: The frontend JS for this guide has only been tested for compatibility with bearblog.dev. Users on platforms like WordPress, Ghost, or static sites may need to adjust the containerSelector variable in the script.