shadimage
unlumen-ui
uinotion-mention-linksource: ok · hit

Notion Mention Link

A Notion-inspired rich link mention with a securely fetched preview card on hover.

npx shadcn add "https://ui.unlumen.com/r/notion-mention-link.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 · medium
frameworknext

Registry dependencies

hover-cardskeleton

Files3 · source cached 10/8/2026, 2:12:01 PM

registry/components/unlumen/notion-mention-link/index.tsx
"use client";

import * as React from "react";

import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";

export type NotionMentionLinkMetadata = {
  url: string;
  title: string;
  description?: string;
  siteName: string;
  domain: string;
  image?: string;
  favicon?: string;
};

export type NotionMentionLinkProps = Omit<
  React.ComponentPropsWithRef<"a">,
  "children" | "href"
> & {
  url: string;
  endpoint?: string;
  metadata?: NotionMentionLinkMetadata;
  prefetch?: "mount" | "hover";
  openDelay?: number;
  closeDelay?: number;
  previewWidth?: number;
  previewHeight?: number;
  align?: "start" | "end";
  showPreview?: boolean;
  invalidLabel?: string;
  previewClassName?: string;
};

const metadataCache = new Map<string, NotionMentionLinkMetadata>();

function normalizeUrl(value: string) {
  const trimmed = value.trim();
  if (!trimmed) return "";

  return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
}

function isValidWebsiteUrl(value: string) {
  if (!value || /\s/.test(value)) return false;

  try {
    const parsed = new URL(value);
    const labels = parsed.hostname.split(".");

    return (
      ["http:", "https:"].includes(parsed.protocol) &&
      labels.length >= 2 &&
      labels.every((label) =>
        /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i.test(label),
      )
    );
  } catch {
    return false;
  }
}

function getDomain(url: string) {
  try {
    return new URL(url).hostname.replace(/^www\./, "");
  } catch {
    return url;
  }
}

function getFallbackMetadata(url: string): NotionMentionLinkMetadata {
  const domain = getDomain(url);

  return {
    url,
    title: domain,
    siteName: domain.split(".")[0] || domain,
    domain,
  };
}

function getAssetUrl(endpoint: string, url?: string) {
  if (!url) return undefined;

  const params = new URLSearchParams({ asset: "1", url });
  return `${endpoint}${endpoint.includes("?") ? "&" : "?"}${params.toString()}`;
}

function SiteIcon({
  src,
  label,
  large = false,
}: {
  src?: string;
  label: string;
  large?: boolean;
}) {
  const [failed, setFailed] = React.useState(false);

  React.useEffect(() => setFailed(false), [src]);

  if (!src || failed) {
    return (
      <span
        className={cn(
          "inline-flex shrink-0 items-center justify-center rounded bg-foreground font-semibold uppercase text-background",
          large ? "size-6 text-[10px]" : "size-[22px] text-[9px]",
        )}
        aria-hidden="true"
      >
        {label.charAt(0)}
      </span>
    );
  }

  return (
    // The same-origin endpoint validates and proxies this remote asset.
    // eslint-disable-next-line @next/next/no-img-element
    <img
      src={src}
      alt=""
      className={cn(
        "shrink-0 rounded object-cover",
        large ? "size-6" : "size-[22px]",
      )}
      onError={() => setFailed(true)}
      aria-hidden="true"
    />
  );
}

function PreviewSkeleton({ height }: { height: number }) {
  return (
    <div className="overflow-hidden rounded-xl border border-border bg-card shadow-xl shadow-foreground/10">
      <Skeleton className="rounded-none" style={{ height }} />
      <div className="flex flex-col gap-3 p-5">
        <Skeleton className="h-5 w-4/5" />
        <Skeleton className="h-4 w-full" />
        <Skeleton className="h-4 w-2/3" />
      </div>
    </div>
  );
}

function PreviewImage({ src, height }: { src: string; height: number }) {
  const [failed, setFailed] = React.useState(false);

  React.useEffect(() => setFailed(false), [src]);

  if (failed) return null;

  return (
    // The same-origin endpoint validates and proxies this remote asset.
    // eslint-disable-next-line @next/next/no-img-element
    <img
      src={src}
      alt=""
      className="block w-full bg-muted object-cover"
      style={{ height }}
      onError={() => setFailed(true)}
      aria-hidden="true"
    />
  );
}

