shadimage
lens-blocks
componentlink-previewsource: ok · hit

Link Preview

A component to display a link preview for URLs included in Lens posts, showing the title, description, and image.

npx shadcn add "https://lensblocks.com/r/link-preview.json"
free

Where it goes

testimonial

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
node-html-parser@7.0.1

Registry dependencies

https://lensblocks.com/r/utils.json

Files4 · source cached 10/14/2026, 2:18:35 PM

registry/new-york/components/feed/link-preview.tsx
import { useEffect, useState } from "react";
import { UrlMetadata } from "@/registry/new-york/lib/get-meta-from-url";
import getFavicon from "@/registry/new-york/lib/get-favicon";
import { truncateUrl } from "@/registry/new-york/lib/lens-utils";

type Props = {
  url: string;
};

export const LinkPreview = (props: Props) => {
  const { url } = props;

  const [urlMetadata, setUrlMetadata] = useState<UrlMetadata | null>(null);

  const favicon = getFavicon(url);

  useEffect(() => {
    if (!url) return;
    fetch(`/api/url-meta?url=${encodeURIComponent(url)}`)
      .then(r => r.json())
      .then(setUrlMetadata);
  }, [url]);

  if (!urlMetadata) {
    return null;
  }

  return (
    <div onClick={event => event.stopPropagation()}>
      <a
        href={url}
        className="w-full max-w-sm flex flex-col bg-card text-card-foreground border rounded-lg mb-4 cursor-pointer hover:!no-underline"
        target="_blank"
        rel="noopener noreferrer"
      >
        <div className="flex flex-col p-2 flex-1 min-w-0 gap-0.5 overflow-hidden">
          <div className="flex gap-1 items-center text-xs font-normal">
            <img
              src={urlMetadata.icon ?? favicon}
              width={24}
              height={24}
              alt={`${url} favicon`}
              className="w-4 h-4 p-0.5"
            />
            {urlMetadata.siteName && <div className="line-clamp-1 opacity-60">{urlMetadata.siteName + " • "}</div>}
            <div className="flex-none opacity-60">{truncateUrl(urlMetadata.url)}</div>
          </div>
          {urlMetadata.title && <div className="line-clamp-1 !font-bold text-sm">{urlMetadata.title}</div>}
          {urlMetadata.description && (
            <div className="line-clamp-2 text-xs !font-normal">{urlMetadata.description}</div>
          )}
        </div>
      </a>
    </div>
  );
};
registry/new-york/lib/get-meta-from-url.ts
import { parse } from "node-html-parser";

export type UrlMetadata = {
  url: string;
  title: string | null;
  description: string | null;
  siteName: string | null;
  icon: string | null;
};

type Props = {
  url: string;
  userAgent?: string;
};

export default async function getMetaFromUrl({ url, userAgent = "LensBot/0.1 (like TwitterBot)" }: Props) {
  const res = await fetch(url, { headers: { "User-Agent": userAgent }, redirect: "follow" });
  if (!res.ok) return { url, title: null, description: null, siteName: null, icon: null };

  const html = await res.text();
  const root = parse(html);

  const pick = (names: string[]) => {
    for (const name of names) {
      const tag = root.querySelector(`meta[name="${name}"]`) || root.querySelector(`meta[property="${name}"]`);
      const val = tag?.getAttribute("content");
      if (val) return val;
    }
    return null;
  };

  const title = pick(["og:title", "twitter:title"]) || root.querySelector("title")?.text.trim() || null;

  const description = pick(["og:description", "twitter:description", "description"]) || null;

  const siteName = pick(["og:site_name"]) || null;

  const iconTags = root.querySelectorAll(
    'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"], link[rel="mask-icon"]',
  );

  let icon: string | null = null;
  let bestScore = -1;
  for (const el of iconTags) {
    const href = el.getAttribute("href");
    if (!href) continue;
    let score = href.toLowerCase().endsWith(".svg") ? 1000 : 0;
    const sizes = el.getAttribute("sizes");
    if (sizes) {
      const m = sizes.match(/(\d+)x(\d+)/);
      if (m) score += Math.max(parseInt(m[1], 10), parseInt(m[2], 10));
    }
    if (score > bestScore) {
      bestScore = score;
      icon = new URL(href, url).toString();
    }
  }
  if (!icon) icon = new URL("/favicon.ico", url).toString();

  return { url, title, description, siteName, icon };
}
registry/new-york/lib/get-favicon.ts
const FAVICON_BASE_URL = "https://external-content.duckduckgo.com/ip3";
const UNKNOWN_DOMAIN = "unknowndomain";

const getFavicon = (url: string): string => {
  const domain = (() => {
    try {
      return new URL(url).hostname;
    } catch {
      return UNKNOWN_DOMAIN;
    }
  })();

  return `${FAVICON_BASE_URL}/${domain || UNKNOWN_DOMAIN}.ico`;
};

export default getFavicon;
registry/new-york/files/api/url-meta/route.ts
import { NextResponse } from "next/server";
import getMetaFromUrl from "@/registry/new-york/lib/get-meta-from-url";

export const dynamic = "force-dynamic";

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const url = searchParams.get("url");
  if (!url) return NextResponse.json({ error: "url required" }, { status: 400 });
  const meta = await getMetaFromUrl({ url });
  return NextResponse.json(meta, { headers: { "Cache-Control": "max-age=0, s-maxage=3600" } });
}

More from lens-blocks

Browse all components in this library →