function NotionMentionLink({
  url,
  endpoint = "/api/notion-mention-link",
  metadata: suppliedMetadata,
  prefetch = "mount",
  openDelay = 180,
  closeDelay = 120,
  previewWidth = 310,
  previewHeight = 140,
  align = "start",
  showPreview = true,
  invalidLabel = "Enter a valid website URL.",
  previewClassName,
  className,
  target = "_blank",
  rel = "noopener noreferrer",
  onMouseEnter,
  onMouseLeave,
  onFocus,
  onBlur,
  ref,
  ...props
}: NotionMentionLinkProps) {
  const normalizedUrl = React.useMemo(() => normalizeUrl(url), [url]);
  const isValidUrl = React.useMemo(
    () => isValidWebsiteUrl(normalizedUrl),
    [normalizedUrl],
  );
  const fallback = React.useMemo(
    () => getFallbackMetadata(normalizedUrl),
    [normalizedUrl],
  );
  const [loadedMetadata, setLoadedMetadata] = React.useState<
    NotionMentionLinkMetadata | undefined
  >(() => suppliedMetadata ?? metadataCache.get(normalizedUrl));
  const [status, setStatus] = React.useState<
    "idle" | "loading" | "ready" | "error" | "invalid"
  >(
    !isValidUrl
      ? "invalid"
      : suppliedMetadata || metadataCache.has(normalizedUrl)
        ? "ready"
        : "idle",
  );
  const requestRef = React.useRef<AbortController | null>(null);
  const previewId = React.useId();
  const currentMetadata = suppliedMetadata ?? loadedMetadata ?? fallback;

  const loadMetadata = React.useCallback(async () => {
    if (!isValidUrl) {
      setStatus("invalid");
      return;
    }

    if (suppliedMetadata || metadataCache.has(normalizedUrl)) {
      const cached = metadataCache.get(normalizedUrl);
      if (cached) {
        setLoadedMetadata(cached);
        setStatus("ready");
      }
      return;
    }

    requestRef.current?.abort();
    const controller = new AbortController();
    requestRef.current = controller;
    setStatus("loading");

    try {
      const separator = endpoint.includes("?") ? "&" : "?";
      const response = await fetch(
        `${endpoint}${separator}${new URLSearchParams({ url: normalizedUrl }).toString()}`,
        { signal: controller.signal },
      );

      if (!response.ok) throw new Error("Preview request failed");

      const data = (await response.json()) as NotionMentionLinkMetadata;
      metadataCache.set(normalizedUrl, data);
      setLoadedMetadata(data);
      setStatus("ready");
    } catch (error) {
      if (error instanceof DOMException && error.name === "AbortError") return;
      setStatus("error");
    }
  }, [endpoint, isValidUrl, normalizedUrl, suppliedMetadata]);

  React.useEffect(() => {
    requestRef.current?.abort();
    setLoadedMetadata(suppliedMetadata ?? metadataCache.get(normalizedUrl));
    setStatus(
      !isValidUrl
        ? "invalid"
        : suppliedMetadata || metadataCache.has(normalizedUrl)
          ? "ready"
          : "idle",
    );
  }, [isValidUrl, normalizedUrl, suppliedMetadata]);

  React.useEffect(() => {
    if (prefetch === "mount") void loadMetadata();

    return () => {
      requestRef.current?.abort();
    };
  }, [loadMetadata, prefetch]);

  const faviconUrl = getAssetUrl(endpoint, currentMetadata.favicon);
  const imageUrl = getAssetUrl(endpoint, currentMetadata.image);

  if (!isValidUrl || status === "invalid") {
    return (
      <span
        role="status"
        className="inline-flex min-h-7 items-center text-sm text-destructive"
      >
        {invalidLabel}
      </span>
    );
  }

  const mention = (
    <a
      ref={ref}
      href={normalizedUrl}
      target={target}
      rel={rel}
      className={cn(
        "inline-flex max-w-full items-center gap-1.5 rounded-[5px] px-1 py-0.5 text-[15px] leading-6 text-foreground outline-none transition-colors duration-100",
        "hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
        className,
      )}
      onMouseEnter={(event) => {
        if (prefetch === "hover" || status === "idle") void loadMetadata();
        onMouseEnter?.(event);
      }}
      onMouseLeave={(event) => {
        onMouseLeave?.(event);
      }}
      onFocus={(event) => {
        if (prefetch === "hover" || status === "idle") void loadMetadata();
        onFocus?.(event);
      }}
      onBlur={(event) => {
        onBlur?.(event);
      }}
      {...props}
    >
      <SiteIcon src={faviconUrl} label={currentMetadata.siteName} />
      <span className="min-w-0 truncate text-muted-foreground">
        {currentMetadata.siteName}
      </span>
      <span className="min-w-0 truncate font-medium underline decoration-foreground/35 underline-offset-[3px]">
        {currentMetadata.title}
      </span>
    </a>
  );

  if (!showPreview) return mention;

  return (
    <HoverCard
      openDelay={openDelay}
      closeDelay={closeDelay}
      onOpenChange={(nextOpen) => {
        if (nextOpen && (prefetch === "hover" || status === "idle")) {
          void loadMetadata();
        }
      }}
    >
      <HoverCardTrigger asChild>{mention}</HoverCardTrigger>
      <HoverCardContent
        id={previewId}
        align={align}
        side="bottom"
        sideOffset={8}
        style={{ width: previewWidth }}
        className={cn(
          "max-w-[calc(100vw-2rem)] border-0 bg-transparent p-0 shadow-none ring-0 will-change-transform",
          "data-[state=open]:animate-in data-[state=open]:fade-in-0  data-[state=open]:slide-in-from-top-1 data-[state=open]:duration-150",
          "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-top-1 data-[state=closed]:duration-150",
          previewClassName,
        )}
      >
        {status === "loading" || status === "idle" ? (
          <PreviewSkeleton height={previewHeight} />
        ) : (
          <span className="block overflow-hidden rounded-xl border border-border bg-card text-left shadow-xl shadow-foreground/10">
            {imageUrl ? (
              <PreviewImage src={imageUrl} height={previewHeight} />
            ) : null}

            <span className="flex flex-col gap-2 px-5 pb-5 pt-4">
              <strong className="line-clamp-2 text-lg font-semibold leading-6 text-foreground">
                {currentMetadata.title}
              </strong>
              {status === "error" ? (
                <span className="text-sm leading-5 text-muted-foreground">
                  Preview unavailable. The link is still safe to open directly.
                </span>
              ) : currentMetadata.description ? (
                <span className="line-clamp-3 text-sm leading-5 text-foreground/85">
                  {currentMetadata.description}
                </span>
              ) : null}
              <span className="mt-2 flex items-center gap-2 text-sm font-medium text-muted-foreground">
                <SiteIcon
                  src={faviconUrl}
                  label={currentMetadata.siteName}
                  large
                />
                <span className="truncate">{currentMetadata.domain}</span>
              </span>
            </span>
          </span>
        )}
      </HoverCardContent>
    </HoverCard>
  );
}

export { NotionMentionLink };
registry/components/unlumen/notion-mention-link/link-preview.server.ts
import { lookup } from "node:dns/promises";
import http from "node:http";
import https from "node:https";
import { isIP } from "node:net";

export type SafeLinkPreview = {
  url: string;
  title: string;
  description?: string;
  siteName: string;
  domain: string;
  image?: string;
  favicon?: string;
};

type SafeResponse = {
  body: Buffer;
  contentType: string;
  finalUrl: URL;
};

const MAX_HTML_BYTES = 512_000;
const MAX_ASSET_BYTES = 5_000_000;
const MAX_REDIRECTS = 3;
const REQUEST_TIMEOUT_MS = 5_000;

function isPrivateIpv4(address: string) {
  const octets = address.split(".").map(Number);
  const [a, b] = octets;

  return (
    a === 0 ||
    a === 10 ||
    a === 127 ||
    (a === 169 && b === 254) ||
    (a === 172 && b !== undefined && b >= 16 && b <= 31) ||
    (a === 192 && b === 168) ||
    (a === 100 && b !== undefined && b >= 64 && b <= 127) ||
    (a === 192 && b === 0) ||
    (a === 198 && (b === 18 || b === 19)) ||
    (a === 198 && b === 51 && octets[2] === 100) ||
    (a === 203 && b === 0 && octets[2] === 113) ||
    (a !== undefined && a >= 224)
  );
}

function isPrivateIpv6(address: string) {
  const normalized = address.toLowerCase().split("%")[0] ?? address;

  return (
    normalized === "::" ||
    normalized === "::1" ||
    normalized.startsWith("fc") ||
    normalized.startsWith("fd") ||
    /^fe[89ab]/.test(normalized) ||
    normalized.startsWith("ff") ||
    normalized.startsWith("2001:db8:") ||
    (normalized.startsWith("::ffff:") &&
      isPrivateIpv4(normalized.slice("::ffff:".length)))
  );
}

function isPublicAddress(address: string) {
  const version = isIP(address);
  if (version === 4) return !isPrivateIpv4(address);
  if (version === 6) return !isPrivateIpv6(address);
  return false;
}

async function validateUrl(input: string) {
  const url = new URL(input);

  if (!["http:", "https:"].includes(url.protocol)) {
    throw new Error("Only HTTP and HTTPS URLs are supported");
  }
  if (url.username || url.password)
    throw new Error("URL credentials are not allowed");
  if (url.port && !["80", "443"].includes(url.port)) {
    throw new Error("Non-standard ports are not allowed");
  }

  const hostname = url.hostname.toLowerCase().replace(/\.$/, "");
  if (
    hostname === "localhost" ||
    hostname.endsWith(".localhost") ||
    hostname.endsWith(".local") ||
    hostname.endsWith(".internal")
  ) {
    throw new Error("Local network hosts are not allowed");
  }

  const literalVersion = isIP(hostname);
  const addresses = literalVersion
    ? [{ address: hostname, family: literalVersion }]
    : await lookup(hostname, { all: true, verbatim: true });

  if (
    addresses.length === 0 ||
    addresses.some(({ address }) => !isPublicAddress(address))
  ) {
    throw new Error("Private or unresolved hosts are not allowed");
  }

  return { url, addresses };
}

async function requestUrl(
  input: string,
  options: { maxBytes: number; accept: string },
  redirects = 0,
): Promise<SafeResponse> {
  const { url, addresses } = await validateUrl(input);
  const selected = addresses[0];
  if (!selected) throw new Error("Host did not resolve");

  const transport = url.protocol === "https:" ? https : http;

  return new Promise((resolve, reject) => {
    const request = transport.request(
      url,
      {
        method: "GET",
        headers: {
          accept: options.accept,
          "accept-encoding": "identity",
          "user-agent": "UnlumenLinkPreview/1.0 (+https://ui.unlumen.com)",
        },
        lookup: (_hostname, lookupOptions, callback) => {
          if (typeof lookupOptions === "object" && lookupOptions.all) {
            callback(null, [selected]);
            return;
          }

          callback(null, selected.address, selected.family as 4 | 6);
        },
      },
      (response) => {
        const status = response.statusCode ?? 500;
        const location = response.headers.location;

        if (status >= 300 && status < 400 && location) {
          response.resume();
          if (redirects >= MAX_REDIRECTS) {
            reject(new Error("Too many redirects"));
            return;
          }

          const redirectUrl = new URL(location, url).toString();
          void requestUrl(redirectUrl, options, redirects + 1).then(
            resolve,
            reject,
          );
          return;
        }

        if (status < 200 || status >= 300) {
          response.resume();
          reject(new Error(`Remote server returned ${status}`));
          return;
        }

        const declaredLength = Number(response.headers["content-length"] ?? 0);
        if (declaredLength > options.maxBytes) {
          response.destroy(new Error("Remote response is too large"));
          return;
        }

        const chunks: Buffer[] = [];
        let received = 0;

        response.on("data", (chunk: Buffer) => {
          received += chunk.length;
          if (received > options.maxBytes) {
            response.destroy(new Error("Remote response is too large"));
            return;
          }
          chunks.push(chunk);
        });
        response.on("end", () => {
          resolve({
            body: Buffer.concat(chunks),
            contentType: String(response.headers["content-type"] ?? ""),
            finalUrl: url,
          });
        });
        response.on("error", reject);
      },
    );

    request.setTimeout(REQUEST_TIMEOUT_MS, () => {
      request.destroy(new Error("Remote request timed out"));
    });
    request.on("error", reject);
    request.end();
  });
}

function decodeEntities(value: string) {
  const entities: Record<string, string> = {
    amp: "&",
    apos: "'",
    gt: ">",
    lt: "<",
    quot: '"',
  };

  return value
    .replace(/&#(\d+);/g, (_, code: string) =>
      String.fromCodePoint(Number(code)),
    )
    .replace(/&#x([\da-f]+);/gi, (_, code: string) =>
      String.fromCodePoint(Number.parseInt(code, 16)),
    )
    .replace(
      /&([a-z]+);/gi,
      (entity, name: string) => entities[name.toLowerCase()] ?? entity,
    )
    .replace(/\s+/g, " ")
    .trim();
}

function getAttribute(tag: string, name: string) {
  const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, "i"));
  return match?.[2] ? decodeEntities(match[2]) : undefined;
}

function getMeta(html: string, keys: string[]) {
  for (const tag of html.match(/<meta\b[^>]*>/gi) ?? []) {
    const key = getAttribute(tag, "property") ?? getAttribute(tag, "name");
    if (key && keys.includes(key.toLowerCase())) {
      const content = getAttribute(tag, "content");
      if (content) return content;
    }
  }
  return undefined;
}

function getFavicon(html: string) {
  for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
    const rel = getAttribute(tag, "rel")?.toLowerCase();
    if (rel?.split(/\s+/).includes("icon")) return getAttribute(tag, "href");
  }
  return undefined;
}

function resolveRemoteUrl(value: string | undefined, base: URL) {
  if (!value) return undefined;
  try {
    const url = new URL(value, base);
    return ["http:", "https:"].includes(url.protocol)
      ? url.toString()
      : undefined;
  } catch {
    return undefined;
  }
}

export async function getSafeLinkPreview(
  input: string,
): Promise<SafeLinkPreview> {
  const response = await requestUrl(input, {
    maxBytes: MAX_HTML_BYTES,
    accept: "text/html,application/xhtml+xml",
  });

  if (!response.contentType.toLowerCase().includes("text/html")) {
    throw new Error("The URL did not return HTML");
  }

  const html = response.body.toString("utf8");
  const titleTag = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1];
  const domain = response.finalUrl.hostname.replace(/^www\./, "");
  const title =
    getMeta(html, ["og:title", "twitter:title"]) ??
    (titleTag ? decodeEntities(titleTag.replace(/<[^>]+>/g, "")) : undefined) ??
    domain;
  const siteName =
    getMeta(html, ["og:site_name"]) ?? domain.split(".")[0] ?? domain;

  return {
    url: response.finalUrl.toString(),
    title: title.slice(0, 200),
    description: getMeta(html, [
      "og:description",
      "twitter:description",
      "description",
    ])?.slice(0, 500),
    siteName: siteName.slice(0, 80),
    domain,
    image: resolveRemoteUrl(
      getMeta(html, ["og:image:secure_url", "og:image", "twitter:image"]),
      response.finalUrl,
    ),
    favicon: resolveRemoteUrl(
      getFavicon(html) ?? "/favicon.ico",
      response.finalUrl,
    ),
  };
}

export async function getSafeRemoteAsset(input: string) {
  const response = await requestUrl(input, {
    maxBytes: MAX_ASSET_BYTES,
    accept:
      "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml",
  });

  if (!response.contentType.toLowerCase().startsWith("image/")) {
    throw new Error("The URL did not return an image");
  }

  return { body: response.body, contentType: response.contentType };
}
registry/components/unlumen/notion-mention-link/route.ts
import { NextResponse, type NextRequest } from "next/server";

import {
  getSafeLinkPreview,
  getSafeRemoteAsset,
} from "@/lib/notion-link-preview.server";

export const runtime = "nodejs";

const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX_REQUESTS = 60;
const rateLimitBuckets = new Map<string, number[]>();

function isRateLimited(request: NextRequest) {
  const forwardedFor = request.headers.get("x-forwarded-for")?.split(",")[0];
  const client =
    forwardedFor?.trim() || request.headers.get("x-real-ip") || "anonymous";
  const now = Date.now();
  const recent = (rateLimitBuckets.get(client) ?? []).filter(
    (timestamp) => now - timestamp < RATE_LIMIT_WINDOW_MS,
  );

  if (recent.length >= RATE_LIMIT_MAX_REQUESTS) return true;
  rateLimitBuckets.set(client, [...recent, now]);

  if (rateLimitBuckets.size > 1_000) {
    for (const [key, timestamps] of rateLimitBuckets) {
      if (
        timestamps.every((timestamp) => now - timestamp >= RATE_LIMIT_WINDOW_MS)
      ) {
        rateLimitBuckets.delete(key);
      }
    }
  }

  return false;
}

export async function GET(request: NextRequest) {
  if (isRateLimited(request)) {
    return NextResponse.json(
      { error: "Too many preview requests." },
      { status: 429, headers: { "Retry-After": "60" } },
    );
  }

  const url = request.nextUrl.searchParams.get("url");
  if (!url || url.length > 2_048) {
    return NextResponse.json(
      { error: "A valid URL is required." },
      { status: 400 },
    );
  }

  try {
    if (request.nextUrl.searchParams.get("asset") === "1") {
      const asset = await getSafeRemoteAsset(url);
      return new NextResponse(new Uint8Array(asset.body), {
        headers: {
          "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
          "Content-Type": asset.contentType,
          "X-Content-Type-Options": "nosniff",
        },
      });
    }

    const preview = await getSafeLinkPreview(url);
    return NextResponse.json(preview, {
      headers: {
        "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
        "X-Content-Type-Options": "nosniff",
      },
    });
  } catch (error) {
    console.warn("Notion mention preview rejected", error);
    return NextResponse.json(
      { error: "Preview unavailable." },
      { status: 422 },
    );
  }
}

More from unlumen-ui

Browse all components in this library →